Engineering Atlas › System Design Interviews

Design a webhook delivery service

18 min read · last reviewed 2026-10-17

Open in the interactive app: practice, quiz, mock interview →

In one minute

Requirements

Functional

Non-functional

Out of scope

Back-of-envelope

API

POST /v1/endpoints
{ "tenant_id": "t_91", "url": "https://shop.example.com/hooks",
  "event_types": ["order.paid", "order.refunded"], "ordered": false }
-> 201 { "endpoint_id": "ep_7f3", "secret": "whsec_..." }

POST /v1/events                      (producers; Idempotency-Key header required)
{ "tenant_id": "t_91", "type": "order.paid", "data": { "order_id": "o_55", "version": 12 } }
-> 202 { "event_id": "evt_01J9..." }

GET  /v1/endpoints/ep_7f3/deliveries?status=failed&since=2026-10-01T00:00:00Z
POST /v1/endpoints/ep_7f3/replay     { "from": "2026-10-16T09:00:00Z", "to": "2026-10-16T11:00:00Z", "only_failed": true }
POST /v1/endpoints/ep_7f3/rotate-secret

What the customer receives follows the shape of the open Standard Webhooks specification (Standard Webhooks spec):

POST /hooks HTTP/1.1
webhook-id: msg_2KWPBg        # stable across retries and replays
webhook-timestamp: 1760695200 # seconds, set on every attempt
webhook-signature: v1,K5oZfzN95Z9UVu1Esf...   # base64 HMAC-SHA256 of "id.timestamp.body"
content-type: application/json

{"type":"order.paid","data":{"order_id":"o_55","version":12}}

Any 2xx is success. 429 and 503 with Retry-After are honoured. Everything else, including redirects and timeouts, is a failure.

Data model

Committing the events row and the deliveries rows together (a transactional outbox) is what makes "accepted means not lost" true.

High-level design

flowchart LR
  P[Producer] -->|"POST /events"| ING["Ingest API"]
  ING --> ES[("Event store")]
  ING --> FO["Fan-out (match subscriptions)"]
  FO --> DS[("Delivery state")]
  FO --> Q["Partitioned queue (key = endpoint_id)"]
  Q --> D["Dispatcher (per-endpoint scheduler)"]
  D -->|"signed POST"| EG["Egress proxy"]
  EG --> C["Customer endpoint"]
  D -->|"result"| DS
  DS -->|"retry due"| T["Timer / delay store"]
  T --> Q
  D -->|"disable, alert"| N["Notifier"]
  R["Replay API"] --> ES
  R --> FO

Write path.

  1. The ingest API authenticates the producer, deduplicates on the idempotency key, and commits the event and its delivery rows together. It answers 202.
  2. Fan-out looks up matching endpoints (cached) and publishes one message per delivery to a partitioned queue, keyed by endpoint_id. All work for one endpoint lands on one partition, so one dispatcher owns that endpoint's state.
  3. The dispatcher keeps, per active endpoint, a small in-memory queue, an in-flight count, a failure streak and a circuit state. A fair scheduler picks the next endpoint with capacity. It loads the payload, signs it with the current secret and sends it through the egress proxy.
  4. On 2xx it marks the delivery succeeded. On failure it records the attempt, computes the next time with jittered backoff, and writes next_attempt_at. A timer service re-publishes the delivery to the queue when it is due.

Read path. The delivery log and replay API read deliveries and attempts by endpoint. Replay creates new delivery rows for stored events with the same webhook-id, so receivers can deduplicate across copies.

Deep dives

1. Retries, backoff and the recovery stampede

Options. Fixed interval retries are simple, but every failed delivery comes back in lockstep. Exponential backoff spreads them out. Exponential backoff with full jitter spreads them further, because retries from many senders are randomised across the whole window, which AWS's simulations found reduces contention compared with no jitter (AWS Architecture Blog).

Choice. Delay is random(0, min(cap, base * 2^(attempt-1))), with a long schedule. Real services retry for hours to days. Stripe retries live-mode events for up to three days with exponential backoff (Stripe docs), and Svix's default schedule is immediately, 5 s, 5 min, 30 min, 2 h, 5 h, 10 h and 10 h more (Svix docs). Shopify retries 8 times over 4 hours (Shopify docs).

