Design problems
Platform & infrastructureMediumSDE-2 30 min

Design incoming webhook processing and reconciliation

Asked at
FlipkartPhonePeRazorpayStripeSwiggyZomato

The interviewer asks

“Your food-delivery or e-commerce company receives webhooks from payment gateways (Razorpay, PayU, Stripe), logistics partners and a CRM. They arrive late, twice, out of order or not at all, and orders are confirmed or refunded based on them. Design the system that receives, processes and reconciles them so your records always end up matching the provider's.”

What a strong answer looks like

A strong answer is built on one idea: webhooks are hints, the provider's API is the truth. The receiver verifies the HMAC signature over the raw body, stores the raw event with a unique (provider, event_id) constraint and returns 200 within a few hundred milliseconds; nothing slow happens in the request. Workers process stored events asynchronously with SELECT ... FOR UPDATE SKIP LOCKED (or a queue), apply them idempotently through a state machine that ignores stale or backwards transitions, and retry failures with backoff and jitter into a dead-letter state. A reconciler runs every few minutes, lists objects that changed at the provider and compares them with local state, fixing drift and emitting a drift metric; a daily settlement-file match closes the loop for money. Replay tooling and per-provider dashboards make it operable.

Concepts to mention

Verify HMAC over the raw body, constant-time compareAck fast with a durable insert, process asynchronouslyUnique (provider, event_id) for dedupIdempotent state-machine transitions with conditional updatesDo not trust arrival order; fetch current state from the providerBackoff with jitter, max attempts, dead-letter stateScheduled reconciliation with high-water mark and overlapDrift metric and alertingReplay tooling from stored raw payloadsOutbox for downstream events

1 Requirements

Functional

  • Receive webhooks from many providers at /webhooks/{provider}, each with its own signature scheme
  • Verify authenticity and freshness before trusting anything in the payload
  • Apply each event to local state (payment captured → confirm order; refund processed → update refund) exactly once in effect
  • Handle duplicates, out-of-order arrival and events for objects we have not created yet
  • Reconcile local state against provider APIs on a schedule and fix drift automatically
  • Operator tools: search events, inspect failures, replay one event or a time range

Non-functional

  • Ack within 1 s at p99 so providers do not time out and retry
  • Peak ingest 5k webhooks/s during sales (Big Billion Days, IPL final night orders)
  • Zero lost state changes: any missed webhook detected and repaired within 15 minutes
  • Processing latency p95 under 5 s from receipt to order confirmed
  • Raw events retained 180 days for audit and disputes; payment data stored in India (RBI localisation)

Out of scope (say it out loud)

  • Sending webhooks to our own customers (separate design)
  • The payment gateway integration for creating payments
  • Accounting ledger design

2 Estimates

Webhook ingest~600/s average, ~5k/s peak~15M orders/day × ~3 webhooks each (payment, settlement, delivery status) ≈ 45M/day ÷ 86,400 ≈ 520/s; sales and dinner peak ~8-10×
Duplicates~1-5% of deliveriesProviders retry on any timeout or non-2xx; a slow deploy on our side can spike this to 20%+
Raw event storage~90 GB/day, ~16 TB for 180 days45M × ~2 KB; hot 7 days in Postgres, older partitions archived to S3 as Parquet
Reconciliation API calls~20-50 req/s per providerList objects updated in the last 30 min every 5 min, paginated at 100 per page; must stay under provider rate limits
Ingest DB writes~5k inserts/s at peakOne insert per webhook; a single well-tuned Postgres primary handles this, partitioned by day

3 API

EndpointWhat it does
POST/webhooks/{provider}Receive raw webhook; verify signature; insert; return 200 (or 400 if signature invalid)
GET/admin/webhook-events?provider=&status=dead&from=&to=Search and inspect stored events
POST/admin/webhook-events/{id}/replayRe-run processing for one event after a fix
POST/admin/replayReplay all events for a provider and time range, rate-limited
POST/admin/reconcileTrigger reconciliation for a provider, window or single object id
GETprovider API (outbound)e.g. GET /payments?from=&to= or GET /payments/{id}: fetch the truth during processing and reconciliation

4 Data model

webhook_eventsPostgres (partitioned by day)
idproviderevent_idevent_typeobject_idreceived_atsignature_okpayload jsonbstatus (pending|done|failed|dead)attemptsnext_attempt_atlast_error

UNIQUE (provider, event_id) gives dedup at insert. Partial index on (next_attempt_at) WHERE status IN ('pending','failed') for the worker poll.

payments (local view)Postgres
payment_idproviderprovider_payment_id (unique)order_idamountstatusprovider_updated_atversion

State machine column; updates are conditional on allowed transitions and on provider_updated_at being newer.

provider_secretsSecrets Manager / Vault
provideraccountsecret versions

Two active versions during rotation; cached in memory in the receiver.

