Security and delivery
Verify the signature in constant time, plus retries, the delivery log, and replaying one.
Every delivery is signed, and delivery is at-least-once with bounded retries. Verify before you act on a payload: an unverified endpoint is an unauthenticated POST endpoint anyone who learns the URL can drive.
The signature
t is the Unix timestamp of this attempt (retries are re-signed); v1 is HMAC-SHA256 of ` ${t}.${rawBody} ` with your endpoint secret, hex encoded. Read the versions you know and ignore the rest.
- 1Read the RAW bodyBefore any JSON parsing. The signature covers the exact bytes we sent, and re-serialising a parsed object reorders keys. This is the most common mistake.
- 2Bound the timestampReject anything older than about five minutes, or a captured delivery can be replayed forever.
- 3Compare in constant timeA byte-by-byte
==leaks, through timing, how much of a forged signature was correct.
Endpoints must be public HTTPS: private ranges, loopback and link-local addresses (the cloud metadata endpoint at 169.254.169.254 included) are refused, and re-checked at delivery time, since a hostname that resolved publicly last week can point somewhere private today. Redirects are not followed. Secrets are not rotatable in place: register a second endpoint, accept either secret, confirm, then delete the old one.
Retries
| Attempt | Sent after | Elapsed |
|---|---|---|
| 1 | immediately | 0 |
| 2 | 1 minute | 1m |
| 3 | 5 minutes | 6m |
| 4 | 30 minutes | 36m |
| 5 | 2 hours | 2h 36m |
| 6 | 6 hours | 8h 36m |
| 7 | 12 hours | 20h 36m |
Seven attempts over roughly 20.5 hours, front-loaded: most failures are a deploy and clear within minutes, and the long tail survives an overnight incident. The request timeout is 10 seconds. Ordering is not guaranteed: a retried event can arrive after a later one, so use created_at rather than arrival order.
| Your response | Treated as | Retried |
|---|---|---|
2xx | Delivered | — |
410 Gone | Retired: the endpoint is disabled | No |
408, 429 | Temporary | Yes |
other 4xx | Rejected: you understood it and refused | No |
5xx | Server error | Yes |
| timeout, DNS failure, connection refused | Unreachable | Yes |
The delivery log
/api/v1/webhooks/{id}/deliveriesscope: shows:read| Field | Type | Required | Description |
|---|---|---|---|
status | string | No | One of queued, delivering, delivered, failed. |
event | string | No | One of the three event names. |
limit | number | No | Default 20, maximum 100. |
cursor | string | No | The next_cursor from a previous page. |
No payloads or response bodies in the list: it is for finding the interesting row. next_attempt_at is set only while a delivery is queued.
/api/v1/webhooks/{id}/deliveries/{deliveryId}scope: shows:readOne delivery in full adds payload (exactly what was sent), response_body, truncated to 2000 characters, and replay_blocked_reason, which is null when a replay would be accepted and a sentence when it would not, so you can decide without POSTing to find out.
Replaying one
/api/v1/webhooks/{id}/deliveries/{deliveryId}/retryscope: shows:writeReturns 202 once queued; the sweep runs every minute. The attempt count is reset, so the delivery gets the full ladder again, and the delivery id does not change: a receiver deduplicating on id treats it as the same event, which is the point. It needs shows:write because it causes an outbound request under your account.
| Situation | Response | Why |
|---|---|---|
Delivery is queued | 400 | It will be attempted anyway. Replaying would send it twice. |
Delivery is delivering | 400 | In flight right now. |
| Endpoint is disabled | 400 | Re-enable first: PATCH /api/v1/webhooks/{id} with {"disabled": false}. An endpoint that returned 410 Gone retired itself, and we honour that. |
| Claimed mid-request | 200, queued: false | The sweep picked it up between the check and the write. Not an error. |
Debugging order. GET /api/v1/webhooks first: a recent last_success_at means the problem is one event, not the endpoint. Then list failures: 401/403 is your own auth in front of the receiver, 404 a wrong path, 5xx your handler throwing, and a null response_status with a timeout means you are working before acknowledging.