How webhook delivery systems work
How webhook systems reliably deliver HTTP callbacks using event queues, exponential backoff retries, signature verification, and dead letter queues to handle millions of outbound notifications.
The Interview Question
Interviewer: "Your platform needs to notify thousands of external applications whenever a payment completes. Walk me through how you would design a webhook delivery system that handles retries, guarantees at-least-once delivery, and does not overwhelm the consumers. How do Stripe and GitHub do it?"
This question tests whether you understand the full lifecycle of outbound event notifications. The interviewer wants to hear about queue-based delivery, retry strategies with backoff, signature verification, idempotency, and what happens when a consumer is permanently down. Candidates who say "just send an HTTP POST" miss 90% of the real engineering.
What to Clarify Before Answering
You: "Let me clarify the scope before diving in..."
- "Are we building the webhook producer side (sending notifications) or the consumer side (receiving them)? I will cover both, but the design is different."
- "What scale are we targeting? Thousands of events per second (Stripe-scale) or hundreds per day?"
- "Do we need exactly-once delivery semantics, or is at-least-once with consumer-side idempotency acceptable?"
- "Should I cover fan-out scenarios where one event goes to multiple subscribers?"
- "Is the payload sensitive? That affects whether we send the full event or just a notification with an ID for the consumer to fetch."
Why this matters: A webhook system for 100 subscribers sending 10 events per day is a cron job. A webhook system for 100,000 subscribers sending millions of events per day is a distributed system with queues, worker pools, circuit breakers, and dead letter queues. Scoping the answer shows you understand the spectrum.
The 30-Second Answer
A webhook delivery system converts internal events into outbound HTTP POST requests sent to consumer-registered URLs. Events flow from the application into an event queue (SQS, Kafka, or RabbitMQ), where delivery workers pick them up and send HTTP requests to registered endpoints. Each delivery attempt is recorded. If a consumer returns a non-2xx response, the system retries with exponential backoff and jitter, typically up to 5-8 attempts over 24-72 hours. Every request includes an HMAC-SHA256 signature so consumers can verify the payload was not tampered with. A timestamp header prevents replay attacks. After all retries are exhausted, failed events move to a dead letter queue with a dashboard so developers can inspect and manually replay them. At scale, the system adds per-endpoint rate limiting and circuit breakers to avoid overwhelming slow consumers or wasting resources on permanently down endpoints.
The Architecture Overview
The architecture splits cleanly into four layers. The application layer generates events. The queue layer provides durability and decouples event production from delivery. The worker pool handles the actual HTTP delivery with rate limiting and circuit breaking. The observability layer records every attempt for debugging.
I find this separation critical. Without the queue, a spike in events would either overwhelm consumers or block the application from processing new transactions. The queue acts as a shock absorber.
Event Generation and Queueing
The first step in any webhook system is converting an internal state change into a deliverable event. When a payment completes, the service creates an event payload with a unique ID, event type, timestamp, and the relevant data.
The critical design decision is to persist the event to a database before enqueuing it. If the queue goes down momentarily, you can replay from the database. Stripe does this: every event is stored in a durable events table, and the queue is a delivery mechanism, not the source of truth.
Do not skip the database
If you only put events in the queue and the queue loses a message, the event is gone forever. Always write to a durable store first, then enqueue. The events table is your recovery mechanism.
Each event gets a unique ID (UUID or ULID). The payload typically includes:
{
"id": "evt_1NqL7z2eZvKYlo2C",
"type": "payment.completed",
"created": 1681234567,
"data": {
"object": {
"id": "pay_abc123",
"amount": 2500,
"currency": "usd",
"status": "succeeded"
}
}
}
Fan-out happens at the subscription registry level. One internal event (payment completed) becomes N delivery tasks, one per registered endpoint. This is why the queue matters: if you have 10,000 subscribers, you need 10,000 independent delivery tasks, each with its own retry state.
Delivery Pipeline and Retry Strategy
This is the heart of a webhook system. The delivery worker dequeues a task, constructs the HTTP request, signs it, sends it, and handles the response.
Exponential backoff with jitter
The retry schedule uses exponential backoff to avoid thundering herds. The formula is:
delay = min(base_delay * 2^attempt + random_jitter, max_delay)
The jitter is critical. Without it, if 1,000 deliveries fail at the same time (because a consumer went down for 30 seconds), all 1,000 retries fire simultaneously when the consumer comes back. That is a self-inflicted DDoS. Jitter spreads the retries across a window.
Stripe's retry schedule
Stripe retries webhook deliveries up to 8 times over approximately 3 days. The intervals are roughly: immediately, 1 minute, 5 minutes, 30 minutes, 2 hours, 8 hours, 24 hours, 48 hours. After that, the event moves to the dashboard for manual retry.
Response code handling
Not all failures are the same:
| Response | Action | Rationale |
|---|---|---|
| 200-299 | Mark delivered | Consumer acknowledged receipt |
| 301/302 | Follow redirect (once) | Endpoint moved, but follow cautiously |
| 400 | Retry (consumer bug) | Consumer might fix their handler |
| 401/403 | Retry (credential issue) | Consumer might rotate their verification secret |
| 404 | Retry a few times, then DLQ | Endpoint might be temporarily unregistered |
| 410 Gone | Stop immediately, disable endpoint | Consumer explicitly says "stop sending" |
| 429 | Retry with Retry-After header | Consumer is rate limiting you |
| 500-503 | Retry with backoff | Server error, likely transient |
| Timeout (>30s) | Retry with backoff | Network issue or slow consumer |
Signature Verification and Security
Every webhook delivery must be signed so the consumer can verify it came from you and was not tampered with in transit. The standard approach is HMAC-SHA256.
Why include a timestamp?
The timestamp prevents replay attacks. Without it, an attacker who intercepts a valid webhook payload could replay it days later. The consumer checks that the timestamp is within a tolerance window (typically 5 minutes). Any delivery older than that is rejected.
Use the raw request body for verification
A common consumer-side bug is parsing the JSON body, then re-serializing it for signature verification. JSON serialization is not guaranteed to preserve key order or whitespace. Always compute the HMAC over the raw bytes of the request body, exactly as received. Any transformation invalidates the signature.
Secret management
Each consumer gets a unique webhook secret when they register their endpoint. This means a compromised secret for one consumer does not affect any other consumer. Secrets should be at least 32 bytes of cryptographic randomness, displayed once at registration, and rotatable without downtime.
Stripe supports secret rotation by allowing two active secrets simultaneously. During rotation, the producer signs with both the old and new secret, sending two signatures in the header. The consumer can verify against either one. After a grace period, the old secret is revoked.
Idempotency: Handling Duplicate Deliveries
At-least-once delivery means consumers will sometimes receive the same event twice. Network timeouts are the most common cause: the consumer processes the event and returns 200, but the producer times out before receiving the response and retries.
Consumers must handle duplicates. The standard pattern is:
- Extract the event ID from the payload (e.g.,
evt_1NqL7z2eZvKYlo2C) - Check if the event ID exists in a local processed-events table
- If it exists, return 200 immediately without reprocessing
- If it does not exist, process the event, then insert the ID into the table
- Return 200
The idempotency key is the event ID, not the delivery ID
Each delivery attempt has a unique attempt ID, but the same event ID. Consumers should deduplicate on the event ID. If you deduplicate on the attempt ID, you will process the same event multiple times, defeating the purpose.
Dead Letter Queues and Debugging
When all retry attempts are exhausted, the event moves to a dead letter queue (DLQ). This is not a black hole. It is a debugging tool with a user-facing dashboard.
A good DLQ dashboard shows:
| Field | Purpose |
|---|---|
| Event ID | Unique identifier for the event |
| Event type | What happened (payment.completed, invoice.created) |
| Endpoint URL | Where delivery was attempted |
| Attempt count | How many times the system tried |
| Last response code | The most recent HTTP response |
| Last response body | First 1KB of the consumer's error response |
| First attempted at | When the first delivery attempt happened |
| Last attempted at | When the system gave up |
| Manual retry button | One-click to re-enqueue the event |
DLQ is a feature, not a failure
Every mature webhook provider (Stripe, GitHub, Twilio) has a delivery log and manual retry mechanism. Engineers use this daily to debug integration issues. Treat the DLQ and dashboard as first-class product features, not afterthoughts.
Stripe's approach is instructive. Their dashboard shows every webhook event with its delivery status, every attempt with the response code and latency, and a "Resend" button. When a developer is integrating, they can see exactly why their endpoint is failing (usually a 500 with a stack trace in the response body) and fix it without contacting support.
Fan-Out at Scale
At Stripe or GitHub scale, a single event can trigger thousands of deliveries. A popular repository with 5,000 installed GitHub Apps means one push event generates 5,000 independent webhook deliveries.
Architecture for high fan-out
The key optimization is separating event creation from delivery fan-out:
- Event ingestion: Write the event once to the events table
- Fan-out stage: Query the subscription registry, create one delivery task per subscriber, enqueue all tasks
- Delivery stage: Workers process tasks independently
The fan-out stage can be parallelized. For 10,000 subscribers, you batch the enqueue operations (SQS supports SendMessageBatch with up to 10 messages per call, so 1,000 batch calls). Kafka handles this naturally with topic partitioning.
Per-endpoint rate limiting
You cannot send 1,000 events per second to a consumer running on a single $20/month VPS. Each endpoint gets a rate limit (typically 100-500 requests per minute). Deliveries that exceed the limit are delayed, not dropped. The rate limiter uses a token bucket per endpoint URL.
Circuit breaking
If an endpoint fails 5 consecutive deliveries, the circuit breaker trips. While the circuit is open, deliveries are queued but not attempted. Every 60 seconds, the circuit enters a half-open state and probes with a single delivery attempt. If it succeeds, the circuit closes and normal delivery resumes. If it fails, the circuit stays open.
This prevents the system from wasting millions of HTTP requests on endpoints that are permanently down. It also protects consumers from being hammered during an outage, which would make recovery harder.
What Happens When Things Break
| Failure | What Happens | How to Detect | How to Fix |
|---|---|---|---|
| Consumer returns 500 for 3 days | All retries exhaust, events land in DLQ | DLQ depth alarm, delivery success rate drops | Consumer fixes their handler, manually replays from DLQ |
| Queue goes down (SQS outage) | Events accumulate in the events table, no new deliveries | Queue depth drops to 0, delivery rate drops to 0 | Events table acts as recovery source, replay undelivered events when queue recovers |
| Worker pool crashes | Delivery stops, queue depth grows | Queue depth alarm, no delivery log entries | Auto-scaling replaces workers, tasks are re-delivered from queue |
| Consumer is slow (30s+ response) | Timeouts eat worker threads, delivery throughput drops | P99 delivery latency alarm, thread pool exhaustion | Set aggressive timeouts (30s max), circuit break slow endpoints |
| Secret rotation mishandled | Consumer rejects all deliveries with invalid signature | Spike in 401 responses from one endpoint | Dual-signing during rotation window, verify against both secrets |
| Thundering herd after outage | All delayed events retry simultaneously | Spike in outbound HTTP traffic, consumer overwhelmed | Jitter on retry delays, per-endpoint rate limiting |
Performance Characteristics
| Metric | Typical Value | Notes |
|---|---|---|
| Delivery latency (P50) | 200-500ms | Dominated by consumer response time |
| Delivery latency (P99) | 2-5s | Slow consumers or network latency |
| Throughput (per worker) | 50-200 deliveries/sec | Depends on consumer response time |
| Event-to-first-attempt delay | < 1s | Time from event emission to first HTTP POST |
| Retry window | 24-72 hours | Total time before giving up |
| DLQ processing time | Minutes to hours | Manual, depends on developer response |
| Signature computation | < 1ms | HMAC-SHA256 is fast |
| Fan-out latency (10K subscribers) | 5-15s | Time to enqueue all delivery tasks |
How This Compares to Alternatives
| Feature | Webhooks (push) | Polling (pull) | Server-Sent Events | WebSocket |
|---|---|---|---|---|
| Direction | Server pushes to consumer | Consumer polls server | Server pushes to browser | Bidirectional |
| Latency | < 1s (near real-time) | Polling interval (10s-5min) | < 1s | < 100ms |
| Consumer complexity | HTTP endpoint + signature verification | Simple HTTP GET with pagination | EventSource API | WebSocket client |
| Scalability | O(N) outbound connections per event | O(N) polling requests per interval | Persistent connections | Persistent connections |
| Failure handling | Retry with backoff, DLQ | Consumer retries on next poll | Auto-reconnect | Reconnect logic |
| Works across firewalls | Yes (outbound POST) | Yes (outbound GET) | Yes (HTTP-based) | Sometimes blocked |
| Best for | Server-to-server notifications | Simple integrations, batch processing | Real-time browser updates | Interactive applications |
I reach for webhooks when I need to notify external applications about events in near real-time. Polling works for simple integrations where the consumer only needs periodic updates. Server-Sent Events are ideal for browser-based dashboards. WebSockets are for truly interactive, bidirectional communication like chat or collaborative editing.
Interview Cheat Sheet
- When asked about delivery guarantees: "Webhooks provide at-least-once delivery. Events are persisted to a database first, then enqueued for delivery. Consumers must be idempotent because duplicates are inevitable."
- When asked about retries: "Exponential backoff with jitter. Stripe retries 8 times over 3 days. Jitter prevents thundering herds when a consumer recovers from an outage."
- When asked about security: "HMAC-SHA256 signature over the raw payload with a per-consumer secret. A timestamp header prevents replay attacks. Consumers verify within a 5-minute tolerance window."
- When asked about scale: "Fan-out separates event creation from delivery. Each subscriber gets an independent delivery task in a queue. Per-endpoint rate limiting and circuit breakers protect both the producer and consumers."
- When asked about failures: "Dead letter queue captures events that exhaust all retries. A developer dashboard shows delivery logs, response codes, and a manual replay button. This is a product feature, not just an ops tool."
- When asked about idempotency: "The producer includes a unique event ID. Consumers store processed IDs in a database with a TTL matching the retry window. Duplicate deliveries are silently acknowledged."
- When asked about alternatives to webhooks: "Polling is simpler but higher latency. SSE and WebSockets require persistent connections. Webhooks are the standard for server-to-server event notification because they work across firewalls and scale independently."
- When asked about ordering: "Webhooks do not guarantee ordering. Events can arrive out of order due to retries and parallelism. Consumers should use the event timestamp, not arrival order, and handle out-of-order delivery gracefully."
Test Your Understanding
Quick Recap
- Webhook systems convert internal events into outbound HTTP POST requests delivered to consumer-registered URLs.
- Events are persisted to a durable store first, then enqueued for delivery, ensuring no data loss if the queue fails.
- Delivery workers sign the payload with HMAC-SHA256 and send the HTTP request with a timestamp header for replay protection.
- Failed deliveries are retried with exponential backoff and jitter, typically 5-8 attempts over 24-72 hours, with response-aware scheduling.
- Consumers must be idempotent, using the event ID to deduplicate, because at-least-once delivery guarantees duplicates will occur.
- Dead letter queues capture events that exhaust all retries, surfaced in a developer dashboard with manual replay buttons.
- Per-endpoint rate limiting and circuit breakers protect both the producer and consumers at scale.
- Webhooks are the standard for server-to-server event notification because they provide near real-time delivery and work across firewalls.
Related Concepts
- Message queues (SQS, RabbitMQ, Kafka): The backbone of webhook delivery infrastructure, providing durable event buffering and ordered processing.
- Circuit breaker pattern: The same pattern used in microservice communication applies to outbound webhook delivery, preventing resource exhaustion on failing endpoints.
- Idempotency in distributed systems: The consumer-side requirement for handling duplicate webhook deliveries is the same idempotency pattern used in payment systems and API design.
- Event-driven architecture: Webhooks are the external-facing manifestation of internal event-driven systems, bridging the gap between your event bus and third-party consumers.
- API rate limiting: The per-endpoint rate limiting in webhook systems uses the same token bucket and sliding window algorithms as inbound API rate limiting, applied in the outbound direction.