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 parse Retry-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
4
curl -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 signalWho sets itHow to use it safely
Retry-AfterYour endpointInclude with 429/503 to suggest backoff windows
Attempt count headers (provider-specific)SenderLog for debugging only; do not gate persistence logic
No retry headers presentEither sideStill 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:

  1. Attempt 1 — immediate
  2. Attempt 2 — +1 minute
  3. Attempt 3 — +10 minutes
  4. Attempt 4 — +1 hour
  5. 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 conditionResponseRetry outcomeWhy
Payload persisted (or queued durably)200Retries stopYou safely accepted responsibility
Temporary infra failure (DB/queue down)503 (+ Retry-After)Retries continueTransient recovery path
Temporary overload429 (+ Retry-After)Retries continueBackpressure without dropping events
Contract issue needing manual review200 + DLQ markerRetries stopPrevents 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.

ApproachProsCons
Direct process in requestSimpleRisk of timeouts / retries
Persist then jobSafe, fast ackExtra storage + job management
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
Route::post('/webhooks/sendpromptly', function (\Illuminate\Http\Request $request) {
    $raw = $request->getContent();
    $dedupeKey = hash('sha256', $raw);

    // If DB/queue is down, signal transient failure (will retry)
    try {
        \DB::table('webhook_inbox')->updateOrInsert(
            ['dedupe_key' => $dedupeKey],
            ['payload' => json_decode($raw, true), 'received_at' => now(), 'updated_at' => now(), 'created_at' => now()]
        );
    } catch (\Throwable $e) {
        // Ask sender to slow down (best-effort)
        return response()->json(['error' => 'temporarily_unavailable'], 503)
            ->header('Retry-After', '30');
    }

    dispatch(new \App\Jobs\ProcessWebhookInbox($dedupeKey));

    return response()->json(['accepted' => true], 200);
});

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:

  1. Trigger a real run using Send a test event + confirm delivery runs in Message Log.
  2. Confirm your webhook endpoint responds 200 quickly.
1
2
3
4
5
6
7
8
9
curl -X POST https://app.sendpromptly.com/api/v1/events \
  -H "Authorization: Bearer sp_dev_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx" \
  -H "Idempotency-Key: retry-headers-test-001" \
  -H "Content-Type: application/json" \
  -d '{
    "event_key": "order.created",
    "recipient": {"email": "[email protected]", "name": "Dev User"},
    "payload": {"order_id": "O-1001"}
  }'

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 200 directly (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.

1
2
2026-02-14T12:02:11Z webhook_delivery event=evt_123 attempt=3 upstream_status=200 edge_status=504 latency_ms=30050 result=retry
2026-02-14T12:02:48Z webhook_delivery event=evt_123 attempt=4 upstream_status=200 edge_status=200 latency_ms=142 result=delivered

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

  1. Returning 200 before persisting -> sender stops retrying, but you lost the payload.
  2. Returning 301/302 (redirect) -> sender treats as failure; retries.
  3. Gateway timeout (your app took too long) -> sender retries despite eventual processing.
  4. Sending 429 without Retry-After -> retry storms.
  5. Treating “bad payload” as 500 -> endless retries for permanent contract issues.
  6. No dedupe -> retries create duplicate side effects.

Conclusion

  • Treat webhook delivery as at-least-once, not exactly-once.
  • Return 200 only after durable persistence or queue acceptance.
  • Use 503/429 with Retry-After only 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.