How Webhook Retries Work
What happens when your endpoint does not answer with a 2xx
When an event such as order.paid fires, we deliver it to your endpoint with a POST. If that attempt fails, we keep trying on a growing schedule for about 36 hours before giving up.
- Retry loop: Delivery queue, Worker POSTs to your endpoint, 2xx within 10 s?, 410 Gone?, Attempts left?, Wait with backoff
- Event created → Delivery queue: enqueue
- Delivery queue → Worker POSTs to your endpoint: next attempt
- Worker POSTs to your endpoint → 2xx within 10 s?
- 2xx within 10 s? → Delivered: yes
- 2xx within 10 s? → 410 Gone?: no
- 410 Gone? → Endpoint disabled: yes
- 410 Gone? → Attempts left?: no
- Attempts left? → Wait with backoff: yes
- Attempts left? → Dead-letter queue: no
- Wait with backoff → Delivery queue: requeue
- Dead-letter queue → Manual replay: on request
SVCQUEUEFNDECISIONEXTTEAMfocusboundaryrequest / flowasync / reply / optional
In short
Delivery is at least once. Any 2xx response within 10 seconds counts as success. A timeout, a connection error, a 5xx and every 4xx other than 410 all count as a failure and enter the retry loop.
- Up to 8 attempts. The first is immediate, then 7 retries after waits of 30 s, 2 min, 10 min, 1 h, 3 h, 8 h and 24 h. Each wait gets up to 20% random jitter so a recovering server is not hit by every queued event at once.
- Retry-After is respected. On a 429 or 503 with a Retry-After header we wait for the longer of that value and the scheduled delay.
- 410 Gone stops everything. We treat it as a deliberate removal, disable the endpoint and email its owner. Queued events wait for you to re-enable it.
- After the 8th failure the event moves to the dead-letter queue. It is kept for 14 days and can be replayed from the dashboard or with
POST /.v1/ webhooks/ deliveries/ {id}/ replay - Circuit breaker. An endpoint that fails 100 events in a row is paused and its owner is emailed, so a dead server does not accumulate a backlog.
The retry schedule
Data
| retry | wait |
|---|---|
| Retry 1 | 0.5 min |
| Retry 2 | 2 min |
| Retry 3 | 10 min |
| Retry 4 | 60 min |
| Retry 5 | 180 min |
| Retry 6 | 480 min |
| Retry 7 | 1,440 min |
| retry | wait before it | total since first try | jitter window |
|---|---|---|---|
| 1 | 30 s | 30 s | 24 s to 36 s |
| 2 | 2 min | 2.5 min | 96 s to 144 s |
| 3 | 10 min | 12.5 min | 8 to 12 min |
| 4 | 1 h | about 1 h 12 min | 48 to 72 min |
| 5 | 3 h | about 4 h 12 min | 2.4 to 3.6 h |
| 6 | 8 h | about 12 h 12 min | 6.4 to 9.6 h |
| 7 | 24 h | about 36 h 12 min | 19.2 to 28.8 h |
What your endpoint should do
Because delivery is at least once, the same event can arrive more than once, for example when your server processed it but its 200 was lost. Every retry carries the same event id, so deduplicate on it. Respond fast and do the slow work afterward.
POST /hooks/payments HTTP/1.1
Webhook-Id: evt_01JA7Q3M
Webhook-Attempt: 3
Webhook-Timestamp: 1760803200
Content-Type: application/json
@app.post("/hooks/payments")
def receive():
event = request.get_json()
if already_processed(event["id"]): # retries reuse the same id
return "", 200
queue.put(event) # slow work happens later
return "", 200