Skip to main content

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": "..."
  }
}
FieldTypeDescription
claimIdUUIDThe new claim row. Unique per (user, voucher).
voucherIdUUID
voucherTitlestringSnapshot at claim time.
merchantIdUUID

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
  }
}
FieldTypeDescription
redemptionIdUUIDThe new redemption row.
voucherTitlestringSnapshot at redemption time.
outletNamestringSnapshot at redemption time.
status"SUCCESS" | "FLAGGED"
flagReasonstring | nullgps_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"
  }
}
FieldTypeDescription
voucherTitlestringSnapshot at publish time.
merchantNamestringSnapshot at publish time.
valueTypeenum | nullOne of FREE_ITEM, PERCENT, FIXED, BOGO. May be null. Informational only.