Design an outgoing webhook delivery service
The interviewer asks
“Design the service that sends webhooks to customers' servers, like Stripe or Razorpay do when a payment succeeds. Thousands of merchants register HTTPS endpoints, many of them slow or broken, and every event must reach them reliably, securely and without one bad endpoint hurting everyone else.”
What a strong answer looks like
A strong answer separates accepting an event from delivering it. Product services publish domain events through an outbox to Kafka; a fan-out stage matches each event to the subscribed endpoints and creates one delivery job per (event, endpoint). Delivery workers POST with an HMAC signature, a unique webhook id and a timestamp, under strict timeouts, and treat only a 2xx as success. Failures retry with exponential backoff and jitter for about three days, then land in a dead-letter state the merchant can replay from a dashboard. The hard parts are isolation (a per-endpoint queue and concurrency cap so one endpoint returning 30-second timeouts cannot starve the worker pool), security (signing, secret rotation, SSRF protection for customer-supplied URLs) and being honest that delivery is at-least-once, so receivers must dedupe on the webhook id and should not rely on ordering.
Concepts to mention
1 Requirements
Functional
- Merchants register endpoints (HTTPS URL) and choose which event types they want (payment.captured, refund.processed, order.shipped)
- Every event matching a subscription is delivered as an HTTP POST with a JSON body
- Each request is signed so the receiver can verify it came from us and was not replayed
- Automatic retries with backoff for failures and timeouts, for up to ~3 days
- Merchant dashboard: delivery log per endpoint (request, response code, latency), manual replay of one event or a time range
- Endpoints that keep failing are auto-disabled with an email to the merchant
- Secret rotation without downtime for the receiver
Non-functional
- No event loss once our API has committed it: at-least-once delivery
- First delivery attempt within p99 5 s of the event being committed
- Scale: 100M events/day, 300M delivery attempts/day including retries, 200k registered endpoints
- Isolation: one slow or dead endpoint must not delay deliveries to others
- 99.95% availability of the delivery pipeline; outages delay deliveries but never drop them
- Per-endpoint rate cap so we never overwhelm a small merchant's server (default 50 concurrent requests)
Out of scope (say it out loud)
- Receiving webhooks from third parties (separate design)
- Guaranteed ordering across events
- Non-HTTP transports such as pushing to the customer's own Kafka or SQS
2 Estimates
3 API
| Endpoint | What it does |
|---|---|
POST/v1/webhook_endpoints | Register {url, event_types[], description}; returns endpoint_id and signing secret (shown once) |
PUT/v1/webhook_endpoints/{id} | Change URL or event types, enable or disable the endpoint |
POST/v1/webhook_endpoints/{id}/rotate_secret | Create a new secret; old one stays valid for 24 h so both signatures are sent |
GET/v1/webhook_endpoints/{id}/deliveries?status=failed&cursor= | Delivery log for the dashboard with cursor pagination |
POST/v1/deliveries/{id}/retry | Manually replay one delivery now |
POST/v1/webhook_endpoints/{id}/replay | Replay all events in a time range, e.g. after the merchant fixes a bug |
POSTmerchant URL (outbound) | Headers webhook-id, webhook-timestamp, webhook-signature; JSON body {id, type, created, data} |
4 Data model
endpointsPostgresendpoint_id (PK)merchant_idurlevent_types[]secrets (encrypted, current + previous)status (enabled|disabled|auto_disabled)max_concurrencycreated_atSmall (200k rows) and read on every fan-out, so cached in memory in the fan-out service with change events for invalidation. Secrets encrypted with a KMS key.
eventsKafka + S3event_idmerchant_idtypecreated_atpayloadKafka topic keyed by merchant_id for fan-out; payloads also written to S3 by event_id for 30-day replay.
deliveriesCassandra / DynamoDBdelivery_id (PK) = hash(event_id, endpoint_id)endpoint_idevent_idstatusattemptsnext_attempt_atlast_status_codeDeterministic id makes fan-out idempotent. Partition by endpoint_id + day bucket for the dashboard query.
attemptsClickHousedelivery_idendpoint_idattempt_nostarted_atlatency_msstatus_codeerrorresponse_snippet (1 KB)Append-only log for the dashboard, debugging and endpoint health stats; TTL 30 days.
retry scheduleRedis sorted sets / delay queuescore = next_attempt_atmember = delivery_idDue retries polled every second and pushed back to the endpoint's queue. A durable copy lives in the deliveries table so Redis loss only delays retries.
endpoint healthRedisendpoint_id → success rate (5 min)consecutive failurescircuit statein-flight countDrives concurrency caps, circuit breaking and auto-disable.
5 Architecture
Hover a box to highlight its connections.
6 Request flows
payment.captured reaches the merchant
- 1Payments service commits the payment row and an outbox row in one Postgres transaction
- 2Outbox relay (or Debezium) publishes the event to Kafka topic domain-events, key = merchant_id
- 3Fan-out service looks up the merchant's enabled endpoints subscribed to payment.captured (in-memory cache)
- 4For each endpoint it writes the payload to S3, upserts a delivery row with id = hash(event_id, endpoint_id), and enqueues a job on that endpoint's queue
- 5A delivery worker takes the job, checks the endpoint's in-flight count is under its cap, builds the body and signs timestamp + body with HMAC-SHA256
- 6Worker POSTs with a 3 s connect and 10 s total timeout and no redirects; a 2xx marks the delivery succeeded
- 7Attempt (status code, latency, first 1 KB of response) is appended to the attempts log for the dashboard
Endpoint is down for six hours
- 1Attempts return 503 or time out; each failure schedules a retry at roughly 5 s, 30 s, 2 min, 10 min, 1 h, then every few hours, with ±20% jitter
- 2After 20 consecutive failures the endpoint's circuit opens: new jobs are parked instead of each one hitting the dead server
- 3A probe request every few minutes tests the endpoint; when it returns 2xx the circuit closes
- 4Parked jobs drain at the endpoint's concurrency cap (not all at once) so the recovering server is not flattened
- 5If the endpoint fails continuously for 3 days, remaining deliveries move to failed, the endpoint is auto-disabled and the merchant is emailed
Merchant replays after a bug fix
- 1Merchant selects 'replay failed events from 10:00-14:00' in the dashboard
- 2API queries deliveries for that endpoint and time range with status failed
- 3Replay jobs are enqueued at a capped rate on the endpoint's queue, reusing the same webhook-id so the merchant's dedup still works
7 Deep dives
Isolation: one bad endpoint must not slow everyone
The naive design is one shared queue and a pool of workers that POST and wait. Then a big merchant's endpoint starts taking 10 s to time out, every worker ends up waiting on it, and payment webhooks for 200k other merchants stall. This is head-of-line blocking and it is the main way these systems fail. Fix it at three levels. First, queue per endpoint (or per endpoint shard): workers pull from many queues fairly, for example round-robin across queues that have ready work, so a backlog on one endpoint does not sit in front of others. Second, a per-endpoint concurrency cap stored in Redis (in-flight counter with a short lease): if endpoint X already has 50 requests in flight, its jobs wait while workers serve other endpoints. Third, a circuit breaker per endpoint: after N consecutive failures stop sending, park jobs, and probe occasionally. Use async, non-blocking HTTP clients so a waiting request costs a few KB of memory, not a thread. Finally, separate pools for very large merchants or for slow endpoints (p50 latency above 2 s), the bulkhead pattern, so their volume cannot consume the shared pool.
Signing, replay protection and secret rotation
The receiver must be able to prove the request came from us and is fresh. Each endpoint has a random 32-byte secret. The worker computes HMAC-SHA256(secret, webhook_id + '.' + timestamp + '.' + raw_body) and sends it in a signature header along with the id and timestamp. This is the open Standard Webhooks scheme; Stripe's is similar (it signs timestamp + body). The receiver recomputes the HMAC over the raw bytes (not re-serialised JSON), compares in constant time, and rejects timestamps older than about 5 minutes, which stops captured requests being replayed later. Rotation: when the merchant rotates, keep the old secret valid for 24 hours and send two signatures in the header (v1 with the new secret, v1 with the old), so the receiver can deploy the new secret whenever it likes without failed deliveries. Secrets are stored encrypted with a KMS key and decrypted only inside workers. For higher-security customers, offer asymmetric signatures (Ed25519) so the receiver holds only a public key, and optionally mTLS. Never put secrets or full payloads in our logs.
SSRF: customers control the URL
A webhook service is a machine that makes HTTP requests to any URL a customer types, from inside our network. Without protection, a malicious merchant registers http://169.254.169.254/latest/meta-data/ and reads cloud credentials, or targets internal admin services. Defences: require HTTPS on port 443 (allow other ports only by review); resolve DNS ourselves, reject private, loopback, link-local and multicast ranges (10/8, 172.16/12, 192.168/16, 127/8, 169.254/16, IPv6 equivalents), then connect to that exact IP so a DNS rebinding answer cannot switch it after the check. Do not follow redirects (or re-validate every hop). Run delivery workers in a separate network segment whose egress goes through a proxy (for example Smokescreen, which Stripe built for this) with no route to internal services. Cap response body reads at a few KB and enforce total timeouts so an endpoint cannot hold a connection open by trickling bytes. These details are rarely mentioned by candidates and immediately signal real production experience.
At-least-once, ordering and what we promise
We cannot deliver exactly once. If the merchant's server processes the event and the connection drops before we read the 200, we will retry and they will see it twice. So the contract is at-least-once: every delivery carries a stable webhook-id (the same across retries and manual replays) and the documentation tells receivers to dedupe on it, for example with a unique constraint. Ordering is also not guaranteed: retries and parallel delivery mean refund.processed can arrive before payment.captured. Trying to enforce strict per-object ordering would mean one failed event blocks all later events for that merchant, which is worse. Instead each payload carries created timestamps and object versions, and we recommend receivers treat the webhook as a notification and fetch the current object from our API when order matters (the 'thin event' pattern). Internally we still get effectively-once fan-out: the delivery id is hash(event_id, endpoint_id), so if the fan-out consumer crashes and Kafka redelivers the event, the upsert creates no duplicate jobs.
8 Trade-offs
Fat payloads vs thin events
Fat events carry the full object, which is convenient but can be stale on arrival and leaks more data if the endpoint is compromised. Thin events carry only id and type; the receiver fetches the latest state from our API. Offer fat by default with object versions, and recommend fetch-on-receive for money flows.
Kafka for jobs vs a per-endpoint job queue
Kafka partitions cannot be created per endpoint (200k endpoints), and a slow message blocks its partition. So Kafka is used for the event stream and fan-out, while delivery jobs live in a store that supports many logical queues and delays: Redis streams or sorted sets, SQS with message groups, or a Postgres/Cassandra table polled with SKIP LOCKED-style leases.
Build vs buy
Managed services such as Svix or Hookdeck already solve signing, retries and dashboards. For a payments company webhooks are part of the product, so building gives control over isolation, cost and data residency (RBI localisation for Indian payment data). For an internal tool, buying is usually right.
Retry schedule length
Longer retries (Stripe retries for up to 3 days) recover from weekend outages but keep backlog and storage longer. Shorter schedules simplify operations but push recovery onto merchants. Three days plus manual replay and a reconciliation API is a good balance.
9 Bottlenecks
Retry storm after a large merchant recovers
Six hours of parked deliveries (say 2M) all become due. Drain at the endpoint's concurrency cap with ramp-up (start at 5 concurrent, double while success rate stays high), so we do not take their server down again.
Fan-out of hot event types
Platform-level events with thousands of subscribers (partners, plugins) multiply volume. Fan out in batches, write jobs in bulk, and cap per-merchant subscriptions.
Dashboard queries on the attempts log
300M rows/day. Store in a columnar store (ClickHouse) partitioned by day and ordered by endpoint_id, time, so per-endpoint queries scan a small slice.
10 Failure handling
Delivery workers crash mid-request
Jobs are leased with a visibility timeout of ~30 s. If a worker dies, the lease expires and another worker retries. The merchant might see a duplicate, which the stable webhook-id handles.
Redis (retry schedule) lost
The deliveries table is the source of truth with next_attempt_at; a sweeper rebuilds the Redis schedule from rows where status = pending. Retries are delayed by minutes, not lost.
Kafka or fan-out lag
Product services keep writing to their outbox; events wait in Kafka (7-day retention). First-attempt latency SLO is breached and alerts fire, but nothing is lost; fan-out scales out to catch up.
Our own regional outage
Events stay in the outbox and Kafka; on recovery, delivery resumes with backlog draining at controlled rates. Merchants can also call a list-events API to reconcile anything they missed.
11 What impresses
- Naming head-of-line blocking and solving it with per-endpoint queues and caps rather than more workers
- Raising SSRF unprompted, including DNS rebinding and an egress proxy
- Dual signatures during secret rotation so receivers never see failed verification
- Explaining why strict ordering is a bad promise and offering thin events instead
- Ramp-up when draining a backlog to a recovering endpoint
- Pointing at the Standard Webhooks spec and how Stripe signs requests
12 Mistakes
- Sending webhooks synchronously inside the payment request
- One shared queue and thread-per-request workers, so a single slow endpoint stalls all deliveries
- Claiming exactly-once delivery to the customer
- No signature or no timestamp check, allowing forged or replayed webhooks
- Following redirects and calling any URL, opening an SSRF hole
- Retrying 4xx errors like 410 Gone forever instead of treating some as permanent
13 Follow-ups
Lessons this design uses
Practice questions
- You are building webhooks so merchants get notified when a payment succeeds. Design the webhook contract from the merchant's point of view.
- Your payment platform sends webhooks to merchants when a payment succeeds. A merchant was tricked by a fake 'payment.captured' request and shipped goods. How should webhooks be secured on both sides?
Saved on this device. Create a free account to keep it on every device.
Practise this with Sam
A real mock interview, out loud, with an honest scorecard. Free to start.