// lib/giftbit.js — Client API Giftbit (cartes-cadeaux). VALIDÉ testbed 2026-06-10 (voir memory/reference_giftbit.md).
// Capacités : solde (funds), marketplace (produits), création de campagne (SHORTLINK), statut des cartes,
// révocation (DELETE → GIVER_CANCELLED) pour remplacer un lien compromis.
// ⚠️ Cloudflare bannit les User-Agent non-navigateur (erreur 1010) → on envoie un UA Chrome.
// Secrets : GIFTBIT_TOKEN + GIFTBIT_BASE via ENV (jamais dans le repo). Défaut = TESTBED (aucune vraie carte).
const https = require('https')
const { json, parseBody } = require('./helpers')
const BASE = (process.env.GIFTBIT_BASE || 'https://testbedapp.giftbit.com/papi/v1').replace(/\/+$/, '')
const TOKEN = process.env.GIFTBIT_TOKEN || ''
const UA = 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0 Safari/537.36'
const isTestbed = /testbed/i.test(BASE)
const enc = encodeURIComponent
function api (method, path, body) {
return new Promise((resolve) => {
let u; try { u = new URL(BASE + path) } catch (e) { return resolve({ ok: false, status: 0, error: 'bad url' }) }
const data = body != null ? Buffer.from(JSON.stringify(body)) : null
const headers = { Authorization: 'Bearer ' + TOKEN, Accept: 'application/json', 'User-Agent': UA }
if (data) { headers['Content-Type'] = 'application/json'; headers['Content-Length'] = data.length }
const req = https.request({ hostname: u.hostname, port: 443, path: u.pathname + u.search, method, headers }, (r) => {
let b = ''; r.on('data', c => { b += c }); r.on('end', () => {
let j = null; try { j = JSON.parse(b) } catch (e) {}
resolve({ ok: r.statusCode >= 200 && r.statusCode < 300, status: r.statusCode, body: j, raw: j ? undefined : String(b).slice(0, 300) })
})
})
req.on('error', e => resolve({ ok: false, status: 0, error: String((e && e.message) || e) }))
req.setTimeout(30000, () => { req.destroy(); resolve({ ok: false, status: 0, error: 'timeout' }) })
if (data) req.write(data); req.end()
})
}
const ping = () => api('GET', '/ping')
const funds = () => api('GET', '/funds')
const marketplace = (q = {}) => api('GET', '/marketplace?' + new URLSearchParams({ region: String(q.region || '2'), limit: String(q.limit || '50'), offset: String(q.offset || '0') }).toString())
const brands = (q = {}) => api('GET', '/brands?' + new URLSearchParams({ region: String(q.region || '2'), limit: String(q.limit || '50'), offset: String(q.offset || '0') }).toString())
const listGifts = (q = {}) => { const p = new URLSearchParams(); for (const k of ['campaign_uuid', 'campaign_id', 'limit', 'offset', 'status']) if (q[k] != null && q[k] !== '') p.set(k, String(q[k])); return api('GET', '/gifts?' + p.toString()) }
const getGift = (uuid) => api('GET', '/gifts/' + enc(uuid))
const cancelGift = (uuid) => api('DELETE', '/gifts/' + enc(uuid)) // → GIVER_CANCELLED : révoque le shortlink gtbt.co + rend les fonds
// Construit la liste marketplace_gifts. 1 entrée = reward DIRECT (marque fixée) ; ≥2 = TEMPLATE (le destinataire choisit à la rédemption → brand_code null jusqu'au choix).
function buildMarketplaceGifts (o = {}) {
if (Array.isArray(o.marketplace_gifts) && o.marketplace_gifts.length) return o.marketplace_gifts.map(g => ({ id: Number(g.id), price_in_cents: Number(g.price_in_cents != null ? g.price_in_cents : o.price_in_cents) }))
if (Array.isArray(o.brand_ids) && o.brand_ids.length) return o.brand_ids.map(id => ({ id: Number(id), price_in_cents: Number(o.price_in_cents) }))
return [{ id: Number(o.marketplace_gift_id), price_in_cents: Number(o.price_in_cents) }]
}
// Création de campagne (schéma VALIDÉ) : marketplace_gifts:[{id, price_in_cents}] (PAS "price"/brand_codes) ; message/subject à la racine.
function createCampaign (o = {}) {
const body = {
id: String(o.id),
delivery_type: o.delivery_type || 'SHORTLINK',
expiry: o.expiry || defaultExpiry(),
message: o.message || '',
subject: o.subject || '',
suppress_default_greeting: !!o.suppress_default_greeting,
marketplace_gifts: buildMarketplaceGifts(o),
contacts: (o.contacts || []).map(c => ({ email: c.email, firstname: c.firstname || '', lastname: c.lastname || '' }))
}
return api('POST', '/campaign', body)
}
function defaultExpiry () { const d = new Date(); d.setFullYear(d.getFullYear() + 1); return d.toISOString().slice(0, 10) }
// Remplacer un lien (soupçon de piratage) : révoque l'ancien gift PUIS recrée pour le même contact.
// Renvoie { old:{uuid,shortlink,status}, created } → l'appelant conserve l'ancien en HISTORIQUE.
async function replaceGift (o = {}) {
const cur = await getGift(o.uuid)
const g = cur.body && cur.body.gift
if (!g) return { ok: false, error: 'gift introuvable', detail: cur.body || cur.error }
const cancel = await cancelGift(o.uuid)
if (!cancel.ok) return { ok: false, error: 'révocation échouée', cancel: cancel.body || cancel.error }
const created = await createCampaign({
id: o.new_campaign_id || ('replace-' + String(o.uuid).slice(0, 10) + '-' + Date.now()),
expiry: o.expiry || defaultExpiry(),
message: o.message || 'Nouveau lien de remplacement.',
subject: o.subject || 'Votre carte-cadeau (nouveau lien)',
marketplace_gift_id: o.marketplace_gift_id || g.marketplacegift_id,
price_in_cents: o.price_in_cents || g.price_in_cents,
contacts: [{ email: o.email || g.recipient_email, firstname: (o.firstname || (g.recipient_name || '').split(' ')[0] || ''), lastname: o.lastname || '' }]
})
return {
ok: !!created.ok,
old: { uuid: g.uuid, shortlink: g.shortlink, status: (cancel.body && cancel.body.gift && cancel.body.gift.status) || 'GIVER_CANCELLED', price_in_cents: g.price_in_cents, brand_code: g.brand_code, recipient_email: g.recipient_email, recipient_name: g.recipient_name },
created: created.body || null,
created_status: created.status
}
}
const DEMO_HTML = `
TARGO × Giftbit — Integration Demo (Sandbox)
TARGO Communications × Giftbit — Integration Demo
Reward creation via Giftbit API — direct brand & multi-brand link (recipient choice)
🧪 SANDBOX / TESTBED — Runs only against testbedapp.giftbit.com. No real cards are issued. The API token is held server-side and is never sent to this page. Amount fixed at $5 CAD, single recipient.
For Giftbit reviewers
This page demonstrates TARGO Communications' integration with the Giftbit API (papi/v1). It is connected to the testbed account only. You can create a reward two ways and inspect the resulting shortlink redemption flow:
• Direct reward — one specific brand, fixed at issuance.
• Reward link (multi-brand) — several brands offered; the recipient selects their brand on the Giftbit redemption page (the gift's brand_code stays null until then).
Brands are loaded live from GET /marketplace. Rewards are created via POST /campaign with marketplace_gifts. Links can be cancelled and re-issued with DELETE /gifts/{uuid}. Contact: louis@targo.ca.
1Reward type
Pick exactly one brand below.
2Choose brand(s) GET /marketplace
Loading options…
3Recipient & create
Generated reward links GET /gifts
Loading…
Live statuses from the testbed: SENT_AND_REDEEMABLE → REDEEMED (brand fills in once the recipient chooses) → GIVER_CANCELLED (revoked). “Revoke” cancels a reward link via DELETE /gifts/{uuid}.
Activity by day redemption trend
Loading…
Organic redemptions cluster right after delivery, then taper off. A steady daily trickle — or many instant redemptions (< 15 min after issue) — can indicate automated / low-profile abuse.
Security & correct use
• Token server-side only — the Giftbit Bearer token lives in the hub environment; this browser page never receives it.
• Sandboxed — this endpoint refuses to run against production; amount, brand list and recipient count are capped.
• Direct create sends one marketplace_gifts entry; multi-brand sends several — Giftbit returns a shortlink where the recipient picks their brand (brand_code is null until chosen).
• HTTPS end-to-end; links are delivered via our own wrapper with independent expiry; links can be cancelled (DELETE /gifts/{uuid}) and re-issued at any time.
`
// ─────────────────────────────────────────────────────────────────────────────
// DÉMO « JAIL » (sandbox testbed, publique) — pour la validation par Giftbit.
// Garanties : (1) testbed UNIQUEMENT (refus si prod), (2) montant FIXE 5 $, (3) marques d'une
// liste blanche, (4) 1 destinataire, (5) le token reste SERVEUR (jamais envoyé au navigateur).
// ─────────────────────────────────────────────────────────────────────────────
const DEMO_BRAND_IDS = [386, 574, 572, 617, 619, 620, 621, 622, 568] // CAD, montant variable (≥5 $) — region=1
const DEMO_PRICE = 500
const DEMO_GUARD = () => isTestbed // jamais en prod
async function demoBrands () {
const r = await marketplace({ region: '1', limit: '50' })
const list = (r.body && r.body.marketplace_gifts) || []
return list.filter(g => DEMO_BRAND_IDS.includes(Number(g.id)))
.map(g => ({ id: g.id, name: g.name, image_url: g.image_url, currency: g.currencyisocode }))
}
async function demoCreate (o = {}) {
if (!DEMO_GUARD()) return { ok: false, error: 'Démo désactivée hors environnement testbed.' }
const ids = (Array.isArray(o.brand_ids) ? o.brand_ids : []).map(Number).filter(x => DEMO_BRAND_IDS.includes(x))
if (!ids.length) return { ok: false, error: 'Choisir au moins une marque de la liste.' }
const email = /^[^@\s]+@[^@\s]+\.[^@\s]+$/.test(String(o.email || '')) ? o.email : 'demo@targo.ca'
const mode = ids.length > 1 ? 'template' : 'direct'
const cid = 'targo-demo-' + Date.now()
const created = await createCampaign({
id: cid, delivery_type: 'SHORTLINK', expiry: defaultExpiry(),
subject: 'TARGO × Giftbit — Integration demo', message: mode === 'template' ? 'Choose your gift card.' : 'Your gift card.',
marketplace_gifts: ids.map(id => ({ id, price_in_cents: DEMO_PRICE })),
contacts: [{ email, firstname: 'Demo', lastname: 'Recipient' }]
})
if (!created.ok) return { ok: false, error: 'Création refusée', detail: created.body }
// Le gift est asynchrone ET le shortlink se génère APRÈS la carte (~8 s) → on sonde jusqu'au shortlink (max ~12 s ; la page poll aussi en repli).
let gifts = []
for (let i = 0; i < 5; i++) { await new Promise(r => setTimeout(r, 800)); const g = await listGifts({ campaign_id: cid }); gifts = (g.body && g.body.gifts) || []; if (gifts.length && gifts[0].shortlink) break } // réponse rapide ; la page poll /status pour le shortlink si pas encore prêt
return { ok: true, mode, campaign_id: cid, campaign_uuid: (created.body && created.body.campaign && created.body.campaign.uuid) || '', price_in_cents: DEMO_PRICE, brands: ids, gifts: gifts.map(g => ({ uuid: g.uuid, shortlink: g.shortlink, status: g.status, brand_code: g.brand_code })) }
}
// Historique des liens générés par la démo (campaign_id préfixé targo-demo-), trié du + récent au + ancien.
async function demoHistory () {
const r = await listGifts({ limit: 500 })
const gs = (r.body && r.body.gifts) || []
return gs.filter(g => String(g.campaign_id || '').startsWith('targo-demo-'))
.sort((a, b) => String(b.created_date || '').localeCompare(String(a.created_date || '')))
.map(g => ({ uuid: g.uuid, shortlink: g.shortlink, status: g.status, brand_code: g.brand_code, price_in_cents: g.price_in_cents, created_date: g.created_date, redeemed_date: g.redeemed_date || '', recipient_email: g.recipient_email }))
}
// Stat « activité par jour » : créés vs rédemés/jour + signaux d'abus (rédemptions « instantanées » < 15 min = bot-like ;
// rédemptions tardives = filet régulier après la courbe organique). Révèle un pirate low-profile.
function dayOf (s) { return String(s || '').slice(0, 10) }
function _ts (s) { const t = Date.parse(String(s || '').replace(' ', 'T') + 'Z'); return isFinite(t) ? t : null }
function dailyAgg (gifts) {
const map = {}; let redeemed = 0; let fast = 0; let lateDrip = 0
for (const g of gifts) {
const cd = dayOf(g.created_date); if (cd) { (map[cd] = map[cd] || { created: 0, redeemed: 0 }).created++ }
if (g.status === 'REDEEMED' && g.redeemed_date) {
const rd = dayOf(g.redeemed_date); (map[rd] = map[rd] || { created: 0, redeemed: 0 }).redeemed++; redeemed++
const t1 = _ts(g.created_date); const t2 = _ts(g.redeemed_date)
if (t1 != null && t2 != null) { const dh = (t2 - t1) / 3600000; if (dh < 0.25) fast++; if (dh > 72) lateDrip++ } // <15 min = instantané ; >72 h = tardif
}
}
const days = Object.keys(map).sort().map(d => ({ date: d, created: map[d].created, redeemed: map[d].redeemed }))
return { days, totals: { gifts: gifts.length, redeemed, fast_redemptions: fast, late_redemptions: lateDrip } }
}
// Révocation depuis la démo : SEULEMENT un gift démo (garde-fou) → DELETE → GIVER_CANCELLED (montre le traitement d'un lien compromis).
async function demoRevoke (uuid) {
if (!DEMO_GUARD()) return { ok: false, error: 'testbed only' }
const cur = await getGift(uuid); const g = cur.body && cur.body.gift
if (!g || !String(g.campaign_id || '').startsWith('targo-demo-')) return { ok: false, error: 'Only demo links can be revoked here.' }
if (g.status !== 'SENT_AND_REDEEMABLE') return { ok: false, error: 'Only unredeemed links can be revoked (current status: ' + g.status + ').' }
const r = await cancelGift(uuid)
return { ok: r.ok, status: (r.body && r.body.gift && r.body.gift.status) || 'GIVER_CANCELLED' }
}
async function handle (req, res, method, path, url) {
const qs = (url && url.searchParams) || new URLSearchParams()
// ── Démo publique (sandbox) : page HTML + données testbed. Sert AVANT le contrôle TOKEN pour que la page charge toujours. ──
if (path === '/giftbit-demo' || path === '/giftbit-demo/' || path === '/giftbit-demo.html') { res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8', 'Cache-Control': 'no-store' }); return res.end(DEMO_HTML) }
if (path.startsWith('/giftbit-demo/')) {
if (!TOKEN) return json(res, 503, { ok: false, error: 'token absent' })
if (!isTestbed) return json(res, 403, { ok: false, error: 'Démo disponible en testbed seulement.' })
if (path === '/giftbit-demo/marketplace') return json(res, 200, { ok: true, testbed: true, brands: await demoBrands() })
if (path === '/giftbit-demo/create' && method === 'POST') { const b = await parseBody(req); return json(res, 200, await demoCreate(b)) }
if (path === '/giftbit-demo/status') { const r = await listGifts({ campaign_id: qs.get('campaign_id') || '' }); return json(res, 200, r.body || { ok: false }) }
if (path === '/giftbit-demo/history') return json(res, 200, { ok: true, gifts: await demoHistory() })
if (path === '/giftbit-demo/stats') return json(res, 200, { ok: true, ...dailyAgg(await demoHistory()) })
if (path === '/giftbit-demo/revoke' && method === 'POST') { const b = await parseBody(req); if (!b.uuid) return json(res, 400, { ok: false, error: 'uuid requis' }); return json(res, 200, await demoRevoke(b.uuid)) }
return json(res, 404, { ok: false, error: 'route démo inconnue' })
}
if (!TOKEN) return json(res, 503, { ok: false, error: 'GIFTBIT_TOKEN absent de l\'env du hub' })
try {
if (path === '/giftbit/ping') { const r = await ping(); return json(res, 200, { ok: r.ok, testbed: isTestbed, ...(r.body || {}), _status: r.status }) }
if (path === '/giftbit/funds') { const r = await funds(); return json(res, 200, { ok: r.ok, testbed: isTestbed, ...(r.body || {}) }) }
if (path === '/giftbit/marketplace') { const r = await marketplace(Object.fromEntries(qs)); return json(res, 200, r.body || { ok: false, error: r.error }) }
if (path === '/giftbit/brands') { const r = await brands(Object.fromEntries(qs)); return json(res, 200, r.body || { ok: false, error: r.error }) }
if (path === '/giftbit/gifts' && method === 'GET') { const r = await listGifts(Object.fromEntries(qs)); return json(res, 200, r.body || { ok: false, error: r.error }) }
if (path === '/giftbit/stats' && method === 'GET') { const q = Object.fromEntries(qs); const r = await listGifts({ campaign_id: q.campaign_id || '', campaign_uuid: q.campaign_uuid || '', limit: q.limit || 1000 }); const gifts = (r.body && r.body.gifts) || []; return json(res, 200, { ok: true, count: gifts.length, ...dailyAgg(gifts) }) }
if (path.startsWith('/giftbit/gift/') && method === 'GET') { const r = await getGift(path.split('/').pop()); return json(res, 200, r.body || { ok: false, error: r.error }) }
if (path === '/giftbit/cancel' && method === 'POST') { const b = await parseBody(req); if (!b.uuid) return json(res, 400, { ok: false, error: 'uuid requis' }); const r = await cancelGift(b.uuid); return json(res, 200, { ok: r.ok, ...(r.body || {}) }) }
if (path === '/giftbit/campaign' && method === 'POST') { const b = await parseBody(req); if (!b.id || !b.marketplace_gift_id || !b.price_in_cents || !Array.isArray(b.contacts) || !b.contacts.length) return json(res, 400, { ok: false, error: 'id, marketplace_gift_id, price_in_cents, contacts[] requis' }); const r = await createCampaign(b); return json(res, 200, { ok: r.ok, ...(r.body || {}), _status: r.status }) }
if (path === '/giftbit/replace' && method === 'POST') { const b = await parseBody(req); if (!b.uuid) return json(res, 400, { ok: false, error: 'uuid requis' }); const r = await replaceGift(b); return json(res, r.ok ? 200 : 500, r) }
return json(res, 404, { ok: false, error: 'route giftbit inconnue' })
} catch (e) { return json(res, 500, { ok: false, error: String((e && e.message) || e) }) }
}
module.exports = { handle, api, ping, funds, marketplace, brands, listGifts, getGift, cancelGift, createCampaign, replaceGift, isTestbed, BASE }