Design problems
Platform & infrastructureMediumSDE-2 30 min

Design an outgoing webhook delivery service

Asked at
FreshworksPaytmPhonePeRazorpayStripeZoho

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

Transactional outbox for publishing eventsFan-out to one delivery job per (event, endpoint)Per-endpoint queues and concurrency caps (bulkhead)Exponential backoff with jitter and a long retry windowCircuit breaker and auto-disable for dead endpointsHMAC signatures with timestamp and secret rotationSSRF protection for customer-supplied URLsAt-least-once delivery with stable webhook id for dedupNo ordering guarantee; thin events or object versionsDelivery log and manual replay for merchants

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

Events~1,200/s average, ~10k/s peak100M/day ÷ 86,400 ≈ 1,160/s; festive sales and month-end settlements push ~8× higher
Delivery attempts~3,500/s average, ~25k/s peak1.5 endpoints per event on average plus ~15% of deliveries needing retries
Concurrent outbound requests~5,000 in flight at peakLittle's Law: 25k/s × 0.2 s median latency = 5k; slow endpoints at 10 s timeout add more, which is why concurrency is capped per endpoint
Payload storage~200 GB/day100M events × ~2 KB body; kept 30 days for replay ≈ 6 TB in S3 / object store
Attempt log~90 GB/day300M attempts × ~300 bytes (status, latency, truncated response); 30-day retention in Cassandra or ClickHouse
Worker fleet~50-100 podsAsync HTTP client per pod handles ~1-2k concurrent requests; size for 5k-10k in flight with headroom

3 API

EndpointWhat it does
POST/v1/webhook_endpointsRegister {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_secretCreate 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}/retryManually replay one delivery now
POST/v1/webhook_endpoints/{id}/replayReplay 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

endpointsPostgres
endpoint_id (PK)merchant_idurlevent_types[]secrets (encrypted, current + previous)status (enabled|disabled|auto_disabled)max_concurrencycreated_at

Small (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 + S3
event_idmerchant_idtypecreated_atpayload

Kafka topic keyed by merchant_id for fan-out; payloads also written to S3 by event_id for 30-day replay.

deliveriesCassandra / DynamoDB
delivery_id (PK) = hash(event_id, endpoint_id)endpoint_idevent_idstatusattemptsnext_attempt_atlast_status_code

Deterministic id makes fan-out idempotent. Partition by endpoint_id + day bucket for the dashboard query.

attemptsClickHouse
delivery_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 queue
score = next_attempt_atmember = delivery_id

Due 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 healthRedis
endpoint_id → success rate (5 min)consecutive failurescircuit statein-flight count

Drives concurrency caps, circuit breaking and auto-disable.

5 Architecture

CLIENTSEDGESERVICESASYNCDATAEXTERNALoutbox relayconfig, replaysubscriptions (cached)store payload1 job per endpointsigned POSThealth, in-flightattempt resultdue retriesre-enqueuedelivery logProduct servicesMerchant dashboardWebhook config APIFan-out serviceRetry schedulerKafka: domain eventsPer-endpoint jobqueuesDelivery workersS3 (payloads)Postgres (endpoints)Deliveries + attemptsDBRedis (health,retries)Merchant endpoints
ClientEdge / gatewayService / workerQueue / streamCacheDatabase / storageAI / modelThird partyrequestasync (event / queue)

Hover a box to highlight its connections.

6 Request flows

payment.captured reaches the merchant

  1. 1Payments service commits the payment row and an outbox row in one Postgres transaction
  2. 2Outbox relay (or Debezium) publishes the event to Kafka topic domain-events, key = merchant_id
  3. 3Fan-out service looks up the merchant's enabled endpoints subscribed to payment.captured (in-memory cache)
  4. 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
  5. 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
  6. 6Worker POSTs with a 3 s connect and 10 s total timeout and no redirects; a 2xx marks the delivery succeeded
  7. 7Attempt (status code, latency, first 1 KB of response) is appended to the attempts log for the dashboard

Endpoint is down for six hours

  1. 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
  2. 2After 20 consecutive failures the endpoint's circuit opens: new jobs are parked instead of each one hitting the dead server
  3. 3A probe request every few minutes tests the endpoint; when it returns 2xx the circuit closes
  4. 4Parked jobs drain at the endpoint's concurrency cap (not all at once) so the recovering server is not flattened
  5. 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

  1. 1Merchant selects 'replay failed events from 10:00-14:00' in the dashboard
  2. 2API queries deliveries for that endpoint and time range with status failed
  3. 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

Q1How would you let a merchant receive events in strict order for one order_id if they insist?
Q2How do you size retries so a 3-day outage at a big merchant does not fill your storage?
Q3How would you support delivering to a customer's SQS queue or Kafka topic instead of HTTPS?
Q4How do you detect that an endpoint is 'slow but alive' and move it to a separate pool?
Q5What metrics and alerts would you put on this system?
Q6How would you handle data residency if some merchants require Indian-region processing?

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.

Start free