Design a Webhook System
A webhook system receives real-time event notifications from external services (Stripe, Shopify, GitHub) and processes them reliably. Unlike polling-based APIs where you repeatedly ask "did anything change?", webhooks push data immediately when events occur—making them efficient for real-time integrations.
This walkthrough follows the Interview Framework and focuses on what you'd actually present in a 45-60 minute interview.
Phase 1: Requirements (~5 minutes)
Functional Requirements
- Receive event notifications — Accept HTTP POST requests from external services (e.g., payment confirmed, order shipped)
- Process events reliably — Execute business logic and persist results for tracking and auditing
- Handle retries gracefully — External services retry failed deliveries; our system must handle duplicates
Keep scope focused on receiving webhooks, not sending them. Sending webhooks (like Stripe does to merchants) is a different design problem with its own challenges around delivery guarantees and retry policies.
Out of Scope
- Managing downstream state changes triggered by webhooks (that's application-specific business logic)
- Building a webhook delivery system (sending webhooks to customers)
Non-Functional Requirements
| Requirement | Target | Rationale |
|---|---|---|
| Scale | 1M events/day | Typical for a mid-sized e-commerce platform |
| Peak traffic | 5x burst | Flash sales, Black Friday spikes |
| Latency | < 200ms acknowledgment | External services timeout and retry if we're slow |
| Retention | 30 days | Debugging, audit trails, replay capability |
| Availability | 99.9%+ | Missed webhooks mean missed business events |
| Processing | At-least-once | Every accepted event must be processed (duplicates OK) |
Capacity Estimation
Throughput:
1M events/day ÷ 86,400 seconds ≈ 12 events/second (average)
Peak (5x): ~60 events/second
Storage (30-day retention):
1M events/day × 5KB per event × 30 days = 150 GB
This is modest scale—a single PostgreSQL instance handles this easily. The interesting challenges aren't about scale, but about reliability: ensuring no events are lost even when components fail.
Phase 2: Data Model (~5 minutes)
Core Entities
WebhookEvent
├── id (UUID, PK)
├── idempotency_key (string, unique) — "source:event_id" for deduplication
├── source (string) — "stripe", "shopify", "github"
├── event_type (string) — "payment.succeeded", "order.created"
├── payload (JSONB) — Raw event data
├── status: processing | completed | failed
├── attempts (int) — Processing attempt count
├── error_message (string, nullable)
├── processed_at (timestamp, nullable)
├── created_at (timestamp)
└── updated_at (timestamp) — For detecting stale processing
WebhookSource
├── id (PK)
├── name (string) — "stripe", "shopify"
├── signing_secret (encrypted) — For HMAC verification
├── ip_allowlist (array) — Optional IP restrictions
└── is_active (boolean)
The idempotency_key is crucial—use a composite like stripe:evt_xxx to avoid collisions across providers and detect retries after timeouts.
Phase 3: API Design (~5 minutes)
Receive Webhook
POST /webhooks/{source}
Headers:
Content-Type: application/json
Stripe-Signature: t=1234567890,v1=abc123... (varies by provider)
Body:
{
"id": "evt_1234",
"type": "payment_intent.succeeded",
"data": {
"object": {
"id": "pi_abc123",
"amount": 2000,
"currency": "usd"
}
}
}
Response: 200 OK
{
"status": "accepted"
}
Why return 200 immediately?
External services like Stripe have strict timeout policies (typically 5-20 seconds). If we don't respond quickly, they'll:
- Consider the delivery failed
- Retry (causing duplicates)
- Eventually disable our webhook endpoint
We acknowledge receipt immediately, then process asynchronously.
Return 200 after the event is safely persisted (in queue or database), not before. If you return 200 but crash before persisting, the event is lost and the provider won't retry.
Get Event Status (Internal API)
GET /api/webhook-events/{id}
Response:
{
"id": "evt_1234",
"source": "stripe",
"event_type": "payment_intent.succeeded",
"status": "completed",
"processed_at": "2024-01-15T10:30:00Z"
}
If you need status immediately after receipt, insert a received record in the handler before enqueueing. Otherwise, this endpoint reflects events only after a consumer claims them.
Phase 4: High-Level Design (~15-25 minutes)
Why Not Process Synchronously?
Let's start with the naive approach and understand why it fails:
Problems:
- If the handler crashes after processing but before responding, the provider retries and we double-process
- If the database is slow, we timeout and the provider retries
- Handler does too much: HTTP handling + business logic + persistence
- No buffering during traffic spikes
Adding a Message Queue
Benefits:
- Faster acknowledgment: Handler only validates and enqueues, returns 200 quickly
- Reliability: Queue persists events even if consumers are down
- Scalability: Add more consumers during peak load
- Failure isolation: Consumer crashes don't affect request handling
Complete Architecture
Request Handler Flow
def handle_webhook(request, source):
# 1. Verify signature (security)
if not verify_signature(request, source):
return Response(status=401)
# 2. Parse and validate payload
event = parse_event(request.body)
# 3. Quick deduplication check (Redis)
if redis.exists(f"webhook:{source}:{event.id}"):
return Response({"status": "duplicate"}, status=200)
# 4. Enqueue for processing
try:
queue.publish({
"source": source,
"event_id": event.id,
"event_type": event.type,
"payload": event.data,
"signature": request.headers.get("signature"),
"received_at": now()
})
except QueueUnavailable:
return Response(status=503) # Provider will retry
# 5. Mark as seen (short TTL for dedup window)
redis.setex(f"webhook:{source}:{event.id}", 3600, "1")
# 6. Return 200 immediately
return Response({"status": "accepted"}, status=200)
The Redis deduplication check is an optimization, not a guarantee. The consumer must also handle duplicates because Redis might evict the key or the handler might crash between steps 4 and 5.
Consumer Flow
MAX_RETRIES = 5
PROCESSING_TIMEOUT = timedelta(minutes=5)
RETRY_DELAYS = [1, 5, 30, 120, 600] # seconds - exponential backoff
def process_webhook(message):
event = message.payload
idempotency_key = f"{event.source}:{event.event_id}"
# 1. Try to claim this event atomically
db_event, action = claim_event(idempotency_key, event)
if action == "ack":
message.ack() # Already completed
return
elif action == "nack":
message.nack() # Retry later
return
elif action == "dlq":
message.send_to_dlq()
message.ack() # Remove from main queue
return
# 2. We claimed it - process the event
try:
execute_business_logic(event)
db.update("""
UPDATE webhook_events SET status = 'completed', processed_at = NOW()
WHERE id = ?
""", db_event.id)
message.ack()
except Exception as e:
db.update("""
UPDATE webhook_events SET status = 'failed', error_message = ?
WHERE id = ?
""", str(e), db_event.id)
if db_event.attempts >= MAX_RETRIES:
message.send_to_dlq()
message.ack() # Remove from main queue
else:
# Exponential backoff before retry
delay = RETRY_DELAYS[min(db_event.attempts - 1, len(RETRY_DELAYS) - 1)]
message.nack(requeue_delay=delay)
():
result = db.execute(, idempotency_key, event.source, event.event_type, event.payload)
result:
(result, )
existing = db.query(, idempotency_key)
existing.status == :
(, )
existing.status == :
existing.updated_at < now() - PROCESSING_TIMEOUT:
claimed = db.execute(, existing.)
claimed:
(claimed, )
(, )
existing.attempts >= MAX_RETRIES:
(, )
claimed = db.execute(, existing.)
claimed:
(claimed, )
(, )
Why ON CONFLICT DO NOTHING? This ensures only ONE consumer "wins" the right to process each event. If two consumers race to process the same event (from provider retries), only the first INSERT succeeds. The second gets NULL and backs off. The same atomic pattern applies to retrying failed events.
Stale processing detection: If a consumer crashes while processing (status='processing'), the event gets stuck. The updated_at timeout check allows another consumer to reclaim events that have been "processing" for too long (e.g., 5 minutes). Size the timeout to p99 processing time, or use heartbeats/leases for long-running jobs.
Phase 5: Deep Dives (~15-20 minutes)
1. Security: Verifying Webhook Authenticity
External services sign their webhooks using HMAC. Without verification, attackers could send fake events to trigger unintended actions (e.g., fake "payment succeeded" events).
How HMAC Signing Works:
def verify_stripe_signature(payload, signature_header, secret):
# Use the raw request body bytes (Stripe requires exact payload)
# Parse header: "t=1234567890,v1=abc123..."
# Use split("=", 1) to handle base64 signatures containing "="
parts = dict(p.split("=", 1) for p in signature_header.split(","))
timestamp = parts["t"]
expected_sig = parts["v1"]
# Recreate the signed payload
signed_payload = f"{timestamp}.{payload}"
# Calculate our signature
computed_sig = hmac.new(
secret.encode(),
signed_payload.encode(),
hashlib.sha256
).hexdigest()
# Constant-time comparison (prevents timing attacks)
return hmac.compare_digest(computed_sig, expected_sig)
Additional Security Layers:
| Measure | Purpose |
|---|---|
| HMAC verification | Proves request came from legitimate source |
| IP allowlisting | Reject requests from unknown IPs (if provider publishes IP ranges) |
| Timestamp validation | Reject old signatures to prevent replay attacks |
| Rate limiting | Protect against DoS from compromised or malicious sources |
Store signing secrets securely. Use environment variables or a secrets manager (AWS Secrets Manager, HashiCorp Vault). Never commit them to code or logs.
2. Handling Duplicate Events
Duplicates are inevitable in webhook systems:
- Provider retries after timeout
- Network issues cause retransmission
- Provider's own retry logic
Two-Layer Deduplication:
| Layer | Purpose | Trade-off |
|---|---|---|
| Redis (fast) | Quick rejection of obvious duplicates | May have false negatives (key eviction) |
| Database (authoritative) | Guaranteed deduplication via unique constraint | Slower, but reliable |
CREATE TABLE webhook_events (
id UUID PRIMARY KEY,
idempotency_key VARCHAR(255) UNIQUE NOT NULL, -- source:event_id
-- ... other fields
);
The unique constraint on idempotency_key ensures that even if Redis misses a duplicate, the database will reject the insert.
3. Handling Out-of-Order Events
Events may arrive out of order due to network delays or provider retry behavior. For example, Stripe might send:
invoice.paid(arrives first)invoice.created(arrives second)
Strategies:
Option 1: Fetch Latest State from Source
Don't rely on event data alone—fetch the current state from the provider's API:
def process_stripe_invoice_event(event):
invoice_id = event.data.object.id
# Always fetch latest state from Stripe
current_invoice = stripe.Invoice.retrieve(invoice_id)
# Update our database with current state
db.upsert("""
INSERT INTO invoices (stripe_id, status, amount, updated_at)
VALUES (?, ?, ?, ?)
ON CONFLICT (stripe_id) DO UPDATE SET
status = EXCLUDED.status,
amount = EXCLUDED.amount,
updated_at = EXCLUDED.updated_at
WHERE invoices.updated_at < EXCLUDED.updated_at
""", invoice_id, current_invoice.status, current_invoice.amount, current_invoice.updated)
Option 2: Use Event Timestamps
Compare timestamps and skip stale events:
def process_event(event):
existing = db.query("SELECT * FROM orders WHERE id = ?", event.order_id)
if existing and existing.updated_at >= event.timestamp:
# Event is stale, skip processing
return
# Process event...
Prefer Option 1 (fetch from source) for critical data like payments. The API always has the latest truth. Option 2 works for high-volume, less critical events where API calls would be too expensive.
4. Failure Handling at Each Layer
Request Handler Failures:
| Failure | Result | Mitigation |
|---|---|---|
| Crash before enqueue | Provider doesn't get 200, will retry | None needed—provider handles it |
| Crash after enqueue, before 200 | Provider retries, duplicate in queue | Consumer deduplication handles it |
| Signature verification fails | Return 401 | Provider will alert on repeated failures |
| Queue unavailable | Can't enqueue, would lose event | Circuit breaker + return 503 (triggers provider retry) |
Circuit Breakers for Dependencies:
If the message queue becomes slow or unavailable, the handler shouldn't hang indefinitely. Use a circuit breaker:
@circuit_breaker(failure_threshold=5, recovery_timeout=30)
def enqueue_event(event):
queue.publish(event, timeout=500) # 500ms timeout
When the circuit is "open" (queue consistently failing), immediately return 503 Service Unavailable. The provider will retry later when the queue recovers.
Message Queue Failures:
| Failure | Mitigation |
|---|---|
| Queue node crash | Use durable queues (persist to disk) + replication |
| Message lost | Kafka/SQS guarantee at-least-once delivery with proper ACK handling |
| Queue full | Backpressure: return 503 to provider, triggering retry |
Consumer Failures:
| Failure | Mitigation |
|---|---|
| Crash mid-processing | Don't ACK until complete; message redelivered to another consumer |
| Repeated failures | Exponential backoff, then move to Dead Letter Queue |
| Poison message | DLQ for manual inspection; don't block other messages |
Database Failures:
| Failure | Mitigation |
|---|---|
| Write fails | Consumer retries (message not ACKed) |
| Timeout | Exponential backoff |
| Primary down | Automatic failover to replica |
The key principle: only ACK a message after successful database write. This ensures messages aren't lost even if consumers crash. The trade-off is potential reprocessing, which idempotency handles.
5. Dead Letter Queue (DLQ) Strategy
After multiple failed attempts, move messages to a DLQ rather than retrying forever. The consumer code handles this with exponential backoff:
RETRY_DELAYS = [1, 5, 30, 120, 600] # seconds
# After processing fails:
if db_event.attempts >= MAX_RETRIES:
message.send_to_dlq() # Stop retrying, needs human intervention
message.ack() # Remove from main queue
else:
delay = RETRY_DELAYS[min(db_event.attempts - 1, len(RETRY_DELAYS) - 1)]
message.nack(requeue_delay=delay) # Retry after delay
Why exponential backoff? If a downstream service is failing, hammering it with retries makes things worse. Increasing delays (1s → 5s → 30s → 2m → 10m) give the system time to recover.
DLQ Handling:
- Alert on-call when messages arrive in DLQ
- Provide tooling to inspect and replay DLQ messages
- Track DLQ depth as a health metric
- Investigate root cause before replaying (don't just blindly retry)
6. Monitoring & Observability
| Metric | Alert Threshold | Why It Matters |
|---|---|---|
| Webhook latency (p99) | > 200ms | Provider may timeout and retry |
| Error rate | > 1% | Indicates processing issues |
| Queue depth | > 10,000 | Consumers falling behind |
| DLQ depth | > 0 | Requires manual intervention |
| Signature failure rate | > 0.1% | Possible attack or misconfiguration |
| Duplicate rate | Baseline + 2σ | Unusual retries from provider |
Common Pitfalls
Processing before persisting — If you execute business logic before saving to queue/database and then crash, the event is lost. Always persist first, then return 200.
ACKing before completion — If you ACK a message before processing completes and then crash, the message is lost from the queue. ACK only after successful database write.
Skipping signature verification — Without HMAC verification, anyone can send fake events to your endpoint. A fake "payment.succeeded" could cause you to ship products without actual payment.
Assuming event order — Events may arrive out of order. Don't assume invoice.created arrives before invoice.paid. Fetch current state from the source or use timestamps.
Blocking on downstream services — If your webhook handler calls slow downstream services synchronously, you'll timeout. Keep the handler fast; do heavy processing in consumers.
No dead letter queue — Without a DLQ, poison messages block the queue forever or get silently dropped. Always have a DLQ strategy for manual intervention.
Interview Checklist
| Topic | Key Points to Cover |
|---|---|
| Architecture | Request handler → Queue → Consumer pattern for reliability |
| Security | HMAC signature verification, IP allowlisting, rate limiting |
| Idempotency | Two-layer dedup (Redis + DB unique constraint) |
| Failure handling | ACK only after DB write; DLQ for repeated failures |
| Out-of-order | Fetch from source API or use timestamps |
| Monitoring | Latency, error rate, queue depth, DLQ depth |
Summary
| Aspect | Solution |
|---|---|
| Core pattern | Request Handler → Message Queue → Consumer |
| Fast acknowledgment | Return 200 after enqueue, process asynchronously |
| Reliability | Durable queue + ACK after DB write |
| Security | HMAC signature verification per provider |
| Idempotency | Redis fast-check + DB unique constraint |
| Out-of-order handling | Fetch latest state from source API |
| Failure recovery | Exponential backoff + Dead Letter Queue |
| Scaling | Add consumer instances; queue buffers spikes |
The webhook receiver is a great interview question because it appears simple but reveals depth in reliability engineering. The key insight is that webhooks are inherently unreliable (network failures, timeouts, retries), so your system must be defensive: persist before acknowledging, deduplicate aggressively, and never assume events arrive in order.