Skip to content

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.

  1. In the console, API & webhooks → Webhooks: enter your HTTPS endpoint and pick the events.
  2. Copy the signing secret (whsec_…). It’s shown once; New secret replaces it.
  3. Press Send test: a ping arrives and the delivery log shows the answer.
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.1
Content-Type: application/json
WalletCraft-Event: balance.changed
WalletCraft-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" }
}
}

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
// 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);
});
});
};
}
server.mjs
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.

  • 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 of change.
  • Addresses: public HTTPS endpoints only; redirects aren’t followed.