Webhook delivery and rotation
What we send, how often we retry, and the one thing that breaks verification during a rotation.
See Webhooks and events for the event catalog.
A delivery
| Header | |
|---|---|
x-tone-event | The event type |
x-tone-event-id | Stable across retries — dedupe on this |
x-tone-timestamp | Unix seconds, covered by the signature |
x-tone-signature | One or more space-delimited v1=<hex> signatures |
x-tone-delivery-attempt | 1 on the first send |
Verify HMAC-SHA256 of the literal {timestamp}.{raw body} — the raw body,
before any JSON parsing. Re-serialising first changes the bytes and the signature
will never match.
The URL path is deliberately not signed, so a proxy or a path rewrite in front of your endpoint does not break verification.
Answer within 10 seconds
Do the minimum: verify, enqueue, return 2xx. A slow handler does not merely
time out — it manufactures duplicates, because a retry is indistinguishable from
a first delivery to a handler that already did the work.
| Your response | We |
|---|---|
2xx | Consider it delivered |
4xx, except 408 and 429 | Give up — you said it was malformed |
5xx, 408, 429, timeout | Retry |
The retry ladder
1, 2, 4, 8, 16, 32, 64, 128 minutes, then every 4 hours — 14 attempts over roughly a day, each with ±20% jitter.
⚠️ 20 consecutive failures across all deliveries disables the endpoint, with
an email to owners and admins. Re-enable it with a PATCH once you have fixed the
receiver.
Rotating the secret
curl "$TONE_API/v1/integrations/webhooks/$ID/roll-secret" -X POST \
-H "Authorization: Bearer $TONE_KEY"🔴 During the 24-hour overlap, x-tone-signature carries SEVERAL space-delimited
signatures. A verifier that treats the header as one string starts rejecting
everything the moment you rotate. Split on whitespace and accept the delivery if
any signature matches:
const ok = header.split(' ').some((sig) =>
timingSafeEqual(Buffer.from(sig), Buffer.from(`v1=${expected}`)));Write it that way before you ever rotate, not during.
When you miss one
Deliveries are at-least-once and unordered. If your receiver was down past the retry window:
curl -H "Authorization: Bearer $TONE_KEY" "$TONE_API/v1/events?type=call.completed"
curl -H "Authorization: Bearer $TONE_KEY" "$TONE_API/v1/integrations/webhooks/deliveries"30-day retention. POST /deliveries/{id}/retry re-sends immediately with the
same event id.
⚠️ GET /v1/events/{id} re-renders the payload from current state rather
than replaying the signed bytes. A transcript that landed after delivery is
present here and was absent from what your endpoint received. Use it to
understand an event, not to re-verify a signature.