Tizondocs
Guides

Webhooks

Register an endpoint, verify the signature, and survive retries.

Fulfillment happens in the background, so webhooks are how you find out an order is ready without polling for it.

Register an endpoint

curl https://api.tizon.mobile/v1/webhook_endpoints \
  -H "Authorization: Bearer $TIZON_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "url": "https://api.example.com/tizon/webhooks",
    "description": "Order fulfillment",
    "enabled_events": ["order.fulfilled", "order.failed", "wallet.credited"]
  }'

enabled_events defaults to ["*"] — every type, including ones added later. An unknown type is rejected with 400 unknown_event_type.

The response carries a secret like whsec_…. Store it now: it is never stored in readable form and never returned again. Losing it means rotating with POST /v1/webhook_endpoints/{id}/rotate_secret.

The URL must be https:// and publicly reachable, in test mode as well as live; localhost and private ranges are refused with 400 invalid_webhook_url. Use a tunnel to receive sandbox events on a laptop. Registering the same URL twice for one account is 409 webhook_url_in_use.

Verify the signature

Every delivery carries a Tizon-Signature header:

Tizon-Signature: t=1759400832,v1=5f1c9a3e7b424d189e650c7a2d4b8e31f0a9c6d2...

The signed material is "<t>.<raw request body>" under HMAC-SHA256 with your secret. Two rules, both of which matter:

  1. Reject a delivery whose signature does not match.
  2. Reject one whose t is more than five minutes old.
import { createHmac, timingSafeEqual } from 'node:crypto';

const TOLERANCE_SECONDS = 300;

export function verify(rawBody, header, secret) {
  const parts = Object.fromEntries(
    header.split(',').map((kv) => kv.split('=').map((s) => s.trim())),
  );
  const timestamp = Number(parts.t);
  if (!Number.isFinite(timestamp)) return false;

  // Without this check, a captured delivery can be replayed forever.
  if (Math.abs(Date.now() / 1000 - timestamp) > TOLERANCE_SECONDS) return false;

  const expected = createHmac('sha256', secret)
    .update(`${timestamp}.${rawBody}`)
    .digest('hex');

  const a = Buffer.from(expected, 'hex');
  const b = Buffer.from(parts.v1 ?? '', 'hex');
  return a.length === b.length && timingSafeEqual(a, b);
}

Sign the raw body, exactly as received. Parsing the JSON and re-serialising it changes the bytes and the signature will never match. In Express that means express.raw({ type: 'application/json' }) on this route, not express.json().

Answer quickly

Answer 2xx within ten seconds. Anything else is retried with backoff for about a day, after which the delivery is dead-lettered — and an endpoint that keeps failing is disabled, with disabled_reason saying why.

So acknowledge first and work afterwards. Write the event to a queue, return 200, and do the real work outside the request.

Handle duplicates and reordering

Delivery is at-least-once, so the same id may arrive twice. Dedupe on event.id, which is stable across retries and replays.

Order is not guaranteed. Treat the resource's own status as the truth, and fetch it if you need certainty:

app.post('/tizon/webhooks', async (req, res) => {
  if (!verify(req.body, req.get('Tizon-Signature'), process.env.TIZON_WEBHOOK_SECRET)) {
    return res.sendStatus(400);
  }

  const event = JSON.parse(req.body);

  // Dedupe before any side effect: a retry must not deliver the eSIM twice.
  if (await seen(event.id)) return res.sendStatus(200);
  await remember(event.id);

  await enqueue(event);
  res.sendStatus(200);
});

The envelope

{
  "id": "evt_01J9Z8X7R3T6W9Z2C5F8J1M4P7",
  "object": "event",
  "type": "order.fulfilled",
  "created_at": "2026-10-02T11:47:12Z",
  "livemode": false,
  "data": {
    "object": {}
  }
}

livemode is false for events from a test key. data.object is the resource as it was when the event fired — which is why a stale duplicate must not overwrite newer state.

Event types

TypeFires when
pingYou asked for one, via POST /v1/webhook_endpoints/{id}/ping.
deposit.detectedA stablecoin deposit was seen but not yet confirmed.
deposit.confirmedIt confirmed.
wallet.creditedThe balance went up.
wallet.debitedThe balance went down.
wallet.overdraft_usedA purchase drew on the overdraft.
wallet.overdueAn open debit passed its net terms.
quote.awaiting_fundsA quote is waiting on payment.
quote.fulfilledA quote was paid.
quote.expiredA quote went unpaid.
order.fulfilledThe deliverable is ready.
order.failedFulfillment failed; the price is back in the wallet.
credit.updatedOverdraft limit or net terms changed.
payout.succeeded / payout.failedA Revenue Centre payout resolved.

Treat this as an open enum. More types may be added, so ignore a type you do not recognise rather than failing on it.

Debugging a delivery

Every attempt is logged:

curl -G https://api.tizon.mobile/v1/webhook_endpoints/{id}/deliveries \
  -H "Authorization: Bearer $TIZON_KEY" \
  -d limit=20

You can also list events independently of delivery, and replay one:

curl -X POST https://api.tizon.mobile/v1/events/{event_id}/replay \
  -H "Authorization: Bearer $TIZON_KEY"

A replay reuses the original event id, so your dedupe will swallow it unless you cleared it — which is exactly the behaviour you want to verify.

On this page