The part people forget. Jitter handles retries of the same age. It does not handle a customer that was down for six hours and comes back with 400k queued events. If you release them all, you knock the endpoint over again. So each endpoint has a concurrency limit that adapts like TCP congestion control: start at 1 to 4 in flight, add one per success window, halve on timeout or 5xx, honour Retry-After. The backlog drains at whatever rate the customer can take. Waiting deliveries sit in a timer store (a table indexed on next_attempt_at), not in worker memory.

2. Fairness, slow endpoints and circuit breaking

The failure to prevent. A shared pool of workers and a shared FIFO means one customer whose endpoint takes 10 s to time out can hold all workers. Every other customer then waits behind it. This is head-of-line blocking plus a noisy neighbour, a problem AWS describes for multi-tenant systems.

Options. One physical queue per endpoint isolates perfectly, but 2M mostly empty queues are a management problem. One shared queue with a per-tenant rate limit is cheap but guards against volume, not slowness. The middle path is a logical queue per endpoint multiplexed on shared partitions, with a per-endpoint scheduler in the dispatcher.

Choice: the middle path. The scheduler is round robin over endpoints that have work and capacity (the same shape as the practice problem). Each endpoint has a hard cap on in-flight requests and a tight connect and total timeout (GitHub asks receivers to answer within 10 seconds and cuts the connection after that, GitHub docs). A circuit breaker, in the sense of Fowler's pattern, opens after a failure streak: the endpoint stops getting traffic except a single probe delivery, and goes half-open when the probe succeeds.

Dead endpoints. After a configured period of continuous failure (say 3 days), mark the endpoint disabled, keep the events, email the tenant and offer one-click replay. Shopify instead deletes an Admin-API subscription after 8 consecutive failures, which is harsher than keeping events.

3. Ordering, idempotency and replay

Ordering. Strict ordering and high availability conflict here. To deliver event 2 after event 1 you must hold event 2 until event 1 succeeds, so one failing event blocks the whole endpoint. Stripe states it does not guarantee delivery in the order events are generated (Stripe docs). The default should be unordered with a per-resource version or created field in the payload. For tenants that need order, offer an ordered endpoint: a single in-flight delivery per endpoint (or per ordering key, such as the order ID), at the price of head-of-line blocking.

Idempotency. Duplicates come from the sender (a timeout after the customer already processed the request), from replay, and from the queue (redelivery after a dispatcher crash). The sender cannot remove them. It can make them recognisable: webhook-id stays the same across attempts and replays. The receiver stores processed IDs and ignores repeats, as Stripe's advice to track event IDs and the Standard Webhooks advice to treat webhook-id as an idempotency key both say.

Replay. Replay is a new set of deliveries rows over stored events, with a filter (time range, event type, only failed). It reuses the original webhook-id, gets a new timestamp and signature, and goes through the same per-endpoint scheduler, so a bulk replay of 5M events is still rate limited by the endpoint's adaptive concurrency. Retention bounds what can be replayed: Stripe lets you resend an event for up to 30 days (Stripe docs).

Down the stack