recon_runsPostgres
run_idproviderwindow_startwindow_endcheckedmismatchesfixedstatus

Audit of every reconciliation run and the drift metric source.

recon_cursorPostgres
providerlast_synced_at

High-water mark so each run continues from where the last ended, with overlap.

archiveS3 (Parquet)
raw events older than 7 days

Cheap 180-day retention; queryable with Athena or Trino for disputes.

5 Architecture

CLIENTSEDGESERVICESASYNCDATAEXTERNALPOST webhookverify, insert rawSKIP LOCKED pollfetch truthidempotent transitionPaymentConfirmedafter 8 attemptslist changed objectscompare, fix driftreplayarchive >7 daysRazorpay / PayU /couriersLB + WAFOrder / refund serviceAdmin + replay toolWebhook receiverReconciler (5 min)Event processorsDead events + alertsPostgres:payments/ordersPostgres:webhook_eventsS3 archiveProvider REST APIs
ClientEdge / gatewayService / workerQueue / streamCacheDatabase / storageAI / modelThird partyrequestasync (event / queue)

Hover a box to highlight its connections.

6 Request flows

Payment captured webhook

  1. 1Razorpay POSTs payment.captured with an HMAC-SHA256 signature header
  2. 2Receiver reads the raw body bytes, computes HMAC with the current (and previous) secret, compares in constant time; on mismatch returns 400 and increments a metric
  3. 3Receiver INSERTs into webhook_events with ON CONFLICT (provider, event_id) DO NOTHING and returns 200; total time ~20-50 ms
  4. 4A processor claims due rows with SELECT ... FOR UPDATE SKIP LOCKED LIMIT 50
  5. 5For money events it fetches GET /payments/{id} from the provider to read current state instead of trusting payload order
  6. 6In one transaction: conditional UPDATE payments SET status='captured' WHERE id=? AND status IN ('created','authorized'), write an outbox row PaymentConfirmed, mark the event done
  7. 7Order service consumes PaymentConfirmed and confirms the order; the customer sees 'Order placed'

Webhook never arrives

  1. 1Customer paid but the webhook was lost (provider incident or our 5xx during a deploy)
  2. 2The order sits in payment_pending; the checkout page also polls our API, which after 30 s triggers an on-demand status check against the provider
  3. 3Every 5 minutes the reconciler lists payments updated at the provider in the window [last_synced_at - 10 min, now]
  4. 4It finds the captured payment that is still pending locally, applies the same transition code path as the webhook processor, and records a drift fix
  5. 5Drift metric rises above zero for that provider; a sustained rise pages on-call because it means webhooks are failing

Out-of-order refund events

  1. 1refund.processed arrives before refund.created due to retries
  2. 2Processor finds no local refund row; it fetches the refund from the provider API and creates it directly in status processed
  3. 3The late refund.created event then tries a backwards transition; the state machine treats it as a no-op and marks the event done

7 Deep dives

Ack fast, process later

Providers time out quickly (often 5-10 s) and retry on anything that is not a 2xx. If the handler confirms the order, calls the inventory service, sends an email and maybe runs an LLM fraud check inside the request, a slow dependency turns into provider timeouts, which turn into retries, which multiply load at exactly the wrong time. So the request does only three things: verify the signature over the raw body, insert the raw event, return 200. The insert is the durability point: once it commits we own the event. Everything else is async. Storing the raw payload (not just the parsed fields) is important: when a bug in processing is found, we fix the code and replay the stored events, which is impossible if we only kept what the old code extracted. The worker side uses Postgres as a queue with SELECT ... FOR UPDATE SKIP LOCKED, which is simple and transactional at a few thousand events per second; above that, put Kafka between receiver and workers, keyed by object_id so events for the same payment are processed in sequence. Return 400 only for invalid signatures; never return 5xx for 'business' failures, or the provider will retry forever.

Idempotency and ordering with a state machine

Dedup by (provider, event_id) catches exact redelivery, but not everything: some providers send different event ids for the same state change, and events arrive out of order. The real protection is making the effect idempotent. Model each object as a state machine (created → authorized → captured → refunded) and apply changes with conditional updates: UPDATE ... SET status='captured' WHERE id=? AND status IN ('created','authorized'). Zero rows updated means the change was already applied or is stale, so it is a no-op. Where providers send an updated_at or version, also require it to be newer than the stored one. For important money events, do not trust the payload at all: fetch the object from the provider API and apply its current state. This 'webhook as a doorbell' approach makes order irrelevant, at the cost of an extra API call per event, which is fine at hundreds per second but must respect provider rate limits. Write the downstream event (PaymentConfirmed) through an outbox in the same transaction, so the state change and its announcement cannot diverge.

Reconciliation: the safety net that makes it correct

