Webhooks
Signed fulfillment signals — signature verification, delivery semantics, and retry behavior.
Webhooks are the trusted signal that money moved. The onSuccess
callback in the browser is for UX; ship goods only after verifying a webhook.
Verify every delivery
Each delivery carries an x-tab-signature header:
x-tab-signature: t=1752934800,v1=5257a869e7…t— unix timestamp (seconds) when the delivery was signedv1— hex HMAC-SHA256 of"{t}.{raw body}"using yourwhsec_…endpoint secret
Verification rules:
- Recompute the HMAC over the raw request body (before any JSON parsing) and compare in constant time.
- Reject if
tis older than 5 minutes or more than 30 seconds in the future — this bounds replay.
import { createHmac, timingSafeEqual } from "node:crypto";
export function verifyTabWebhook(rawBody: string, header: string, secret: string) {
const match = /^t=(\d+),v1=([0-9a-f]{64})$/.exec(header);
if (!match) return false;
const [, t, v1] = match;
const age = Math.floor(Date.now() / 1000) - Number(t);
if (age > 300 || age < -30) return false;
const digest = createHmac("sha256", secret).update(`${t}.`).update(rawBody).digest();
return timingSafeEqual(digest, Buffer.from(v1, "hex"));
}Event types
| Type | When | Payload |
|---|---|---|
payment.settled | A payment settled (Testnet: after real on-chain verification) | see below |
test | You triggered a test delivery | {"id":"<delivery UUID>","type":"test","livemode":false} |
payment.settled payload:
{
"id": "<delivery UUID>",
"type": "payment.settled",
"livemode": false,
"transactionId": "0x<real transaction hash>",
"tokenChanges": [
{
"amountAtomic": "1000000",
"chainId": 84532,
"receiver": "0x<your receiving address>",
"tokenAddress": "0x036CbD53842c5426634e7929541eC2318f3dCF7e"
}
]
}Idempotency
The payload id is the delivery UUID — retries and manual resends
preserve it. Build your idempotency key as ${event.type}:${event.id},
insert it under a unique constraint, and fulfill in the same database
transaction. Return 2xx for an already-processed valid event.
transactionId is settlement evidence, not the idempotency key.
Register from code
const { endpoint, signingSecret } = await tab.webhooks.configure({
url: "https://your-server.example/webhooks/tab",
});
await tab.webhooks.sendTest(); // real signed delivery, expects your 2xxOne endpoint per environment. configure creates or updates; the whsec_
secret is returned only on first creation. Also available as raw HTTP:
GET/POST/PATCH/DELETE /api/v1/webhook-endpoint with your secret key.
Delivery semantics
- Deliveries are per-mode: test endpoints receive test events only.
- Failed deliveries make exactly 3 total attempts — immediately, after 1 minute, then after 4 minutes; the dashboard shows every attempt with its response code and timing, and surfaces failed deliveries as an alert badge.
- A
2xxresponse marks the delivery successful. Anything else (including timeouts) schedules a retry. - Endpoint secrets can be regenerated at any time; the old secret stops verifying immediately, so update your server first.
Test your endpoint
The dashboard's webhook page sends a real signed test delivery to your endpoint and shows the response — the Quickstart's "Verify webhook delivery" step completes only when that delivery succeeds.