Webhook Retries: Headers and Correct Endpoint Responses
Webhook retries: headers and endpoint responses
If you are troubleshooting webhook retry after header handling 429 too many requests, start with one rule: your receiver must be safe under retries even when headers are missing or inconsistent.
This guide shows exactly how to map status codes to retry behavior, what Retry-After can and cannot do, and how to make your Laravel endpoint durable before acknowledging success.
You will implement a persist-then-ack flow, test it with real SendPromptly ingestion, and debug cases where a webhook provider keeps retrying after what looks like a successful response.
Reference implementation: Retry decision matrix (GitHub Pages)
Baseline truth: your endpoint must assume “at-least-once”
Success is 2xx; how SendPromptly treats non-2xx responses
2xx— success: retries stop.4xx— treated as a permanent failure (non-retriable); delivery is marked failed and no further retries are scheduled.429— treated as rate-limited; SendPromptly will parseRetry-After(if present) and schedule the next attempt accordingly.5xx, connection errors, or timeouts — treated as transient and retried according to SendPromptly’s retry policy (see schedule below).
Keep this contract strict in your handler design. See Delivery rules: 2xx success, retries, signature headers.
Why retries happen even when you think it succeeded (timeouts, connection drops)
Retries happen when the sender cannot confirm your response, even if your app completed work. Timeouts, early connection closes, and intermediary network drops all create this gap.
Mini incident: a team wrote to the database, then their load balancer timed out the response before the sender received 200. The sender retried, and without dedupe the same order update ran twice.
“Retry headers” you may see (and what to do)
This section covers webhook retry after header handling 429 too many requests and how you should interpret Retry-After vs provider hints.
Retry-After (you can send it with 429/503)
If you are overloaded, return 429 or 503 with Retry-After as a best-effort signal to slow incoming retry traffic.
Minimal test snippet:
1 2 3 4curl -i -X POST "https://your-endpoint/webhooks/sendpromptly" \ -H "Content-Type: application/json" \ -d '{}' \ -H "Retry-After: 30"
Provider-specific attempt headers (treat as hints, not guarantees)
Assumption: provider-specific attempt header names and semantics vary, so use them for observability only, not core correctness logic.
| Header or signal | Who sets it | How to use it safely |
|---|---|---|
Retry-After | Your endpoint | Include with 429/503 to suggest backoff windows |
| Attempt count headers (provider-specific) | Sender | Log for debugging only; do not gate persistence logic |
| No retry headers present | Either side | Still treat delivery as at-least-once and dedupe |
Correct response strategy (the decision table)
Micro checklist:
- Persist payload (DB/queue) before returning 2xx
- Return
503(+Retry-After) for transient infra failures- Route poison/permanent contract issues to DLQ and acknowledge
- Ensure idempotency on downstream side effects
Return 2xx when you have safely persisted the payload
For when to return 200 vs 500 to stop webhook retries, return 200 only after the payload is durably persisted or queued.
Return 5xx/503 when you cannot persist/queue (true transient)
If database/queue infrastructure is unavailable, return 503 (or 5xx) so the sender retries later.
SendPromptly retry policy (reference)
SendPromptly performs up to 5 attempts for webhook delivery. The retry delays are:
- Attempt 1 — immediate
- Attempt 2 — +1 minute
- Attempt 3 — +10 minutes
- Attempt 4 — +1 hour
- Attempt 5 — +6 hours
When a Retry-After is returned with 429, that value is preferred (SendPromptly will use it when numeric or parseable as a date). 4xx errors are considered permanent failures and will stop retries.
Avoid returning 4xx for contract issues you want to inspect later (DLQ instead)
If a payload is semantically invalid for current business logic but worth inspection, persist it and acknowledge, then route to DLQ/review instead of forcing endless retries.
| Endpoint condition | Response | Retry outcome | Why |
|---|---|---|---|
| Payload persisted (or queued durably) | 200 | Retries stop | You safely accepted responsibility |
| Temporary infra failure (DB/queue down) | 503 (+ Retry-After) | Retries continue | Transient recovery path |
| Temporary overload | 429 (+ Retry-After) | Retries continue | Backpressure without dropping events |
| Contract issue needing manual review | 200 + DLQ marker | Retries stop | Prevents useless retry loops |
Use Sample Project to trigger a delivery, then confirm your Message Log shows a single successful attempt.
Laravel pattern: persist → ack 200 → async process
Inbox table + queue job
Before this route runs, verify signature headers in middleware and compare signatures with constant-time hash_equals; never log raw secrets.
| Approach | Pros | Cons |
|---|---|---|
| Direct process in request | Simple | Risk of timeouts / retries |
| Persist then job | Safe, fast ack | Extra storage + job management |
| |
Idempotency / dedupe guards
Add a unique index on dedupe_key, make downstream writes idempotent, and short-circuit duplicates so retries do not repeat side effects. For implementation patterns, see Stop duplicate processing during retries. For poison payload handling, see DLQ pattern for poison events.
Test steps:
- Trigger a real run using Send a test event + confirm delivery runs in Message Log.
- Confirm your webhook endpoint responds
200quickly.
| |
Expected: Message Log shows delivered when endpoint returns 2xx (success). Review Delivery rules: 2xx success, retries, signature headers.
Debugging “keeps retrying after 2xx”
Mini checklist:
- Confirm endpoint returns
200directly (no redirects)- Check proxy/gateway logs for timeouts or 502/504s
- Verify the response reaches the sender before connection close
- Inspect Message Log attempt latency and edge status
Redirects, proxy buffering, upstream timeouts
If your path returns 301/302, if a proxy buffers too long, or if upstream timeout budgets are shorter than your app latency, the sender may never receive a final 2xx confirmation.
Your app returns 2xx but closes connection early
A handler can log 200 internally but still fail delivery if workers crash mid-response, PHP-FPM/NGINX closes early, or an intermediary resets the stream.
| |
For webhook provider keeps retrying after 2xx response, compare upstream app status, edge/gateway status, and end-to-end latency on the same attempt ID. For webhook retries max attempts vs retry until timeout, assume variability across providers and design for safe repeated delivery until the sender stops.
Common failure modes
- Returning 200 before persisting -> sender stops retrying, but you lost the payload.
- Returning 301/302 (redirect) -> sender treats as failure; retries.
- Gateway timeout (your app took too long) -> sender retries despite eventual processing.
- Sending 429 without
Retry-After-> retry storms. - Treating “bad payload” as 500 -> endless retries for permanent contract issues.
- No dedupe -> retries create duplicate side effects.
Conclusion
- Treat webhook delivery as at-least-once, not exactly-once.
- Return
200only after durable persistence or queue acceptance. - Use
503/429withRetry-Afteronly for real transient pressure. - Keep dedupe and side effects idempotent across endpoint and worker layers.
- Debug retries using edge status + app status + latency on the same attempt.
If you see repeats, open Message Log → inspect status codes/latency → fix your response strategy.