Verifying a webhook
Always verify the signature before parsing or trusting the body.
import { createHmac, timingSafeEqual } from 'node:crypto';
import express from 'express';
const app = express();
// IMPORTANT: get the raw body for HMAC verification.
app.use('/voucher-webhooks', express.raw({ type: 'application/json' }));
app.post('/voucher-webhooks', (req, res) => {
const header = String(req.headers['x-signature'] || '');
const parts = Object.fromEntries(
header.split(',').map((p) => {
const i = p.indexOf('=');
return [p.slice(0, i), p.slice(i + 1)];
}),
);
const t = Number(parts.t);
if (!Number.isFinite(t) || Math.abs(Date.now() / 1000 - t) > 300) {
return res.status(400).send('stale');
}
const expected = createHmac('sha256', process.env.PARTNER_HMAC_SECRET!)
.update(`${t}.${req.body.toString('utf8')}`)
.digest('hex');
const got = Buffer.from(parts.v1!, 'hex');
const exp = Buffer.from(expected, 'hex');
if (got.length !== exp.length || !timingSafeEqual(got, exp)) {
return res.status(401).send('bad sig');
}
const event = JSON.parse(req.body.toString('utf8'));
// ... handle event by event.type ...
res.status(200).end();
});
Voucher titles can contain inline markup. Merchants may style the
striking part of an offer with tiny tags — {b}…{/b} (bold), {r}…{/r}
(red highlight), {s}…{/s} (strikethrough), which can nest
({b}{r}RM38{/r}{/b}). Any voucherTitle in a payload (or in /me/*
responses) may include them. Strip the tags for a plain-text display
(remove anything matching \{/?[brs]\}, case-insensitive) or render them
if you want the emphasis.
CLAIM_CREATED
Emitted when the user adds a voucher to their wallet via
POST /me/claims.
{
"id": "9f1a2c4d-5e6f-7891-a2b3-c4d5e6f78901",
"type": "CLAIM_CREATED",
"createdAt": "2026-05-12T08:14:22.501Z",
"data": {
"userId": "8d2a7f3b-1c4e-4b9a-9f55-d3a8e2c1b7d4",
"externalUserId": "usr_demo_001",
"claimId": "0c5a8b7f-9d22-4e1a-b6c1-7f8e92d4ab10",
"voucherId": "3a7e8d12-...",
"voucherTitle": "Free coffee",
"merchantId": "..."
}
}
| Field | Type | Description |
|---|
claimId | UUID | The new claim row. Unique per (user, voucher). |
voucherId | UUID | |
voucherTitle | string | Snapshot at claim time. |
merchantId | UUID | |
REDEMPTION_CREATED
Emitted when the user redeems a voucher via POST /me/redemptions. Both
SUCCESS and FLAGGED redemptions fire this event.
{
"id": "1d4e8f2c-3b6a-4d12-9e8f-c5b3a7d9e0f1",
"type": "REDEMPTION_CREATED",
"createdAt": "2026-05-12T08:14:22.501Z",
"data": {
"userId": "8d2a7f3b-...",
"externalUserId": "usr_demo_001",
"redemptionId": "...",
"voucherId": "...",
"voucherTitle": "Free coffee",
"outletId": "...",
"outletName": "Acme Cafe — Orchard",
"status": "SUCCESS",
"flagReason": null
}
}
| Field | Type | Description |
|---|
redemptionId | UUID | The new redemption row. |
voucherTitle | string | Snapshot at redemption time. |
outletName | string | Snapshot at redemption time. |
status | "SUCCESS" | "FLAGGED" | |
flagReason | string | null | gps_unavailable on a FLAGGED row, or outlet_not_geolocated — an audit tag that can also appear on a SUCCESS row when the outlet has no coordinates configured. |
The code dispensed to the user (if any) is not included in this
payload — it’s considered consumer-facing and only flows through the
redemption response. If you need it server-side, fetch the redemption
via the admin API.
VOUCHER_PUBLISHED
Emitted once per favoriting user when a merchant publishes a new voucher.
We fan out at publish time, so if 10k users have favorited the merchant,
10k events are enqueued.
{
"id": "...",
"type": "VOUCHER_PUBLISHED",
"createdAt": "2026-05-12T08:14:22.501Z",
"data": {
"userId": "...",
"externalUserId": "usr_demo_001",
"voucherId": "...",
"voucherTitle": "Buy one get one — May only",
"merchantId": "...",
"merchantName": "Acme Cafe",
"valueType": "BOGO"
}
}
| Field | Type | Description |
|---|
voucherTitle | string | Snapshot at publish time. |
merchantName | string | Snapshot at publish time. |
valueType | enum | null | One of FREE_ITEM, PERCENT, FIXED, BOGO. May be null. Informational only. |