Design incoming webhook processing and reconciliation
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
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
3 API
| Endpoint | What 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}/replay | Re-run processing for one event after a fix |
POST/admin/replay | Replay all events for a provider and time range, rate-limited |
POST/admin/reconcile | Trigger 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_errorUNIQUE (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)Postgrespayment_idproviderprovider_payment_id (unique)order_idamountstatusprovider_updated_atversionState machine column; updates are conditional on allowed transitions and on provider_updated_at being newer.
provider_secretsSecrets Manager / Vaultprovideraccountsecret versionsTwo active versions during rotation; cached in memory in the receiver.
recon_runsPostgresrun_idproviderwindow_startwindow_endcheckedmismatchesfixedstatusAudit of every reconciliation run and the drift metric source.
recon_cursorPostgresproviderlast_synced_atHigh-water mark so each run continues from where the last ended, with overlap.
archiveS3 (Parquet)raw events older than 7 daysCheap 180-day retention; queryable with Athena or Trino for disputes.
5 Architecture
Hover a box to highlight its connections.
6 Request flows
Payment captured webhook
- 1Razorpay POSTs payment.captured with an HMAC-SHA256 signature header
- 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
- 3Receiver INSERTs into webhook_events with ON CONFLICT (provider, event_id) DO NOTHING and returns 200; total time ~20-50 ms
- 4A processor claims due rows with SELECT ... FOR UPDATE SKIP LOCKED LIMIT 50
- 5For money events it fetches GET /payments/{id} from the provider to read current state instead of trusting payload order
- 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
- 7Order service consumes PaymentConfirmed and confirms the order; the customer sees 'Order placed'
Webhook never arrives
- 1Customer paid but the webhook was lost (provider incident or our 5xx during a deploy)
- 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
- 3Every 5 minutes the reconciler lists payments updated at the provider in the window [last_synced_at - 10 min, now]
- 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
- 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
- 1refund.processed arrives before refund.created due to retries
- 2Processor finds no local refund row; it fetches the refund from the provider API and creates it directly in status processed
- 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
Lessons this design uses
Practice questions
- Your app receives payment webhooks from Razorpay. Sometimes the same webhook arrives twice, and sometimes your handler is slow and the provider retries. How do you design the receiver?
- Your payment gateway sends a 'payment.captured' webhook to your server. How do you process it safely?
- Finance says that last month's ledger total does not match the money that actually arrived in the bank. How would you design reconciliation so mismatches are found daily, not monthly?
- You receive payment webhooks from a provider. You see the same event delivered several times, and sometimes 'payment.failed' arrives after 'payment.captured'. How do you handle this?
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.