# Webhook delivery and rotation

Source: https://docs.usetone.ai/flows/webhook-delivery

> What we send, how often we retry, and the one thing that breaks verification during a rotation.

See [Webhooks and events](/webhooks) 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

```bash
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:

```js
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:

```bash
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.