Webhooks are never complete. Providers have incidents, our endpoint can return 5xx during a bad deploy until the provider gives up, and some providers do not retry at all. Reconciliation turns 'usually correct' into 'eventually always correct'. Run a job every 5 minutes per provider (a cron, or a Temporal schedule for durable progress). It keeps a high-water mark, lists objects updated at the provider in [mark - overlap, now], and compares each with local state; any difference is fixed through the same transition code the webhook path uses, so there is one place that changes state. Overlap of ~10 minutes handles clock skew and provider indexing delay. Track drift (mismatches found per run) as a metric: it should be near zero; a rise means the webhook path is broken even if no error is logged. Add a daily pass that matches the provider's settlement report (amount, fees, UTR for Indian bank transfers) against captured payments, which catches money discrepancies the APIs do not show. Reconciliation must be rate-limited and paginated so it never competes with live payment calls on the provider's rate limit.

Verification details people get wrong

Compute the HMAC over the exact raw bytes received, before any JSON parsing; frameworks that parse and re-serialise change whitespace or key order and the signature will not match, which tempts developers to disable verification. Compare with a constant-time function. Check the timestamp header where the provider sends one and reject events older than ~5 minutes to prevent replays. Support two secrets during rotation. Keep per-provider verifier code behind one interface, since each provider differs (header names, encoding, some sign only the body). Do not log full payloads at info level: they contain names, phone numbers and card metadata, so apply PII redaction in the logging middleware. If a provider publishes source IP ranges, an allow-list at the WAF is a useful extra layer, but never a replacement for signatures.

8 Trade-offs

Postgres as the queue vs Kafka

Postgres with SKIP LOCKED keeps the raw event, status and retries in one transactional table, easy to query and replay; it is enough up to a few thousand events per second. Kafka scales further and preserves per-key order but needs separate retry topics and a DLQ topic. Start with Postgres; move when ingest or worker contention demands it.

Trust the payload vs fetch from the API

Using the payload is fast and avoids provider rate limits. Fetching makes order and staleness irrelevant. Fetch for money-moving events, use the payload for low-risk status updates like courier location.

Reconcile frequency

Every 5 minutes bounds the damage of a missed webhook to minutes but costs API quota. Hourly is cheaper but a customer waits an hour for an order confirmation. Pair a frequent narrow reconciliation with on-demand checks triggered by user-facing pending states.

9 Bottlenecks

Hot rows during retries

A provider retry storm can produce thousands of events for the same few objects. Key processing by object_id (Kafka partition key, or advisory lock) so one object is handled by one worker at a time and conditional updates do not contend.

Provider rate limits

Fetch-on-receive plus reconciliation can exceed the provider's API quota during sales. Use a shared token bucket per provider account, prioritise live-processing fetches over reconciliation, and back off reconciliation when quota is tight.

Table growth

45M rows/day. Daily partitions, drop or archive partitions older than 7 days to S3, keep only status indexes on recent partitions.

10 Failure handling

Our receiver is down for 20 minutes

Providers retry with backoff, so most events arrive later. Whatever they gave up on is found by the reconciler on its next run. Users see delayed confirmations, not lost orders.

A processing bug marks events dead

Raw payloads are stored, so after the fix an operator replays dead events for the affected window; idempotent transitions make replaying already-processed events safe.

Provider API down during processing

Fetch fails, the event is retried with exponential backoff and jitter (1 s, 2 s, 4 s ... capped at 10 min, 8 attempts). If the provider stays down, events go to dead and are replayed or reconciled once it recovers.

Signature secret rotated by the provider without notice

Signature failures spike; alert immediately on the 400 rate per provider. Events are not lost because the provider retries, and reconciliation covers anything beyond its retry window.

11 What impresses

  • Saying 'webhooks are hints, the API is the truth' and designing reconciliation as a first-class component
  • Treating drift as a monitored metric, not a silent fix
  • Using one transition code path for webhooks, reconciliation and replay
  • Knowing the raw-body signature trap
  • Prioritising live fetches over reconciliation on a shared provider rate limit
  • Adding the daily settlement-file match for money

12 Mistakes

  • Doing slow work inside the webhook request and causing provider retries
  • Assuming webhooks arrive once, in order, and always
  • Parsing JSON before verifying the signature
  • Returning 5xx for business rule failures so the provider retries forever
  • No reconciliation, so a lost webhook means a paid order that never confirms
  • Storing only parsed fields, making replay after a bug impossible

13 Follow-ups

Q1How would you handle a provider that does not offer a 'list updated objects' API?
Q2A customer was charged but the order shows failed. Walk me through how your system finds and fixes it.
Q3How would you scale to 50k webhooks/s?
Q4How do you test this? How would you simulate duplicates, reordering and drops?
Q5How do you decide when to auto-refund a payment for an order that could not be fulfilled?
Q6How would you use Temporal here, and what would it replace?

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