Layer 1The contract

  • Delivery: for every accepted event and every matching, enabled endpoint, the service attempts delivery until it gets a 2xx, the retry window ends, or the endpoint is disabled. Each failure is visible in the log.
  • Duplicates: may occur. The same webhook-id is used on every attempt and replay of the same event for the same endpoint.
  • Ordering: none by default. With ordered: true, event N+1 is not sent until event N has succeeded or been dead-lettered.
  • Authenticity: a request is valid only if the HMAC over id.timestamp.body matches one of the signatures sent and the timestamp is within a tolerance (Stripe's libraries default to 5 minutes, Stripe docs).

Layer 2The mechanism

  • State machine per delivery: pending -> in_flight -> succeeded, or in_flight -> retrying -> pending (when next_attempt_at arrives), or -> dead after the last attempt. Every transition is a conditional write on (status, attempt), so two dispatchers that both think they own a delivery cannot both record success.
  • Lease: an in_flight delivery carries a lease. If the dispatcher dies the lease expires and the delivery is re-published, a source of duplicates.
  • Signing: the signed string is webhook-id + "." + webhook-timestamp + "." + body, the MAC is HMAC (RFC 2104) with SHA-256, and the receiver must hash the raw bytes, not a re-serialised JSON object. Secrets are per endpoint. Rotation sends two signatures separated by a space (both v1,... entries), so the customer can switch without downtime. GitHub's X-Hub-Signature-256 signs the body alone (GitHub docs), which has no replay protection. Binding a timestamp fixes that.

Layer 3The machine

  • Connections: a fresh TLS 1.3 connection costs one round trip for TCP plus one for TLS, about 2 RTT, so 100 to 300 ms to a distant customer. Reusing connections per host with keep-alive makes the common case one RTT. Dispatchers keep a pool per destination host.
  • Port exhaustion: the default Linux ephemeral range, 32768 to 60999, gives about 28k source ports per (source IP, destination IP, destination port). A single large customer receiving 50k/s through one egress IP runs out. Spread egress over several source IPs, or reuse connections with HTTP/2.
  • DNS and SSRF: the egress proxy resolves the name itself, rejects private, loopback and link-local addresses (including cloud metadata addresses such as 169.254.169.254), and connects to the resolved IP. Without that, a tenant can point an endpoint at your internal network, and DNS rebinding can swap the address between check and connect.
  • HMAC cost: SHA-256 runs at about 1 to 2 GB/s per core with hardware instructions, so signing a 2 KB payload takes a couple of microseconds. Signing is never the bottleneck. Per-attempt signing is cheap, which is why every attempt gets a fresh timestamp.

Why it's built this way

  1. The customer's server is outside your control and fails often, so you cannot ever know whether it processed a request. So delivery is at-least-once, and the protocol hands the receiver an ID to deduplicate on.
  2. Failures last from seconds to days. So retries live in a durable timer store with a long schedule, not in a worker's memory.
  3. Customers share your capacity but not each other's fate. So you need per-endpoint state (queue, cap, breaker), because a timeout holds a connection for as long as the timeout is.
  4. Nothing proves a request came from you except a secret. So requests are signed over a body, ID and timestamp, and secrets rotate with overlap.
  5. People fix their endpoints later and want the missed events. So events are kept past delivery, and replay is a first-class, rate-limited operation, not a support ticket.

Worked example

A tenant, Acme, has one endpoint subscribed to order.paid and receives 40 events/s. Its server goes down for 5 hours (18,000 s). The dispatcher uses base 5 s, cap 10 h, full jitter, and caps the probe interval at 5 minutes while the breaker is open.

Failure modes

In the wild

Trade-offs

OptionStrengthsWeaknessesChoose when
Logical per-endpoint queue on shared partitionsIsolation without millions of queues; state is rebuildableNeeds a scheduler and per-endpoint memoryMany small and a few large endpoints (this design)
Physical queue per endpointStrongest isolationQueue count, cost, managementFew, very large, regulated tenants
Unordered deliveryHighest availability; parallelism per endpointReceivers must handle reorderingDefault
Ordered delivery per keySimple receiver logicHead-of-line blocking; low throughputPer-aggregate streams where order is required
Push onlyLow latency; no pollingReceiver must be reachableDefault
Push plus pull events APIReceivers recover alone; fewer replay ticketsMore API surfaceAnything with strict completeness

Leadership lens

Check yourself

  1. A customer's endpoint returns 200 and then the connection drops before your service reads the response. What does your service do, and what must the receiver have done to make that safe?

    Show answer

    It sees a failure and retries, so the receiver gets a duplicate. The receiver must deduplicate on the stable webhook-id (store processed IDs), because the sender cannot tell a lost response from a lost request.

  2. Why use full jitter on the retry delay, and why is jitter alone not enough after a six-hour customer outage?

    Show answer

    Jitter spreads retries of similar age so they do not arrive in lockstep. After a long outage, the backlog itself is the stampede, so you also need a per-endpoint adaptive concurrency limit that ramps up as the receiver proves it can take load.

  3. Your pipeline is a single shared worker pool and a FIFO queue. One customer's endpoint starts timing out at 10 s. What breaks, and what is the minimum fix?

    Show answer

    Workers fill up with 10 s waits, so every tenant's deliveries queue behind them. The minimum fix is a per-endpoint in-flight cap with a short timeout, a failure breaker, and fair scheduling across endpoints.

  4. With 1B events/day and 20% retry overhead, roughly how many attempts per second are made on average and how many connections are open at 300 ms latency?

    Show answer

    1.2B / 86,400 is about 14k attempts/s. By Little's law, 14k x 0.3 s is about 4.2k open requests, before adding slow timeouts and the 5x peak.

Sources and further study

Primary

Systems docs

Books

Other curricula

Watch

Free resources

Open in the interactive app: practice, quiz, mock interview →