// 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_REDEEMABLEREDEEMED (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 }