~learn.
?

Day 0440 minPreface+60 XP

Webhook Delivery System.

4 stages31 code

watch it first · newThe whole lesson in a 18-minute animated lecture5 quiz checkpoints · ask the AI about any moment · captions, chapters and speed

Preface · 2 min

Preface: From Synchronous to Asynchronous#

For the past three days, we've been working in a synchronous world:

The synchronous world
  1. User clicks "Pay"
  2. Your server
  3. Bank API
  4. Response
  5. User sees result

Everything happens in a single request-response cycle. The user waits while you call downstream services. We added:

  • Day 1: Timeouts (don't wait forever)
  • Day 2: Idempotency (safe to retry)
  • Day 3: Circuit breakers (fail fast when broken)

But what happens when you need to notify EXTERNAL systems about events?

Order completed in your system
  • → Need to notify: Warehouse, Accounting, Analytics, Partner systems
  • → These systems are outside your control
  • → They might be slow, down, or misconfigured
  • → You can't make the user wait for all of them

This is where webhooks come in.

What Are Webhooks?#

Webhooks are HTTP callbacks. When an event occurs in your system, you make an HTTP request to a URL that someone else has registered:

Pull vs push

Traditional API (Pull)

  • Partner: "Do you have any new orders?"
  • You: "Yes, here's order #123"
  • Partner: "Do you have any new orders?"
  • You: "No"
  • Partner: "Do you have any new orders?"
  • You: "Yes, here's order #124"

Problem: Wasteful polling, delayed updates

Webhooks (Push)

  • Partner: "Call me at https://partner.com/webhook when you have new orders"
  • You: [Order #123 created]
  • You: POST https://partner.com/webhook {"event": "order.created", "order": {...}}
  • Partner: "Got it, thanks!"

Benefit: Real-time, efficient

Why Webhooks Are Hard#

Webhooks seem simple, but they're deceptively difficult:

  1. Receivers are unreliable: They go down, timeout, return errors
  2. Networks are unreliable: Requests get lost, connections drop
  3. Scale is challenging: Sending millions of webhooks quickly
  4. Ordering is tricky: Events might arrive out of order
  5. Security is critical: Anyone could send fake webhooks
  6. Observability is hard: Did the webhook arrive? Did they process it?

Today, we'll design a production webhook delivery system that handles all of these challenges.

ExplainerThe whole lesson: webhook delivery

Webhooks push events to systems you don't control, so deliver at-least-once (a 200 arriving after your 30-second timeout makes duplicates unavoidable) and require idempotent receivers that dedupe on event_id. Events flow from Kafka through a dispatcher that fans out to subscription-partitioned queues and workers, retrying over 9 attempts (about 39 hours) before a dead letter queue, with per-entity ordering, HMAC-SHA256 signatures, a 5-minute replay window and secret rotation. At 1M events an hour (2,500 requests a second at peak, 250 async workers), health checks recover a 2-hour outage's 10,000 events by 4:13 PM instead of about 6 PM, with gradual ramp-up, a DLQ dashboard and alerts.

Part I · Stage 1 of 4 · 17 min

Part I: Foundations#

Chapter 1: Delivery Guarantees#

Your turn · checkpoint 1not answered

Trade-off: send once, or retry until acknowledged?

An order-confirmation webhook goes to a partner's endpoint. You can send it once and accept the loss if it fails, or retry until the partner acknowledges it. What does each choice cost you when the network blips or the partner is down for a while?

Think it through, or jot it down. Then compare.

One strong answer

  • Send once (at-most-once): a blip or an outage loses the event for good. The customer gets no email, thinks the order failed, calls support and may order again.
  • Retry until acknowledged (at-least-once): the event gets there, but sometimes twice, because a lost acknowledgment looks like a failure.
  • The call: a duplicate costs the receiver one lookup on the event ID; a lost order costs real money. For business events, retry and make receivers idempotent.

1.1 The Three Guarantees#

In distributed systems, there are three delivery guarantees:

Delivery Guarantees

AT-MOST-ONCE

  • Send once, don't retry
  • May lose messages

Easiest

AT-LEAST-ONCE

  • Retry until acknowledged
  • May duplicate messages

Most Common

EXACTLY-ONCE

  • Delivered exactly once
  • No loss, no duplicates

Impossible*

* True exactly-once is impossible in distributed systems. We approximate it with at-least-once + idempotent receivers.

Let's understand each guarantee in depth:

At-Most-Once#

The simplest approach: try once and forget.

pythonsend_webhook_at_most_once · 6 lines
def send_webhook_at_most_once(url, payload):
    try:
        response = requests.post(url, json=payload, timeout=5)
        return 200 <= response.status_code < 300
    except:
        return False  # Don't retry, accept the loss

When to use at-most-once:

  • Analytics and tracking events (losing a few is acceptable)
  • Logging and debugging notifications
  • Non-critical status updates

When NOT to use:

  • Payment notifications
  • Order confirmations
  • Any event where loss causes business impact
At-Least-Once#

The standard for webhooks: retry until acknowledged.

pythonsend_webhook_at_least_once · 13 lines
def send_webhook_at_least_once(url, payload, max_retries=5):
    for attempt in range(max_retries):
        try:
            response = requests.post(url, json=payload, timeout=5)
            if 200 <= response.status_code < 300:
                return True  # Success (any 2xx)
        except:
            pass
        
        # Exponential backoff before retry
        time.sleep(2 ** attempt)
    
    return False  # Move to dead letter queue

The duplicate problem:

The duplicate problem
  • T=0 —
    You send webhook
  • T=29s —
    Receiver processes it, sends "200 OK"
  • T=30s —
    Your timeout fires (before 200 arrives)
  • T=30s —
    You think it failed, schedule retry
  • T=31s —
    The 200 arrives, but your client already gave up on the connection
  • T=60s —
    You retry - duplicate!

This is unavoidable. The receiver MUST handle duplicates.

Exactly-Once (The Myth)#

True exactly-once is impossible in distributed systems. Here's the fundamental problem:

The Two Generals Problem

General A wants to coordinate attack with General B.

  • A sends messenger: "Attack at dawn"
  • B receives message
  • B sends messenger back: "Confirmed"
  • A receives confirmation

But what if B's messenger is captured?

  • B thinks A knows
  • A doesn't know B confirmed
  • A sends another messenger
  • Now B has two messages

No amount of acknowledgments solves this. After N confirmations, you still don't know if the Nth was received.

The practical solution: At-least-once delivery + idempotent receivers.

This is exactly what we learned in Day 2 — idempotency keys make operations safe to repeat. The webhook system delivers at-least-once, and the receiver uses the event ID for idempotency.

1.2 Why At-Least-Once Is the Standard#

For business-critical webhooks, at-least-once is almost always correct:

Failure Mode At-Most-Once Impact At-Least-Once Impact
Network blip Lost forever Retried, delivered
Receiver slow Lost forever Retried, delivered
Receiver crash Lost forever Retried when back
Your crash Lost forever Recovered from queue

The cost of handling a duplicate is almost always lower than the cost of losing an event.

Example: Order confirmation webhook

At-most-once: Customer doesn't get email, thinks order failed, contacts support, places duplicate order. Cost: Support time + customer frustration + inventory issues.

At-least-once: Receiver sees same order twice, uses order_id for idempotency, sends one email. Cost: One database lookup.

1.3 The Receiver's Contract#

When providing at-least-once delivery, document the receiver's responsibilities:

markdown13 lines
# Webhook Receiver Requirements

1. **Idempotency**: Your endpoint must handle duplicate deliveries.
   Use the `event_id` field to deduplicate.

2. **Response**: Return a 2xx status code to acknowledge receipt.
   Any other status or timeout will trigger retry.

3. **Speed**: Respond within 30 seconds.
   Do heavy processing asynchronously.

4. **Verification**: Verify webhook signatures.
   Check the X-Webhook-Signature header.

Example receiver implementation:

pythonhandle_webhook · 32 lines
@app.route('/webhook', methods=['POST'])
def handle_webhook():
    # Verify signature first
    if not verify_signature(request):
        abort(401)
    
    event = request.json
    event_id = event['id']
    
    # Idempotency check
    if db.exists(f"processed:{event_id}"):
        return jsonify({"status": "already_processed"}), 200
    
    # Mark as processing (prevent concurrent duplicates)
    if not db.set(f"processing:{event_id}", "1", nx=True, ex=300):
        # Non-2xx so the sender retries later: if the in-flight attempt
        # fails, a 200 here would have acknowledged an event nobody processed
        return jsonify({"status": "processing"}), 409
    
    try:
        # Process the event
        process_event(event)
        
        # Mark as complete
        db.set(f"processed:{event_id}", "1", ex=86400*7)
        db.delete(f"processing:{event_id}")
        
        return jsonify({"status": "processed"}), 200
    
    except Exception as e:
        db.delete(f"processing:{event_id}")
        raise

Chapter 2: System Architecture#

2.1 High-Level Components#

A production webhook system needs these components:

DiagramWebhook system architecture
%% title: Webhook system architecture
flowchart TD
  ES@{ icon: "sd:server", label: "Event<br/>Sources" } --> EB@{ icon: "tech:kafka", label: "Event Bus<br/>(Kafka)" }
  EB --> D["Webhook Dispatcher<br/>(Fan-out)"]:::core
  D --> Q1@{ icon: "sd:queue", label: "Delivery<br/>Queue P1" }
  D --> Q2@{ icon: "sd:queue", label: "Delivery<br/>Queue P2" }
  D --> QN["..."]:::note
  Q1 --> W1@{ icon: "sd:worker", label: "Workers<br/>(Pool)" }
  Q2 --> W2@{ icon: "sd:worker", label: "Workers<br/>(Pool)" }
  W1 --> EX@{ icon: "sd:webhook", label: "External Receivers" }
  W2 --> EX
  RS@{ icon: "sd:timer", label: "Retry<br/>Scheduler" }
  DLQ@{ icon: "sd:queue", label: "Dead Letter<br/>Queue" }
  DL@{ icon: "sd:log", label: "Delivery<br/>Logs" }
  class ES,W1,W2,RS service
  class EB,Q1,Q2,DLQ queue
  class EX external
  class DL store

Let's understand each component:

Event Sources#

These are the services in your system that generate events:

pythonOrderService · 11 lines
class OrderService:
    def create_order(self, order_data):
        order = self.db.create(order_data)
        
        # Emit event for webhook system
        self.event_bus.publish(
            event_type="order.created",
            data=order.to_dict()
        )
        
        return order

The key insight: event production should be decoupled from webhook delivery. The OrderService doesn't know about webhooks — it just publishes events.

Event Bus (Kafka/SQS/RabbitMQ)#

The event bus provides:

  • Durability: Events survive crashes
  • Decoupling: Producers and consumers are independent
  • Replay: Can reprocess events if needed (Kafka and RabbitMQ Streams keep an append-only log; classic RabbitMQ queues and SQS delete on consume)
  • Ordering: Within a Kafka partition, events stay ordered (SQS standard queues only make a best-effort attempt at order)

Why Kafka is popular for this:

  • High throughput (millions/second)
  • Strong durability guarantees
  • Consumer groups for parallel processing
  • Retention for replay
Webhook Dispatcher (Fan-out)#

This is the brain of the system. It:

  1. Consumes events from the bus
  2. Looks up subscriptions for each event type
  3. Creates delivery tasks for each subscription
pythonWebhookDispatcher · 14 lines
class WebhookDispatcher:
    async def handle_event(self, event):
        # Find all subscriptions for this event type
        subscriptions = await self.db.find_subscriptions(
            event_type=event['type']
        )
        
        # Create delivery task for each
        for sub in subscriptions:
            if sub.active:
                await self.queue.enqueue({
                    'subscription_id': sub.id,
                    'event': event
                })

This is the "fan-out" — one event becomes N delivery tasks.

Delivery Queues (Partitioned)#

Partitioning is key for:

  • Parallelism: Different partitions processed simultaneously
  • Ordering: Events for same subscription stay ordered
  • Isolation: Slow subscriber doesn't block others
pythonget_partition · 3 lines
def get_partition(subscription_id: str) -> int:
    """Stable hash partitioning (md5 is the same in every process; Python's built-in hash() of a str is randomised per process)."""
    return int(hashlib.md5(subscription_id.encode()).hexdigest(), 16) % NUM_PARTITIONS
Delivery Workers#

Workers pull tasks and make HTTP requests:

pythonDeliveryWorker · 19 lines
class DeliveryWorker:
    async def process_task(self, task):
        subscription = await self.db.get_subscription(task['subscription_id'])
        
        try:
            response = await self.http.post(
                subscription.url,
                json=self.build_payload(task['event']),
                headers=self.sign_headers(subscription, task['event']),
                timeout=30
            )
            
            if 200 <= response.status < 300:
                await self.log_success(task)
            else:
                await self.schedule_retry(task)
        
        except Exception as e:
            await self.schedule_retry(task)
Dead Letter Queue (DLQ)#

When all retries are exhausted:

pythonmove_to_dlq · 15 lines
from datetime import datetime, timezone

async def move_to_dlq(self, task, error):
    await self.dlq.push({
        'task': task,
        'error': error,
        'failed_at': datetime.now(timezone.utc),
        'attempts': task['attempt_count']
    })
    
    # Alert operations
    await self.alert_service.notify(
        f"Webhook failed after {task['attempt_count']} attempts",
        subscription_id=task['subscription_id']
    )

2.2 Data Flow Example#

Let's trace an order through the system:

Tracing an order through the system
  1. Order Created
    • OrderService.create_order()
      • event_bus.publish("order.created", {...})
  2. Event Published to Kafka
    • Topic: events
    • Partition: hash("order.created") % 10 = 3
    • Message: {"id": "evt_123", "type": "order.created", "data": {...}}
  3. Dispatcher Consumes Event
    • find_subscriptions("order.created")
      • Returns: [sub_A, sub_B, sub_C]
  4. Delivery Tasks Created
    • Queue partition 7: {"subscription_id": "sub_A", "event": {...}}
    • Queue partition 2: {"subscription_id": "sub_B", "event": {...}}
    • Queue partition 5: {"subscription_id": "sub_C", "event": {...}}
  5. Workers Process Tasks (in parallel)
    • Worker-1: POST https://a.com/webhook {...}
    • Worker-2: POST https://b.com/webhook {...}
    • Worker-3: POST https://c.com/webhook {...}
  6. Results
    • sub_A: 200 OK → logged as success
    • sub_B: timeout → scheduled retry at T+60s
    • sub_C: 500 Error → scheduled retry at T+60s

2.3 Payload Design#

A well-designed webhook payload:

json19 lines
{
    "id": "evt_1234567890abcdef",
    "type": "order.created",
    "api_version": "2024-01-15",
    "created_at": "2024-01-15T10:30:00.123Z",
    "data": {
        "object": "order",
        "id": "ord_abc123",
        "amount": 9900,
        "currency": "usd",
        "status": "pending",
        "customer": {
            "id": "cus_xyz",
            "email": "customer@example.com"
        },
        "items": [...]
    },
    "request_id": "req_original_request_id"
}

Key design decisions:

Field Purpose Why It Matters
id Unique event ID Idempotency key for receivers
type Event type Routing and filtering
api_version Payload version Backward compatibility
created_at Event timestamp Not delivery time!
data.object Object type Helps parsing
request_id Original request Correlation/debugging

Chapter 3: Retry Strategies#

3.1 The Retry Schedule#

A typical exponential backoff schedule:

A typical exponential backoff schedule
  • Attempt 1 —
    Immediate
  • Attempt 2 —
    1 minute later
  • Attempt 3 —
    5 minutes later
  • Attempt 4 —
    15 minutes later
  • Attempt 5 —
    1 hour later
  • Attempt 6 —
    2 hours later
  • Attempt 7 —
    4 hours later
  • Attempt 8 —
    8 hours later
  • Attempt 9 —
    24 hours later (final)
  • Total window —
    ~39 hours (1m + 5m + 15m + 1h + 2h + 4h + 8h + 24h)

Why this schedule works:

  • Early retries catch transient failures (network blips, deploys)
  • Longer delays give time for manual intervention
  • 24-hour final catches overnight outages
Your turn · checkpoint 2not answered

Your call: which failures deserve a retry?

Your worker gets these back from receivers: a timeout, a connection error, a 503, a 400 Bad Request, and a 429 Too Many Requests. Which do you retry, which do you not, and what's special about one of them?

Think it through, or jot it down. Then compare.

One strong answer

  • Retry: the timeout, the connection error and the 503. Each is transient or on the receiver's side, and may work next time.
  • Don't retry: the 400. Your request is malformed for that receiver, so the identical request will fail again; log it or send it to the DLQ.
  • The special case: the 429 is a 4xx you should retry, because rate limiting is temporary. Wait for the Retry-After header if there is one, or about a minute if not.

3.2 What To Retry#

Not all failures should be retried:

pythonshould_retry · 27 lines
def should_retry(response_or_error) -> bool:
    if isinstance(response_or_error, Exception):
        # Network errors - always retry
        if isinstance(response_or_error, (Timeout, ConnectionError)):
            return True
        # Unknown errors - retry cautiously
        return True
    
    status = response_or_error.status_code
    
    # Success (any 2xx) - don't retry
    if 200 <= status < 300:
        return False
    
    # Client errors - don't retry (except rate limit)
    if 400 <= status < 500:
        return status == 429  # Retry rate limits
    
    # Server errors - retry
    if status >= 500:
        return True
    
    # Redirects - don't retry
    if 300 <= status < 400:
        return False
    
    return False

The 4xx rule: If the receiver returns a 4xx error, your request is malformed. Retrying won't help. Log it and move on (or to DLQ for investigation).

Exception: 429 Too Many Requests

Rate limiting is special — it's a temporary condition:

pythonhandle_rate_limit · 12 lines
def handle_rate_limit(response, task):
    retry_after = response.headers.get('Retry-After')
    
    if retry_after:
        try:
            delay = int(retry_after)  # delay-seconds form
        except ValueError:
            delay = 60  # HTTP-date form (e.g. "Wed, 21 Oct 2015 07:28:00 GMT"): parse it in production
    else:
        delay = 60  # Default backoff
    
    schedule_retry(task, delay=delay)

3.3 Retry Implementation#

pythonRetryConfig · 71 lines
from dataclasses import dataclass
from datetime import datetime, timedelta, timezone
from typing import Optional
import random

@dataclass
class RetryConfig:
    max_retries: int = 8  # 9 deliveries in all: the first try plus 8 retries
    # Explicit schedule in seconds: 1m, 5m, 15m, 1h, 2h, 4h, 8h, 24h (~39 h in total)
    delays: tuple = (60, 300, 900, 3600, 7200, 14400, 28800, 86400)
    jitter: float = 0.1  # ±10%

class RetryScheduler:
    def __init__(self, config: RetryConfig, queue):
        self.config = config
        self.queue = queue
    
    def calculate_delay(self, attempt: int) -> int:
        """Look up the delay for this retry in the schedule and add jitter."""
        
        # Delays grow roughly 4-5x at first, then double, and end at 24 hours
        delay = self.config.delays[min(attempt, len(self.config.delays) - 1)]
        
        # Add jitter to prevent thundering herd
        jitter_range = delay * self.config.jitter
        delay += random.uniform(-jitter_range, jitter_range)
        
        return int(delay)
    
    async def schedule_retry(
        self,
        task: dict,
        error: str,
        force_delay: Optional[int] = None
    ) -> bool:
        """
        Schedule a retry for failed delivery.
        Returns True if retry scheduled, False if moved to DLQ.
        """
        
        attempt = task.get('attempt', 0)
        
        if attempt >= self.config.max_retries:
            await self.move_to_dlq(task, error)
            return False
        
        delay = force_delay or self.calculate_delay(attempt)
        
        task['attempt'] = attempt + 1
        task['last_error'] = error
        task['retry_at'] = (datetime.now(timezone.utc) + timedelta(seconds=delay)).isoformat()
        
        await self.queue.enqueue_delayed(task, delay)
        
        return True
    
    async def move_to_dlq(self, task: dict, error: str):
        """Move to dead letter queue after exhausting retries."""
        
        dlq_entry = {
            'task': task,
            'final_error': error,
            'attempts': task.get('attempt', 0),
            'first_attempt': task.get('first_attempt'),
            'last_attempt': datetime.now(timezone.utc).isoformat(),
            'status': 'pending_review'
        }
        
        await self.dlq.push(dlq_entry)
        await self.metrics.increment('webhooks_dlq_total')
        await self.alert_ops(task, error)

3.4 Handling Long Outages#

When an endpoint is down for hours:

When an endpoint is down for hours
Scenario
  • Endpoint goes down at 2:00 PM
  • 100 events/hour accumulate
  • Endpoint recovers at 4:00 PM
  • 200 events need delivery
Problem
  • Each event is at different retry stage
  • Some scheduled for 5:00 PM, some for 6:00 PM, etc.
  • Recovery is slow even though endpoint is healthy

Solution: Health-based retry acceleration

pythonAdaptiveRetryScheduler · 40 lines
class AdaptiveRetryScheduler:
    def __init__(self):
        self.endpoint_health = {}  # endpoint -> health status
    
    async def check_endpoint_health(self, endpoint: str) -> bool:
        """Periodic health check for failing endpoints."""
        
        try:
            async with aiohttp.ClientSession() as session:
                async with session.head(endpoint, timeout=5, allow_redirects=False) as resp:
                    return resp.status < 500
        except:
            return False
    
    async def on_endpoint_recovery(self, endpoint: str):
        """When endpoint recovers, accelerate pending retries."""
        
        # Find all pending retries for this endpoint
        pending = await self.queue.get_pending_for_endpoint(endpoint)
        
        # Move them to immediate queue
        for task in pending:
            await self.queue.move_to_immediate(task)
        
        logger.info(f"Accelerated {len(pending)} retries for {endpoint}")
    
    async def run_health_checker(self):
        """Background task to check unhealthy endpoints."""
        
        while True:
            for endpoint, status in self.endpoint_health.items():
                if not status.healthy:
                    is_healthy = await self.check_endpoint_health(endpoint)
                    
                    if is_healthy:
                        logger.info(f"Endpoint recovered: {endpoint}")
                        await self.on_endpoint_recovery(endpoint)
                        status.healthy = True
            
            await asyncio.sleep(30)  # Check every 30 seconds

Chapter 4: Security#

Webhook security is critical. Without proper security:

  • Attackers can send fake webhooks to your customers
  • Man-in-the-middle attacks can intercept sensitive data
  • Replay attacks can cause duplicate processing

4.1 Signature Verification#

The standard approach: HMAC-SHA256 signatures.

Sender side:

pythonWebhookSigner · 27 lines
import hmac
import hashlib
import time
import json

class WebhookSigner:
    def sign_payload(self, payload: dict, secret: str) -> dict:
        """Generate signature headers for webhook."""
        
        timestamp = int(time.time())
        
        # Create signed content: timestamp.payload
        payload_str = json.dumps(payload, separators=(',', ':'), sort_keys=True)
        signed_content = f"{timestamp}.{payload_str}"
        
        # Generate HMAC-SHA256 signature
        signature = hmac.new(
            secret.encode('utf-8'),
            signed_content.encode('utf-8'),
            hashlib.sha256
        ).hexdigest()
        
        return {
            'X-Webhook-Timestamp': str(timestamp),
            'X-Webhook-Signature': f"sha256={signature}",
            'Content-Type': 'application/json'
        }

Send exactly the bytes you signed (data=payload_str). If you let the HTTP client re-serialize the dict (json=payload), its spacing and key order can differ, and the receiver's HMAC over the raw body won't match.

Receiver side:

pythonWebhookVerifier · 35 lines
class WebhookVerifier:
    def verify(
        self,
        payload_body: str,
        signature_header: str,
        timestamp_header: str,
        secret: str,
        tolerance: int = 300  # 5 minutes
    ) -> bool:
        """Verify webhook signature."""
        
        # Check timestamp is recent (prevent replay attacks)
        try:
            timestamp = int(timestamp_header)
        except (ValueError, TypeError):
            return False
        
        if abs(time.time() - timestamp) > tolerance:
            return False  # Too old or too far in future
        
        # Verify signature
        if not signature_header.startswith('sha256='):
            return False
        
        received_sig = signature_header[7:]
        
        signed_content = f"{timestamp}.{payload_body}"
        expected_sig = hmac.new(
            secret.encode('utf-8'),
            signed_content.encode('utf-8'),
            hashlib.sha256
        ).hexdigest()
        
        # Constant-time comparison (prevent timing attacks)
        return hmac.compare_digest(received_sig, expected_sig)
Your turn · checkpoint 3not answered

What breaks if you sign only the body?

Suppose you signed only the payload with HMAC-SHA256, with no timestamp. An attacker captures a genuine order.refunded webhook and sends it again six months later. Does the signature still check out? What does the receiver do, and what one change stops it?

Think it through, or jot it down. Then compare.

One strong answer

  • The signature still checks out: it's the same bytes signed with the same secret, so an HMAC alone can't tell an old message from a new one.
  • The receiver processes the refund a second time.
  • The fix: sign the timestamp together with the body, and have the receiver reject anything more than about 5 minutes away from its own clock. A replay is then either too old or, if the attacker changes the timestamp, no longer matches the signature.

4.2 Why Timestamp Matters#

Without timestamp verification, attackers can replay old webhooks:

Replay attack

Attack scenario

  1. Attacker intercepts webhook for "order.refunded"
  2. Attacker waits 6 months
  3. Attacker replays same webhook
  4. Receiver processes refund again!

With timestamp verification

  1. Webhook has timestamp from 6 months ago
  2. Receiver checks: |now - timestamp| > 5 minutes
  3. Receiver rejects: "Timestamp too old"

4.3 Secret Rotation#

Secrets should be rotated periodically:

pythonSecretManager · 43 lines
from datetime import datetime, timezone

class SecretManager:
    def rotate_secret(self, subscription_id: str) -> dict:
        """Rotate webhook secret with grace period."""
        
        subscription = self.db.get(subscription_id)
        new_secret = secrets.token_hex(32)
        
        # Keep old secret valid for 24 hours
        self.db.update(subscription_id, {
            'secret': new_secret,
            'previous_secret': subscription.secret,
            'previous_secret_expires': datetime.now(timezone.utc) + timedelta(hours=24)
        })
        
        return {
            'new_secret': new_secret,
            'old_secret_valid_until': (datetime.now(timezone.utc) + timedelta(hours=24)).isoformat()
        }
    
    def verify_with_rotation(
        self,
        subscription_id: str,
        payload: str,
        signature: str,
        timestamp: str
    ) -> bool:
        """Verify allowing both current and previous secrets."""
        
        subscription = self.db.get(subscription_id)
        
        # Try current secret
        if self.verifier.verify(payload, signature, timestamp, subscription.secret):
            return True
        
        # Try previous secret if still valid
        if (subscription.previous_secret and 
            subscription.previous_secret_expires > datetime.now(timezone.utc)):
            if self.verifier.verify(payload, signature, timestamp, subscription.previous_secret):
                return True
        
        return False

4.4 Additional Security Measures#

pythonWebhookSecurityManager · 48 lines
from datetime import datetime, timezone

class WebhookSecurityManager:
    def validate_endpoint(self, url: str) -> bool:
        """Validate webhook URL before registration."""
        
        parsed = urllib.parse.urlparse(url)
        
        # Require HTTPS
        if parsed.scheme != 'https':
            raise ValueError("Webhook URL must use HTTPS")
        
        # Block private IPs (SSRF prevention)
        try:
            ip = socket.gethostbyname(parsed.hostname)
            # is_global rejects private, loopback, link-local and other reserved ranges
            if not ipaddress.ip_address(ip).is_global:
                raise ValueError("Webhook URL cannot point to a private or reserved IP")
        except socket.gaierror:
            raise ValueError("Cannot resolve webhook hostname")
        
        # Block localhost
        if parsed.hostname in ['localhost', '127.0.0.1', '0.0.0.0']:
            raise ValueError("Webhook URL cannot be localhost")
        
        return True
    
    def test_endpoint(self, url: str, secret: str) -> bool:
        """Send test webhook to verify endpoint works."""
        
        test_event = {
            'id': f'evt_test_{uuid.uuid4().hex[:8]}',
            'type': 'webhook.test',
            'created_at': datetime.now(timezone.utc).isoformat()
        }
        
        try:
            response = requests.post(
                url,
                # Send the exact bytes that were signed
                data=json.dumps(test_event, separators=(',', ':'), sort_keys=True),
                headers=self.signer.sign_payload(test_event, secret),
                timeout=10,
                allow_redirects=False  # a redirect could point at an internal address
            )
            return 200 <= response.status_code < 300
        except:
            return False

Chapter 5: Scaling to 1M Webhooks/Hour#

5.1 Understanding the Numbers#

WorksheetUnderstanding the numbers
  • Base load: 1,000,000 events/hour = 16,667 events/minute = 278 events/second
  • With average 3 subscriptions per event: 833 delivery attempts/second
  • With 50% retry rate: ~1,250 total HTTP requests/second
  • Peak (2x average): 2,500 requests/second
Your turn · checkpoint 4not answered

Estimate it: how many workers?

At peak you need about 2,500 HTTP requests a second to receivers, and an average request takes 500ms. How many workers do you need if each handles one request at a time? How many if each async worker keeps 10 requests in flight? What would you actually deploy?

Think it through, or jot it down. Then compare.

One strong answer

  • One at a time: each worker finishes 1 ÷ 0.5 s = 2 requests a second, so 2,500 ÷ 2 = 1,250 workers.
  • Async, 10 in flight: each finishes 20 a second, so 2,500 ÷ 20 = 125 workers.
  • Deploy about 250: twice that, as a safety margin for slow receivers and bursts.

5.2 Worker Pool Sizing#

python12 lines
# Assumptions:
# - Average HTTP request takes 500ms
# - We want to handle 2,500 requests/second at peak
# - Each worker can handle 1/0.5 = 2 requests/second

# Required workers = 2,500 / 2 = 1,250 workers

# With async workers (10 concurrent requests each):
# Required workers = 2,500 / 20 = 125 workers

# With safety margin (2x):
# Deploy 250 async workers

5.3 Connection Pooling#

pythonWebhookHTTPClient · 20 lines
class WebhookHTTPClient:
    def __init__(self):
        # Connection pool settings
        self.connector = aiohttp.TCPConnector(
            limit=1000,           # Total connections
            limit_per_host=10,    # Per-endpoint limit (fairness)
            ttl_dns_cache=300,    # DNS cache TTL
            keepalive_timeout=30  # Keep connections alive
        )
        
        self.timeout = aiohttp.ClientTimeout(
            total=30,      # Total request timeout
            connect=5,     # Connection timeout
            sock_read=25   # Read timeout
        )
        
        self.session = aiohttp.ClientSession(
            connector=self.connector,
            timeout=self.timeout
        )

5.4 Rate Limiting Per Endpoint#

Don't overwhelm receivers:

pythonEndpointRateLimiter · 32 lines
class EndpointRateLimiter:
    def __init__(self, redis_client):
        self.redis = redis_client
        self.default_limit = 800  # requests per minute (~13/second)
    
    async def acquire(self, endpoint: str, limit: int = None) -> bool:
        """Try to acquire a rate limit slot."""
        
        limit = limit or self.default_limit
        key = f"ratelimit:{endpoint}"
        
        current = await self.redis.incr(key)
        
        if current == 1:
            await self.redis.expire(key, 60)
        
        if current > limit:
            return False
        
        return True
    
    async def wait_for_slot(self, endpoint: str, timeout: float = 30) -> bool:
        """Wait until rate limit slot available."""
        
        start = time.time()
        
        while time.time() - start < timeout:
            if await self.acquire(endpoint):
                return True
            await asyncio.sleep(0.1)
        
        return False

5.5 Queue Partitioning#

pythonPartitionedQueue · 34 lines
class PartitionedQueue:
    """
    Partition by subscription for:
    - Parallel processing of different subscriptions
    - Ordered delivery per subscription
    - Isolation (slow subscriber doesn't block others)
    """
    
    NUM_PARTITIONS = 64
    
    def get_partition(self, subscription_id: str) -> int:
        """Stable hash (modulo) partitioning."""
        hash_val = int(hashlib.md5(subscription_id.encode()).hexdigest(), 16)
        return hash_val % self.NUM_PARTITIONS
    
    async def enqueue(self, task: dict):
        """Add task to appropriate partition."""
        partition = self.get_partition(task['subscription_id'])
        stream = f"webhooks:p{partition}"
        await self.redis.xadd(stream, task)
    
    async def start_workers(self):
        """Start workers for all partitions."""
        tasks = []
        
        for partition in range(self.NUM_PARTITIONS):
            # Multiple workers per partition for throughput
            for worker_num in range(4):
                task = asyncio.create_task(
                    self.partition_worker(partition, worker_num)
                )
                tasks.append(task)
        
        await asyncio.gather(*tasks)
Quick check4 questions · +5 XP each

Webhook delivery foundations

  1. Q1/4 The receiver processes a webhook and returns 200 at 29 seconds, but your 30-second timeout fires first and you schedule a retry. Who has to deal with the duplicate?

    Answer & explanation

    Answer: C

    With at-least-once delivery, duplicates like this are unavoidable. The contract tells receivers to use event_id as an idempotency key, as on Day 2. At-most-once removes duplicates by accepting lost events, which is wrong for business-critical webhooks.

  2. Q2/4 Which receiver response should your delivery worker NOT retry?

    Answer & explanation

    Answer: C

    A 4xx means your request is malformed for that receiver, so retrying will not help; log it or send it to the DLQ. 429 is the exception: back off (honoring Retry-After) and retry. 5xx and timeouts are server-side or transient, so they are retried.

  3. Q3/4 An attacker captured a validly signed order.refunded webhook six months ago and replays it today. What stops it from being processed?

    Answer & explanation

    Answer: B

    The signature on a replayed message is still valid. Signing the timestamp along with the body, and enforcing a tolerance window, is what defeats replays.

  4. Q4/4 A customer registers https://10.0.0.5/admin as their webhook URL. Why does registration reject it?

    Answer & explanation

    Answer: A

    Webhook senders run inside your infrastructure, so URLs pointing at private IPs or localhost turn your system into a proxy for attacks. Validate before registering.

Part II · Stage 2 of 4 · 8 min

Part II: The Design Problem#

Chapter 6: Design Challenge - Receiver Down for 2 Hours#

6.1 The Scenario#

The scenario
Timeline
  • 2:00 PM — Partner's webhook endpoint goes down
  • 2:00 PM to 4:00 PM — You generate 10,000 events for them
  • 4:00 PM — Endpoint comes back up
Questions
  1. What happens to those 10,000 events?
  2. How long until they're all delivered?
  3. What's the customer experience?
  4. How do you optimize recovery?
Your turn · checkpoint 5not answered

What breaks after the partner comes back?

The partner's endpoint is down from 2:00 to 4:00 PM. Your schedule retries after 1 minute, 5 minutes, 15 minutes, 1 hour, then 2 hours. An event created at 2:00 PM keeps failing. When is its next try after the endpoint returns at 4:00, and roughly when does the last backed-up event arrive?

Think it through, or jot it down. Then compare.

One strong answer

  • The 2:00 PM event retries at 2:01, 2:06, 2:21 and 3:21, all while the endpoint is down. Its next step is 2 hours, so it waits until 5:21 PM.
  • Other events are scattered across the schedule, and stragglers keep arriving until about 6:00 PM, two hours after the endpoint was healthy again.
  • The cause: backoff is right while the endpoint is down, but nothing notices the recovery. Probing unhealthy endpoints and moving their pending retries forward fixes that.

6.2 Default Behavior (Without Optimization)#

Default behavior
  • 2:00 PM —
    Event 1 fails, scheduled retry at 2:01 PM
  • 2:01 PM —
    Retry fails, scheduled at 2:06 PM
  • 2:06 PM —
    Retry fails, scheduled at 2:21 PM
  • 2:21 PM —
    Retry fails, scheduled at 3:21 PM
  • 3:21 PM —
    Retry fails, scheduled at 5:21 PM
  • 4:00 PM —
    Endpoint recovers. But Event 1 won't retry until 5:21 PM!
  • Meanwhile —
    Event from 2:30 PM: next retry 5:51 PM (it hit the 2-hour step at 3:51) · Event from 3:30 PM: next retry 4:51 PM · Event from 3:55 PM: next retry 4:01 PM
  • Result —
    Stragglers keep trickling in until ~6:00 PM: about 2 hours after the endpoint came back, even though it was healthy the whole time!

6.3 Solution 1: Health Monitoring + Accelerated Retry#

pythonSmartRetryManager · 35 lines
class SmartRetryManager:
    async def run_health_monitor(self):
        """Monitor unhealthy endpoints and trigger recovery."""
        
        while True:
            # Get all endpoints with recent failures
            unhealthy = await self.db.get_unhealthy_endpoints()
            
            for endpoint in unhealthy:
                # Check if recovered
                if await self.check_health(endpoint.url):
                    logger.info(f"Endpoint recovered: {endpoint.url}")
                    
                    # Accelerate all pending retries
                    await self.accelerate_retries(endpoint.subscription_id)
                    
                    # Update status
                    endpoint.healthy = True
                    endpoint.consecutive_failures = 0
                    await self.db.update(endpoint)
            
            await asyncio.sleep(30)  # Check every 30 seconds
    
    async def accelerate_retries(self, subscription_id: str):
        """Move all pending retries to immediate queue."""
        
        # Find all scheduled retries for this subscription
        pending = await self.queue.get_scheduled_for_subscription(subscription_id)
        
        logger.info(f"Accelerating {len(pending)} retries for {subscription_id}")
        
        for task in pending:
            # Move from delayed queue to immediate queue
            await self.queue.cancel_delayed(task['id'])
            await self.queue.enqueue_immediate(task)

Result:

Optimized recovery
  • 4:00 PM —
    Health check detects recovery
  • 4:00 PM —
    10,000 pending retries moved to immediate queue
  • 4:01 PM —
    Workers start delivering, capped by the per-endpoint rate limit (800/minute, ~13/second)
  • 4:13 PM —
    All 10,000 events delivered!
  • Recovery time —
    13 minutes (vs ~2 hours)
ExplainerWebhook retries after a 2-hour outage

A partner endpoint is down from 2:00 to 4:00 PM. With plain exponential backoff each event waits on its own timer (Event 1's next retry is 5:21 PM), so deliveries trickle in until about 6:00 PM. A health check that moves all 10,000 pending retries to the immediate queue at 4:00 delivers them by 4:13 PM at the endpoint's ~13/s rate limit.

6.4 Solution 2: Gradual Ramp-Up#

Don't overwhelm a recovering endpoint:

pythonGradualRecoveryManager · 32 lines
class GradualRecoveryManager:
    async def deliver_with_rampup(
        self,
        subscription_id: str,
        pending_tasks: List[dict]
    ):
        """Deliver pending webhooks with gradual ramp-up."""
        
        # Start slow, increase over time
        initial_rate = 1    # per second
        max_rate = 13       # per second: the per-endpoint cap (800/minute)
        ramp_duration = 300  # 5 minutes to reach max
        
        start_time = time.time()
        delivered = 0
        
        for task in pending_tasks:
            # Calculate current rate based on time
            elapsed = time.time() - start_time
            progress = min(1.0, elapsed / ramp_duration)
            current_rate = initial_rate + (max_rate - initial_rate) * progress
            
            # Deliver
            await self.deliver(task)
            delivered += 1
            
            # Rate limit
            await asyncio.sleep(1.0 / current_rate)
            
            # Log progress
            if delivered % 100 == 0:
                logger.info(f"Recovery: {delivered}/{len(pending_tasks)} at {current_rate:.1f}/sec")

6.5 Solution 3: Dead Letter Queue + Manual Recovery#

For events that exhaust all retries:

pythonDLQManager · 67 lines
class DLQManager:
    async def get_failed_webhooks(
        self,
        subscription_id: str = None,
        limit: int = 100
    ) -> List[dict]:
        """List failed webhooks for review."""
        
        query = """
            SELECT * FROM webhook_dlq 
            WHERE status = 'pending'
        """
        params = []
        
        if subscription_id:
            params.append(subscription_id)
            query += f" AND subscription_id = ${len(params)}"  # parameterized, not interpolated
        
        params.append(limit)
        query += f" ORDER BY created_at DESC LIMIT ${len(params)}"
        
        return await self.db.fetch(query, *params)
    
    async def retry_dlq_entry(self, entry_id: str, user_id: str) -> dict:
        """Manually retry a DLQ entry."""
        
        entry = await self.db.get_dlq_entry(entry_id)
        
        if not entry:
            raise NotFoundError("DLQ entry not found")
        
        # Mark as retrying
        await self.db.update_dlq_status(entry_id, 'retrying')
        
        # Attempt delivery
        result = await self.delivery_service.deliver(
            entry['subscription_id'],
            entry['event']
        )
        
        if result.success:
            await self.db.update_dlq_status(
                entry_id, 
                'resolved',
                resolved_by=user_id
            )
            return {'status': 'delivered'}
        else:
            await self.db.update_dlq_status(entry_id, 'pending')
            return {'status': 'failed', 'error': result.error}
    
    async def retry_all_for_subscription(self, subscription_id: str, user_id: str):
        """Retry all DLQ entries for a subscription."""
        
        entries = await self.get_failed_webhooks(subscription_id, limit=10000)
        
        results = {'success': 0, 'failed': 0}
        
        for entry in entries:
            result = await self.retry_dlq_entry(entry['id'], user_id)
            
            if result['status'] == 'delivered':
                results['success'] += 1
            else:
                results['failed'] += 1
        
        return results

6.6 Admin Dashboard#

pythonget_subscription_health · 61 lines
from fastapi import FastAPI, HTTPException

app = FastAPI()

@app.get("/admin/webhooks/subscriptions/{subscription_id}/health")
async def get_subscription_health(subscription_id: str):
    """Get health status for a subscription."""
    
    subscription = await db.get_subscription(subscription_id)
    
    return {
        "subscription_id": subscription_id,
        "endpoint": subscription.url,
        "healthy": subscription.healthy,
        "consecutive_failures": subscription.consecutive_failures,
        "last_success": subscription.last_success,
        "last_failure": subscription.last_failure,
        "pending_retries": await queue.count_pending(subscription_id),
        "dlq_count": await dlq.count_for_subscription(subscription_id)
    }

@app.get("/admin/webhooks/dlq")
async def list_dlq(subscription_id: str = None, limit: int = 100):
    """List DLQ entries."""
    
    entries = await dlq_manager.get_failed_webhooks(subscription_id, limit)
    
    return {
        "entries": entries,
        "total": len(entries)
    }

@app.post("/admin/webhooks/dlq/{entry_id}/retry")
async def retry_dlq_entry(entry_id: str, current_user: User):
    """Retry a single DLQ entry."""
    
    result = await dlq_manager.retry_dlq_entry(entry_id, current_user.id)
    return result

@app.post("/admin/webhooks/subscriptions/{subscription_id}/retry-all-dlq")
async def retry_all_dlq(subscription_id: str, current_user: User):
    """Retry all DLQ entries for a subscription."""
    
    result = await dlq_manager.retry_all_for_subscription(
        subscription_id, 
        current_user.id
    )
    return result

@app.get("/admin/webhooks/stats")
async def get_webhook_stats():
    """Get overall webhook system stats."""
    
    return {
        "queue_depth": await queue.total_size(),
        "retry_queue_depth": await retry_queue.size(),
        "dlq_depth": await dlq.size(),
        "deliveries_last_hour": await metrics.get_deliveries_count(hours=1),
        "success_rate_last_hour": await metrics.get_success_rate(hours=1),
        "unhealthy_endpoints": await db.count_unhealthy_endpoints()
    }

Chapter 7: Monitoring and Observability#

7.1 Key Metrics#

python44 lines
from prometheus_client import Counter, Histogram, Gauge

# Delivery metrics
webhook_deliveries_total = Counter(
    'webhook_deliveries_total',
    'Total webhook deliveries',
    ['status', 'event_type']  # success, failed, retrying
)

webhook_delivery_duration = Histogram(
    'webhook_delivery_duration_seconds',
    'Webhook delivery duration',
    buckets=[0.1, 0.25, 0.5, 1, 2.5, 5, 10, 30]
)

# Queue metrics
webhook_queue_depth = Gauge(
    'webhook_queue_depth',
    'Webhooks pending delivery',
    ['partition']
)

webhook_retry_queue_depth = Gauge(
    'webhook_retry_queue_depth',
    'Webhooks pending retry'
)

webhook_dlq_depth = Gauge(
    'webhook_dlq_depth',
    'Webhooks in dead letter queue'
)

# Endpoint health
webhook_endpoint_healthy = Gauge(
    'webhook_endpoint_healthy',
    'Endpoint health status',
    ['subscription_id']
)

webhook_endpoint_consecutive_failures = Gauge(
    'webhook_endpoint_consecutive_failures',
    'Consecutive failures per endpoint',
    ['subscription_id']
)

7.2 Alert Rules#

yaml41 lines
groups:
  - name: webhook_alerts
    rules:
      # High failure rate
      - alert: WebhookHighFailureRate
        expr: |
          sum(rate(webhook_deliveries_total{status="failed"}[5m])) /
          sum(rate(webhook_deliveries_total[5m])) > 0.1
        for: 5m
        labels:
          severity: warning
        annotations:
          summary: "Webhook failure rate above 10%"
          description: "{{ $value | humanizePercentage }} of webhooks failing"
      
      # Queue backlog
      - alert: WebhookQueueBacklog
        expr: sum(webhook_queue_depth) > 10000
        for: 5m
        labels:
          severity: warning
        annotations:
          summary: "Webhook queue backlog: {{ $value }}"
      
      # DLQ growing
      - alert: WebhookDLQGrowing
        expr: webhook_dlq_depth > 100
        for: 15m
        labels:
          severity: warning
        annotations:
          summary: "Webhook DLQ has {{ $value }} entries"
      
      # Endpoint down
      - alert: WebhookEndpointDown
        expr: webhook_endpoint_consecutive_failures > 10
        for: 10m
        labels:
          severity: critical
        annotations:
          summary: "Webhook endpoint has {{ $value }} consecutive failures"

7.3 Structured Logging#

pythonWebhookLogger · 52 lines
import structlog

logger = structlog.get_logger()

class WebhookLogger:
    def log_delivery_attempt(
        self,
        subscription_id: str,
        event_id: str,
        event_type: str,
        attempt: int,
        result: DeliveryResult
    ):
        logger.info(
            "webhook_delivery_attempt",
            subscription_id=subscription_id,
            event_id=event_id,
            event_type=event_type,
            attempt=attempt,
            success=result.success,
            status_code=result.status_code,
            duration_ms=result.duration_ms,
            error=result.error
        )
    
    def log_moved_to_dlq(
        self,
        subscription_id: str,
        event_id: str,
        error: str,
        attempts: int
    ):
        logger.error(
            "webhook_moved_to_dlq",
            subscription_id=subscription_id,
            event_id=event_id,
            error=error,
            total_attempts=attempts
        )
    
    def log_endpoint_status(
        self,
        subscription_id: str,
        healthy: bool,
        consecutive_failures: int
    ):
        logger.info(
            "webhook_endpoint_status",
            subscription_id=subscription_id,
            healthy=healthy,
            consecutive_failures=consecutive_failures
        )
Quick check2 questions · +5 XP each

Receiver down for two hours

  1. Q1/2 A partner's endpoint is down from 2:00 to 4:00 PM. With plain exponential backoff, why do events keep failing to arrive long after 4:00 PM?

    Answer & explanation

    Answer: C

    Backoff spreads retries out, which is right while the endpoint is down but leaves events idle after it recovers: about 2 hours to drain in the lesson's example. The fix is health monitoring: probe unhealthy endpoints every 30 seconds and move their pending retries to the immediate queue on recovery.

  2. Q2/2 When flushing the 10,000 backed-up events after recovery, why ramp the delivery rate up gradually?

    Answer & explanation

    Answer: B

    This is the same thundering-herd risk as circuit breaker recovery. Starting slow and climbing to the max rate protects the receiver you are trying to reach.

Part III · Stage 3 of 4 · 3 min

Part III: Interview Questions#

Chapter 8: The Hard Questions#

8.1 "Design a webhook system for 1M events/hour. Receiver is down for 2 hours."#

Strong Answer:

"Let me walk through the architecture and then address the failure scenario.

Architecture for 1M events/hour:

The math: 1M events/hour = 278/second. With average 3 subscriptions per event, that's 834 webhooks/second. At peak (2x), 1,668/second.

Components:

  • Event bus: Kafka for durability and replay capability
  • Dispatcher: Consumes events, fans out to delivery queues
  • Partitioned queues: By subscription ID for parallel processing
  • Async workers: 250 (125 at the 2,500 req/s peak, plus 2x headroom) with connection pooling
  • Per-endpoint rate limiting: Prevent overwhelming receivers

The 2-hour outage scenario:

Default behavior is exponential backoff: 1min, 5min, 15min, 1hr, 2hr... After 2 hours of failures, webhooks are scattered across the retry schedule.

My optimization: Active health monitoring

Background job pings unhealthy endpoints every 30 seconds. When one recovers, we immediately accelerate all pending retries instead of waiting for their scheduled time.

Without this: Stragglers arrive for ~2 hours after the endpoint returns With this: Recovery takes ~13 minutes after endpoint returns

I'd also implement:

  • Gradual ramp-up to avoid overwhelming recovering endpoints
  • DLQ with admin dashboard for manual retry
  • Alerting when endpoints are persistently unhealthy

Connection to previous days:

This ties to Day 2 (idempotency) because receivers must handle duplicates. We're doing at-least-once delivery, so they need to use the event_id for deduplication."

8.2 "How do you ensure webhook security?"#

Strong Answer:

"Multiple layers of security:

1. HMAC-SHA256 signatures

Every webhook is signed with a shared secret:

  • Include timestamp in signature (prevent replay attacks)
  • Use constant-time comparison (prevent timing attacks)
  • Document verification clearly for receivers

2. Timestamp verification

Receivers should reject webhooks with timestamps more than 5 minutes old. This prevents attackers from replaying intercepted webhooks.

3. HTTPS only

We only allow HTTPS webhook URLs. We validate this during registration and refuse HTTP endpoints.

4. Secret rotation

Support rotating secrets with a grace period:

  • Generate new secret
  • Keep old secret valid for 24 hours
  • Both secrets accepted during transition
  • Old secret expires automatically

5. SSRF prevention

When users register webhook URLs, validate they're not pointing to:

  • Private IPs (10.x, 192.168.x, etc.)
  • Localhost
  • Internal hostnames

Also never follow redirects when delivering (a 3xx answer is a failed delivery), and re-check the resolved IP at delivery time, because DNS can change after registration.

6. IP whitelisting option

For security-conscious receivers, publish our webhook source IPs so they can whitelist."

8.3 "How do you handle ordering of webhooks?"#

Strong Answer:

"Ordering is tricky in distributed systems. Let me explain the challenge and solutions.

The problem:

Events occur: A → B → C But due to retries and parallelism, receiver might see: A → C → B

Solution 1: Per-entity ordering (recommended)

Partition by entity ID (order_id, customer_id):

  • All events for same entity go to same partition
  • Single worker per partition ensures order
  • Different entities processed in parallel

This gives you ordering where it matters most.

Solution 2: Sequence numbers

Include sequence in payload:

json1 line
{"sequence": 42, "entity_id": "order_123", ...}

Receiver tracks last processed sequence and handles gaps.

Solution 3: Accept eventual consistency

Document that strict global ordering isn't guaranteed. Many systems don't actually need it — they need idempotency and eventual consistency.

My recommendation:

Per-entity partitioning plus sequence numbers in payloads. This handles 95% of use cases. For the rare case needing strict global ordering, I'd push back on the requirement or use a different mechanism."

Chapter 9: Session Summary#

What You've Learned#

Topic Key Concepts
Delivery Guarantees At-least-once is standard; exactly-once is impossible
Architecture Event bus → Dispatcher → Queues → Workers → DLQ
Retry Strategy Exponential backoff with health-based acceleration
Security HMAC signatures, timestamp validation, HTTPS
Scale Partitioning, connection pooling, rate limiting
Recovery Health monitoring, gradual ramp-up, DLQ

Connection to Previous Days#

Day Connection
Day 1 (Timeouts) Webhook requests need timeouts
Day 2 (Idempotency) Receivers must be idempotent
Day 3 (Circuit Breakers) Per-endpoint circuit breakers
Day 4 (Webhooks) Complete async delivery system

Key Trade-offs#

Decision Trade-off
At-least-once vs at-most-once Reliability vs simplicity
Long retry window Higher delivery rate vs older events
Health monitoring Faster recovery vs more complexity
Per-endpoint rate limiting Fairness vs throughput
Quick check2 questions · +5 XP each

Ordering and security trade-offs

  1. Q1/2 A receiver needs order.created, order.paid and order.shipped for the same order in order. What does the lesson recommend?

    Answer & explanation

    Answer: A

    Per-entity partitioning keeps one order's events in sequence while different orders still run in parallel. Sequence numbers let receivers spot gaps. One global worker would preserve order but throw away all parallelism; retries are exactly what scrambles order.

  2. Q2/2 When you rotate a subscription's signing secret, why keep the previous secret valid for 24 hours?

    Answer & explanation

    Answer: C

    During the grace period both secrets verify, so there is no window where valid webhooks are rejected while receivers update their configuration.

Part IV · Stage 4 of 4 · 1 min

Part IV: Further Learning#

Resources#

Documentation#

Open Source Projects#

Books#

  • Book

    "Designing Data-Intensive Applications" by Martin Kleppmann - Chapter 11 on streaming

  • Book

    "Building Microservices" by Sam Newman - Event-driven architecture

Standards#

Exercises · 1 min

Exercises#

Exercise 1: Implement Webhook Signature Verification#

Build complete signature generation and verification with:

  • HMAC-SHA256 signing
  • Timestamp validation
  • Replay attack prevention
  • Secret rotation support

Exercise 2: Build Adaptive Retry System#

Create a retry manager that:

  • Uses exponential backoff
  • Monitors endpoint health
  • Accelerates retries on recovery
  • Implements gradual ramp-up

Exercise 3: Design DLQ Dashboard#

Create admin interface with:

  • List failed webhooks by subscription
  • Single and batch retry functionality
  • Failure analytics and trends
  • Alerting configuration

Appendix · 8 min

Appendix: Production Implementation#

Complete Webhook Delivery Service#

pythonWebhookConfig · 491 lines
"""
Production webhook delivery system.
Building on Days 1-4: Timeouts, Idempotency, Circuit Breakers, Webhooks.
"""

import asyncio
import hashlib
import hmac
import json
import time
import uuid
from dataclasses import dataclass, field
from datetime import datetime, timedelta, timezone
from typing import List, Optional, Dict, Any
from enum import Enum
import aiohttp
import structlog
from prometheus_client import Counter, Histogram, Gauge

logger = structlog.get_logger()

# =============================================================================
# Configuration
# =============================================================================

@dataclass
class WebhookConfig:
    # Delivery
    delivery_timeout: int = 30
    max_retries: int = 8  # 9 deliveries in all: the first try plus 8 retries
    retry_delays: List[int] = field(default_factory=lambda: [
        60, 300, 900, 3600, 7200, 14400, 28800, 86400
    ])
    
    # Rate limiting
    max_concurrent_per_endpoint: int = 10
    max_requests_per_minute: int = 800
    
    # Health monitoring
    health_check_interval: int = 30
    circuit_breaker_threshold: int = 10
    
    # Security
    signature_tolerance: int = 300

# =============================================================================
# Models
# =============================================================================

class DeliveryStatus(Enum):
    PENDING = "pending"
    SUCCESS = "success" 
    FAILED = "failed"
    RETRYING = "retrying"
    DLQ = "dlq"

@dataclass
class WebhookSubscription:
    id: str
    user_id: str
    url: str
    secret: str
    events: List[str]
    active: bool = True
    healthy: bool = True
    consecutive_failures: int = 0
    last_success: Optional[datetime] = None
    last_failure: Optional[datetime] = None
    created_at: datetime = field(default_factory=lambda: datetime.now(timezone.utc))

@dataclass
class WebhookEvent:
    id: str
    type: str
    data: Dict[str, Any]
    timestamp: datetime = field(default_factory=lambda: datetime.now(timezone.utc))

@dataclass 
class DeliveryResult:
    success: bool
    status_code: Optional[int] = None
    duration_ms: Optional[int] = None
    error: Optional[str] = None
    should_retry: bool = False

# =============================================================================
# Metrics
# =============================================================================

deliveries_total = Counter(
    'webhook_deliveries_total',
    'Total deliveries',
    ['status', 'event_type']
)

delivery_duration = Histogram(
    'webhook_delivery_duration_seconds', 
    'Delivery duration',
    buckets=[0.1, 0.5, 1, 2, 5, 10, 30]
)

queue_depth = Gauge('webhook_queue_depth', 'Queue depth')
dlq_depth = Gauge('webhook_dlq_depth', 'DLQ depth')

# =============================================================================
# Webhook Signer
# =============================================================================

class WebhookSigner:
    @staticmethod
    def sign(payload: dict, secret: str, timestamp: int = None) -> Dict[str, str]:
        timestamp = timestamp or int(time.time())
        payload_str = json.dumps(payload, separators=(',', ':'), sort_keys=True)
        signed_content = f"{timestamp}.{payload_str}"
        
        signature = hmac.new(
            secret.encode(),
            signed_content.encode(),
            hashlib.sha256
        ).hexdigest()
        
        return {
            'X-Webhook-Timestamp': str(timestamp),
            'X-Webhook-Signature': f"sha256={signature}",
            'Content-Type': 'application/json'
        }
    
    @staticmethod
    def verify(payload: str, signature: str, timestamp: str, 
               secret: str, tolerance: int = 300) -> bool:
        try:
            ts = int(timestamp)
            if abs(time.time() - ts) > tolerance:
                return False
            
            if not signature.startswith('sha256='):
                return False
            
            expected = hmac.new(
                secret.encode(),
                f"{ts}.{payload}".encode(),
                hashlib.sha256
            ).hexdigest()
            
            return hmac.compare_digest(signature[7:], expected)
        except:
            return False

# =============================================================================
# Retry Manager
# =============================================================================

class RetryManager:
    def __init__(self, config: WebhookConfig, queue, dlq):
        self.config = config
        self.queue = queue
        self.dlq = dlq
    
    async def schedule_retry(self, task: dict, error: str) -> bool:
        attempt = task.get('attempt', 0)
        
        if attempt >= self.config.max_retries:
            await self.move_to_dlq(task, error)
            return False
        
        delay = self.config.retry_delays[min(attempt, len(self.config.retry_delays) - 1)]
        delay += int(delay * 0.1 * (hash(task['event']['id']) % 100 - 50) / 50)
        
        task['attempt'] = attempt + 1
        task['last_error'] = error
        task['retry_at'] = (datetime.now(timezone.utc) + timedelta(seconds=delay)).isoformat()
        
        await self.queue.enqueue_delayed(task, delay)
        
        logger.info("retry_scheduled",
            subscription_id=task['subscription_id'],
            event_id=task['event']['id'],
            attempt=task['attempt'],
            delay=delay
        )
        
        return True
    
    async def move_to_dlq(self, task: dict, error: str):
        dlq_entry = {
            'id': str(uuid.uuid4()),
            'task': task,
            'error': error,
            'attempts': task.get('attempt', 0),
            'failed_at': datetime.now(timezone.utc).isoformat(),
            'status': 'pending'
        }
        
        await self.dlq.push(dlq_entry)
        dlq_depth.inc()
        
        logger.error("moved_to_dlq",
            subscription_id=task['subscription_id'],
            event_id=task['event']['id'],
            error=error,
            attempts=task.get('attempt', 0)
        )

# =============================================================================
# Delivery Service
# =============================================================================

class WebhookDeliveryService:
    def __init__(self, config: WebhookConfig, subscription_store, 
                 retry_manager: RetryManager):
        self.config = config
        self.subscriptions = subscription_store
        self.retry_manager = retry_manager
        self.signer = WebhookSigner()
        self.session: Optional[aiohttp.ClientSession] = None
        self.endpoint_semaphores: Dict[str, asyncio.Semaphore] = {}
    
    async def start(self):
        connector = aiohttp.TCPConnector(
            limit=1000,
            limit_per_host=self.config.max_concurrent_per_endpoint,
            ttl_dns_cache=300
        )
        timeout = aiohttp.ClientTimeout(total=self.config.delivery_timeout)
        self.session = aiohttp.ClientSession(connector=connector, timeout=timeout)
    
    async def stop(self):
        if self.session:
            await self.session.close()
    
    async def deliver(self, task: dict) -> DeliveryResult:
        subscription_id = task['subscription_id']
        event = task['event']
        attempt = task.get('attempt', 0)
        
        subscription = await self.subscriptions.get(subscription_id)
        if not subscription or not subscription.active:
            return DeliveryResult(success=False, error="Subscription inactive")
        
        # Build signed payload
        payload = {
            'id': event['id'],
            'type': event['type'],
            'data': event['data'],
            'created_at': event['timestamp']
        }
        headers = self.signer.sign(payload, subscription.secret)
        # Send exactly the bytes that were signed (json= would re-serialize)
        body = json.dumps(payload, separators=(',', ':'), sort_keys=True, default=str)
        
        # Rate limit per endpoint
        semaphore = self.endpoint_semaphores.setdefault(
            subscription.url,
            asyncio.Semaphore(self.config.max_concurrent_per_endpoint)
        )
        
        start = time.time()
        
        async with semaphore:
            try:
                async with self.session.post(
                    subscription.url,
                    data=body,
                    headers=headers,
                    allow_redirects=False  # never follow redirects (SSRF); a 3xx is a failed delivery
                ) as response:
                    duration_ms = int((time.time() - start) * 1000)
                    
                    delivery_duration.observe(duration_ms / 1000)
                    
                    if 200 <= response.status < 300:
                        deliveries_total.labels(status='success', event_type=event['type']).inc()
                        await self._record_success(subscription)
                        return DeliveryResult(
                            success=True,
                            status_code=response.status,
                            duration_ms=duration_ms
                        )
                    
                    elif 400 <= response.status < 500 and response.status != 429:
                        deliveries_total.labels(status='client_error', event_type=event['type']).inc()
                        return DeliveryResult(
                            success=False,
                            status_code=response.status,
                            error=f"Client error: {response.status}",
                            should_retry=False
                        )
                    
                    else:
                        deliveries_total.labels(status='server_error', event_type=event['type']).inc()
                        await self._record_failure(subscription)
                        return DeliveryResult(
                            success=False,
                            status_code=response.status,
                            error=f"Server error: {response.status}",
                            should_retry=True,
                            duration_ms=duration_ms
                        )
            
            except asyncio.TimeoutError:
                deliveries_total.labels(status='timeout', event_type=event['type']).inc()
                await self._record_failure(subscription)
                return DeliveryResult(
                    success=False,
                    error="Timeout",
                    should_retry=True
                )
            
            except Exception as e:
                deliveries_total.labels(status='error', event_type=event['type']).inc()
                await self._record_failure(subscription)
                return DeliveryResult(
                    success=False,
                    error=str(e),
                    should_retry=True
                )
    
    async def _record_success(self, subscription: WebhookSubscription):
        subscription.healthy = True
        subscription.consecutive_failures = 0
        subscription.last_success = datetime.now(timezone.utc)
        await self.subscriptions.update(subscription)
    
    async def _record_failure(self, subscription: WebhookSubscription):
        subscription.consecutive_failures += 1
        subscription.last_failure = datetime.now(timezone.utc)
        
        if subscription.consecutive_failures >= self.config.circuit_breaker_threshold:
            subscription.healthy = False
        
        await self.subscriptions.update(subscription)

# =============================================================================
# Worker
# =============================================================================

class WebhookWorker:
    def __init__(self, worker_id: str, delivery_service: WebhookDeliveryService,
                 retry_manager: RetryManager, queue):
        self.worker_id = worker_id
        self.delivery = delivery_service
        self.retry_manager = retry_manager
        self.queue = queue
        self.running = False
    
    async def run(self):
        self.running = True
        logger.info("worker_started", worker_id=self.worker_id)
        
        while self.running:
            try:
                tasks = await self.queue.dequeue(count=10, timeout=5)
                
                if tasks:
                    await asyncio.gather(*[
                        self.process_task(task) for task in tasks
                    ])
            except Exception as e:
                logger.error("worker_error", error=str(e))
                await asyncio.sleep(1)
    
    async def process_task(self, task: dict):
        result = await self.delivery.deliver(task)
        
        if result.success:
            logger.info("delivery_success",
                subscription_id=task['subscription_id'],
                event_id=task['event']['id'],
                duration_ms=result.duration_ms
            )
        elif result.should_retry:
            await self.retry_manager.schedule_retry(task, result.error)
        else:
            logger.warning("delivery_permanent_failure",
                subscription_id=task['subscription_id'],
                event_id=task['event']['id'],
                error=result.error
            )
    
    async def stop(self):
        self.running = False

# =============================================================================
# Health Checker
# =============================================================================

class HealthChecker:
    def __init__(self, subscription_store, queue, check_interval: int = 30):
        self.subscriptions = subscription_store
        self.queue = queue
        self.check_interval = check_interval
        self.running = False
    
    async def run(self):
        self.running = True
        
        while self.running:
            try:
                unhealthy = await self.subscriptions.find_unhealthy()
                
                for sub in unhealthy:
                    if await self._check_health(sub.url):
                        logger.info("endpoint_recovered", 
                            subscription_id=sub.id, url=sub.url)
                        
                        sub.healthy = True
                        sub.consecutive_failures = 0
                        await self.subscriptions.update(sub)
                        
                        await self._accelerate_retries(sub.id)
                
                await asyncio.sleep(self.check_interval)
            except Exception as e:
                logger.error("health_check_error", error=str(e))
                await asyncio.sleep(self.check_interval)
    
    async def _check_health(self, url: str) -> bool:
        try:
            async with aiohttp.ClientSession() as session:
                async with session.head(url, timeout=aiohttp.ClientTimeout(total=5), allow_redirects=False) as resp:
                    return resp.status < 500
        except:
            return False
    
    async def _accelerate_retries(self, subscription_id: str):
        pending = await self.queue.get_pending_for_subscription(subscription_id)
        
        for task in pending:
            await self.queue.move_to_immediate(task)
        
        if pending:
            logger.info("retries_accelerated",
                subscription_id=subscription_id,
                count=len(pending)
            )
    
    async def stop(self):
        self.running = False

# =============================================================================
# DLQ Manager
# =============================================================================

class DLQManager:
    def __init__(self, dlq, delivery_service: WebhookDeliveryService):
        self.dlq = dlq
        self.delivery = delivery_service
    
    async def list_entries(self, subscription_id: str = None, 
                          status: str = 'pending', limit: int = 100) -> List[dict]:
        return await self.dlq.find(
            subscription_id=subscription_id,
            status=status,
            limit=limit
        )
    
    async def retry_entry(self, entry_id: str, user_id: str) -> dict:
        entry = await self.dlq.get(entry_id)
        if not entry:
            raise ValueError("Entry not found")
        
        await self.dlq.update_status(entry_id, 'retrying')
        
        result = await self.delivery.deliver(entry['task'])
        
        if result.success:
            await self.dlq.update_status(entry_id, 'resolved', resolved_by=user_id)
            dlq_depth.dec()
            return {'status': 'delivered'}
        else:
            await self.dlq.update_status(entry_id, 'pending')
            return {'status': 'failed', 'error': result.error}
    
    async def retry_all(self, subscription_id: str, user_id: str) -> dict:
        entries = await self.list_entries(subscription_id, limit=10000)
        
        results = {'success': 0, 'failed': 0}
        
        for entry in entries:
            result = await self.retry_entry(entry['id'], user_id)
            if result['status'] == 'delivered':
                results['success'] += 1
            else:
                results['failed'] += 1
        
        return results
    
    async def discard_entry(self, entry_id: str, user_id: str, reason: str):
        await self.dlq.update_status(entry_id, 'discarded', 
            resolved_by=user_id, resolution_note=reason)
        dlq_depth.dec()

this lesson in shorts · 3

play them all →

One idea from this lesson in about a minute each, made for your phone.

End of Day 04 · Webhook Delivery System
Finish Day 4Stages clear as you read them (0/4 so far). Skipping ahead doesn't count: marking complete unlocks after ~6 more min of reading.

Go deeper · reading trail

all reading →

Real write-ups from the teams that built these systems. Each opens on the publisher's site in a new tab.

StripeDocs · ~17 min

Receive Stripe events in your webhook endpoint

What
Stripe's guide to receiving and verifying webhook events.
Key idea
Events are retried with exponential backoff for up to three days in live mode, may arrive out of order or twice, and carry a signature to verify against the raw body.
Why here
The sender's contract this lesson designs: at-least-once delivery, signed payloads and receivers that dedupe by event ID.
taught here: Delivery guarantees
RazorpayDocs · ~4 min

Webhooks Best Practices

What
Razorpay's rules for how its webhooks are delivered and how merchants should handle them.
Key idea
Any non-2xx response is retried with exponential backoff for 24 hours; after that the webhook is disabled and the merchant alerted, and duplicates must be handled.
Why here
A concrete retry window and disable policy, exactly the decisions behind this lesson's 'receiver down for 2 hours' challenge.
taught here: Retry strategies
GitHubDocs · ~4 min

Best practices for using webhooks

What
GitHub's checklist for webhook receivers.
Key idea
Use a secret to verify payloads, respond with 2xx within 10 seconds and process asynchronously, redeliver missed events and dedupe with the X-GitHub-Delivery header.
Why here
The receiver side of this lesson's design: fast acknowledgement, signature checks and idempotent processing.
taught here: Webhook security

New lessons by email

New lessons, side quests and case files as they ship, at most once a week. No spam, unsubscribe anytime.

Discussion · …

Public: everyone reading this lesson sees comments. Be kind and keep it on the material.

Sign in to ask a question or answer one. Reading stays open to everyone.
loading the discussion…