4. Webhooks
Instead of asking WalletCraft for news, let it tell you. We send a POST to your server when something happens —
for example so your website can show the latest points without a request on every page view.
Set up
Section titled “Set up”- In the console, API & webhooks → Webhooks: enter your HTTPS endpoint and pick the events.
- Copy the signing secret (
whsec_…). It’s shown once; New secret replaces it. - Press Send test: a
pingarrives and the delivery log shows the answer.
Events
Section titled “Events”| Event | When | data |
|---|---|---|
card.added |
A card was added to Apple Wallet or Google Wallet | passId, customerId, wallet |
card.removed |
A card was removed from a wallet | passId, customerId, wallet |
balance.changed |
Points or stamps changed: purchase, redemption, reward, correction, cancellation | passId, customerId, balance, change, operation (id, type) |
tier.changed |
The card moved to another level (also when old purchases expire) | passId, customerId, tier, previous |
consent.changed |
News & offers consent was given or withdrawn | customerId, marketingConsent, passIds |
A delivery looks like this:
POST /walletcraft HTTP/1.1Content-Type: application/jsonWalletCraft-Event: balance.changedWalletCraft-Delivery: 01a1…WalletCraft-Signature: t=1791386400,v1=5f2c…
{ "id": "01a1…", "type": "balance.changed", "createdAt": "2026-10-07T12:00:00.000Z", "data": { "passId": "01a1…", "customerId": "01a1…", "balance": 12, "change": 4, "operation": { "id": "01a1…", "type": "purchase" } }}Verify and handle
Section titled “Verify and handle”The signature is HMAC-SHA256(secret, "<t>.<raw body>") in hex. Check it on the raw body, before parsing
JSON, and refuse old timestamps.
// webhooks.mjs — receive WalletCraft webhooks in Node.js 18+ (no dependencies).import { createHmac, timingSafeEqual } from 'node:crypto';
/** * Checks `WalletCraft-Signature: t=<unix seconds>,v1=<hex>` against the raw request body and refuses old * deliveries (default 5 minutes), so a captured request can't be replayed later. */export function verifyWebhook(rawBody, signatureHeader, secret, toleranceSeconds = 300) { const parts = Object.fromEntries( String(signatureHeader ?? '') .split(',') .map((p) => p.split('=')), ); const timestamp = Number(parts.t); if (!parts.v1 || !Number.isFinite(timestamp)) return false; if (Math.abs(Date.now() / 1000 - timestamp) > toleranceSeconds) return false; const expected = createHmac('sha256', secret).update(`${parts.t}.${rawBody}`).digest('hex'); const given = Buffer.from(parts.v1, 'hex'); return ( given.length === expected.length / 2 && timingSafeEqual(given, Buffer.from(expected, 'hex')) );}
/** * A request handler for `node:http` (and Express without a JSON body parser on this route). Answers 2xx quickly; * deliveries may repeat, so skip ids you have already handled. */export function webhookHandler({ secret, onEvent }) { return (req, res) => { let body = ''; req.setEncoding('utf8'); req.on('data', (chunk) => { body += chunk; }); req.on('end', () => { if (!verifyWebhook(body, req.headers['walletcraft-signature'], secret)) { res.writeHead(401).end(); return; } res.writeHead(204).end(); Promise.resolve(onEvent(JSON.parse(body))).catch((err) => { // eslint-disable-next-line no-console -- example code for the reader's server, which logs to the console console.error('WalletCraft webhook handler failed', err); }); }); };}import { createServer } from 'node:http';import { webhookHandler } from './webhooks.mjs';
const handle = webhookHandler({ secret: process.env.WALLETCRAFT_WEBHOOK_SECRET, async onEvent(event) { if (await db.webhookEvents.exists(event.id)) return; // deliveries may repeat await db.webhookEvents.add(event.id); if (event.type === 'balance.changed') { await db.users.updateByCard(event.data.passId, { points: event.data.balance }); } },});
createServer((req, res) => req.url === '/walletcraft' ? handle(req, res) : res.writeHead(404).end(),).listen(3000);With Express, mount the handler on its route before any JSON body parser, so the raw body stays intact.
Delivery rules
Section titled “Delivery rules”- Answer 2xx within 10 seconds. Anything else — or no answer — is a failure.
- Retries: after a failure we try again after 1 minute, 5 minutes, 30 minutes, 2 hours and 6 hours. Then the delivery is marked Failed in the log.
- At least once: a delivery can arrive twice (e.g. your server answered too late). Deduplicate by
id. - Order isn’t guaranteed across events; use
balance, not sums ofchange. - Addresses: public HTTPS endpoints only; redirects aren’t followed.