Claude API Webhook Retry Strategy Design
If you're building anything that depends on Claude API responses arriving reliably—async job processing, long-running completions, tool-use callbacks—you need a retry strategy for the webhooks that deliver those results. The short answer: use exponential backoff with jitter, make your webhook handler idempotent, cap retries with a dead-letter queue, and verify signatures before you process anything. The rest of this article explains how to implement each piece without building a fragile, over-engineered system.
Webhook retries exist because networks fail, servers restart, and consumers occasionally choke on a payload. A good retry strategy assumes failure is normal and designs for it, rather than treating every delivery as a one-shot event that either works or is lost forever.
Why Claude API Webhooks Need a Retry Strategy at All
Claude's API itself is synchronous for most use cases, but once you wrap it in your own infrastructure — background workers, queue-based processing, or a service like SubToAPI that exposes your Claude access as an HTTPS API — you often end up delivering results via webhook: a job finishes, you POST the output to a customer's endpoint, and you need to know what happens when that POST fails.
Failure modes you have to plan for:
- The receiving server is down or returns a 5xx.
- The receiving server is slow and times out your request.
- The receiving server returns a 2xx but never actually processed the payload (crashed mid-handler).
- Your own delivery worker crashes before marking the webhook as delivered.
- The network drops the connection mid-request, so you don't know if it was received.
Each of these needs a different mental model, but one retry mechanism can cover all of them if designed correctly.
Core Design Principles
1. Make Every Webhook Idempotent
Before you write a single line of retry logic, make sure your webhook payloads are idempotent. Attach a unique event ID to every webhook, and require consumers to deduplicate on that ID.
{
"event_id": "evt_7f3a9c2e",
"type": "completion.finished",
"created_at": "2024-05-02T10:15:00Z",
"data": {
"request_id": "req_abc123",
"status": "completed"
}
}
If a retry delivers the same event_id twice, the receiver should process it once and acknowledge the second delivery as a no-op. Without this, retries turn into duplicate side effects — double-charged invoices, duplicate emails, duplicate database rows.
2. Use Exponential Backoff with Jitter
A fixed retry interval causes thundering-herd problems: if your downstream service goes down, every queued webhook retries at the same moment and re-overwhelms it the instant it comes back up. Exponential backoff with jitter spreads retries out naturally.
A reasonable schedule for most webhook systems:
attempt 1: immediate
attempt 2: 30s (+/- jitter)
attempt 3: 2m (+/- jitter)
attempt 4: 10m (+/- jitter)
attempt 5: 1h (+/- jitter)
attempt 6: 6h (+/- jitter)
final attempt: 24h, then dead-letter
Jitter should be randomized by 10–30% of the interval so retries don't cluster. In code:
function nextDelay(attempt) {
const base = [0, 30_000, 120_000, 600_000, 3_600_000, 21_600_000, 86_400_000];
const delay = base[Math.min(attempt, base.length - 1)];
const jitter = delay * (0.1 + Math.random() * 0.2);
return delay + jitter;
}
3. Cap Retries and Use a Dead-Letter Queue
Infinite retries are a liability, not a feature. Decide on a maximum number of attempts (6–8 is typical) and a maximum total retry window (24–72 hours). Once exhausted, move the event to a dead-letter queue where it can be inspected, replayed manually, or alerted on.
A dead-letter queue should store:
- The full payload
- Delivery attempt history with timestamps and response codes
- The last error message or status code received
This gives you an audit trail instead of a silent failure.
4. Only Retry on Retryable Failures
Not every non-2xx response should trigger a retry. A 400 Bad Request means your payload is malformed and retrying won't help — it will just fail the same way six more times. Treat these as retryable:
- Connection timeouts
500,502,503,504- No response at all
And treat these as non-retryable (log and dead-letter immediately):
400,401,403,404,410- Any response indicating the endpoint no longer exists
5. Verify Signatures Before Processing
Every webhook request should be signed with an HMAC secret so receivers can confirm it actually came from your system and wasn't replayed by an attacker who captured a prior delivery. Verify the signature and a timestamp window (reject anything older than 5 minutes) before doing any processing, including idempotency checks — this prevents replay attacks from triggering expensive downstream work.
const crypto = require('crypto');
function isValidSignature(payload, signature, secret) {
const expected = crypto
.createHmac('sha256', secret)
.update(payload)
.digest('hex');
return crypto.timingSafeEqual(
Buffer.from(expected),
Buffer.from(signature)
);
}
6. Return Fast, Process Async
Your webhook handler should acknowledge receipt (2xx) as fast as possible and queue the actual processing work separately. If your handler does slow synchronous work — calling Claude again, writing to multiple tables, sending emails — a timeout on the sender's side can trigger an unnecessary retry even though you did receive the event. Acknowledge first, process second.
Applying This to Claude-Based Pipelines
If you're calling Claude through an API layer that itself exposes webhooks — for status updates on long-running completions, streaming completion events, or tool-use callbacks — apply the same retry discipline on both sides: your outbound calls to Claude's API should have their own retry/backoff logic (see rate limit and timeout handling for that), and any webhooks you emit downstream to your own customers should follow the design above independently. These are two separate retry boundaries and conflating them makes debugging much harder.
If you're using SubToAPI to turn your Claude access into an HTTPS API, requests go through standard REST endpoints with full usage metadata per call, which makes it straightforward to track delivery and retry state yourself at the application layer. Check the docs and quickstart for request/response shapes, and the streaming guide if your pipeline needs event-by-event delivery rather than a single webhook at completion time.
A Minimal Retry Table Schema
If you're rolling your own system rather than using a managed queue, a simple table covers most cases:
CREATE TABLE webhook_deliveries (
id UUID PRIMARY KEY,
event_id TEXT NOT NULL,
endpoint_url TEXT NOT NULL,
payload JSONB NOT NULL,
attempt_count INT DEFAULT 0,
next_attempt_at TIMESTAMPTZ,
status TEXT DEFAULT 'pending', -- pending, delivered, dead_letter
last_response_code INT,
last_error TEXT
);
A worker polls for rows where status = 'pending' and next_attempt_at <= now(), attempts delivery, and updates attempt_count, next_attempt_at, and status accordingly.
questions
Do I need a separate retry strategy for Claude API calls and my own webhooks? Yes. Retrying a Claude API call (rate limits, timeouts) is a different failure domain than retrying delivery of your own webhook to a customer's endpoint. Handle each independently with its own backoff schedule.
How many retry attempts is reasonable for a webhook? Most production systems use 6–8 attempts over a 24–72 hour window with exponential backoff and jitter. Beyond that, move the event to a dead-letter queue rather than retrying indefinitely.
Should I retry on every non-2xx response? No. Retry on timeouts and 5xx errors. Treat 4xx errors (except 429) as non-retryable since they indicate a problem with the request itself that retries won't fix.