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:
- Reject a delivery whose signature does not match.
- Reject one whose
tis 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
| Type | Fires when |
|---|---|
ping | You asked for one, via POST /v1/webhook_endpoints/{id}/ping. |
deposit.detected | A stablecoin deposit was seen but not yet confirmed. |
deposit.confirmed | It confirmed. |
wallet.credited | The balance went up. |
wallet.debited | The balance went down. |
wallet.overdraft_used | A purchase drew on the overdraft. |
wallet.overdue | An open debit passed its net terms. |
quote.awaiting_funds | A quote is waiting on payment. |
quote.fulfilled | A quote was paid. |
quote.expired | A quote went unpaid. |
order.fulfilled | The deliverable is ready. |
order.failed | Fulfillment failed; the price is back in the wallet. |
credit.updated | Overdraft limit or net terms changed. |
payout.succeeded / payout.failed | A 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=20You 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.