Webhooks: sending and receiving reliably
In one line
A webhook is an HTTP callback that tells another system something happened; reliable webhooks need signed, at-least-once delivery with retries on the sending side, and fast acknowledgement, deduplication, out-of-order handling and reconciliation on the receiving side.
What it is
Instead of a client polling 'is my payment done yet?', the provider sends an HTTP POST to a URL the client registered when the event happens: payment.captured, refund.processed, shipment.delivered, a pull request opened. Payment gateways, shipping partners, code hosts, CRMs and messaging platforms all use webhooks.
Webhooks travel over the public internet to servers the sender does not control. Receivers go down, deploy, time out or return errors. So senders retry, which makes delivery at-least-once: duplicates are normal. Retries and parallel delivery also mean events can arrive out of order, for example refund.processed before payment.captured. Some events never arrive at all, after the sender gives up.
Sending webhooks well means a durable delivery pipeline: an event is stored, queued per endpoint, signed, sent with a timeout, retried with backoff for hours or days, and endpoints that keep failing are paused, with logs and manual replay for customers.
Receiving webhooks well means: verify the signature, store the raw event, return 2xx quickly, process asynchronously and idempotently by event id, never trust arrival order, and run periodic reconciliation against the provider's API, because webhooks are a notification, not the source of truth.
Why interviewers ask
Payment and commerce designs depend on webhooks: a Razorpay or Stripe payment is often confirmed by webhook, and a missed or duplicated one means unshipped orders or double fulfilment. Interviewers ask both sides: 'design an outgoing webhook service for our platform' and 'how do you handle the payment gateway's webhooks?' They listen for signatures, idempotency, retries with backoff, per-endpoint isolation, ordering and reconciliation.
How it works
- 1Sender: when a business event commits, write a delivery record per subscribed endpoint (via the outbox, so no event is lost). Each event has a stable id that stays the same across retries.
- 2Sender: sign each request with HMAC-SHA256 over timestamp + raw body using a per-endpoint secret, and send the event id, timestamp and signature in headers. Support secret rotation by signing with both old and new secrets for a while.
- 3Sender: deliver from per-endpoint queues with a short timeout (for example 5-10 s) and a concurrency limit per endpoint, so one slow customer server cannot block deliveries to everyone else.
- 4Sender: on non-2xx or timeout, retry with exponential backoff and jitter over hours or days (for example 1 min, 5 min, 30 min, 2 h ... up to 3 days). After repeated failures, mark the endpoint as failing, notify its owner and eventually disable it. Provide a dashboard with delivery logs and a 'resend' button.
- 5Sender: protect yourself. Customer-supplied URLs can point to internal addresses, so resolve DNS and block private IP ranges and cloud metadata endpoints (SSRF protection); send from fixed egress IPs that customers can allow-list.
- 6Receiver: read the raw body, verify the signature and timestamp before parsing, and reject anything older than about 5 minutes.
- 7Receiver: insert the event into a table with a unique (provider, event_id), return 200 within a second or two, and process asynchronously with workers. A duplicate insert is a no-op that still returns 200.
- 8Receiver: handle ordering by checking the object's status or version: ignore a payment.authorized arriving after payment.captured, or simply fetch the current object from the provider's API and act on that.
- 9Receiver: reconcile. A scheduled job lists recent objects from the provider's API (or settlement reports) and fixes any order whose state does not match, catching webhooks that never arrived.
Worked example · Handling payment gateway webhooks for an e-commerce site
- A sale produces 500 payments per second at peak. The gateway sends payment.captured for each, plus about 50 refund and failure events per second.
- The webhook endpoint verifies the HMAC signature (microseconds), inserts the raw payload into webhook_events (about 2 ms), and returns 200. Even at 550 requests per second a few small instances handle it, and the gateway never sees slow responses.
- Workers pick pending rows with SELECT ... FOR UPDATE SKIP LOCKED, mark the order paid with a conditional update (WHERE status = 'PAYMENT_PENDING'), and publish OrderPaid through the outbox.
- The gateway delivers about 1% of events twice during its own retries; the unique key drops them. Some refund events arrive before the capture event; the worker sees the order is not yet paid, fetches the payment from the gateway API, and applies the true current state.
- A 10-minute deploy problem makes the endpoint return 502s. The gateway retries with backoff, and all events arrive within the next hour.
- Every 15 minutes, a reconciler lists payments captured in the last 2 hours from the gateway API and compares them with orders. It finds 3 orders still pending whose webhooks never arrived, marks them paid, and records a drift metric for alerting.
Picture it
A webhook is like a courier delivering letters to your office. The courier may come twice with the same letter if nobody signed the first time, letters may arrive in the wrong order, and sometimes the courier gives up. A sensible office checks the seal (signature), stamps each letter's number in a register (dedupe), reads them in the right sequence, and calls the sender once a day to confirm nothing went missing (reconciliation).
Reference code
import hashlib, hmac, json, time
TOLERANCE_SECONDS = 300
def verify(raw_body: bytes, timestamp: str, signature_hex: str, secret: bytes) -> bool:
# 1. Reject old requests to limit replay attacks.
if abs(time.time() - int(timestamp)) > TOLERANCE_SECONDS:
return False
# 2. Sign the EXACT raw bytes received, never re-serialised JSON.
signed = timestamp.encode() + b"." + raw_body
expected = hmac.new(secret, signed, hashlib.sha256).hexdigest()
# 3. Constant-time compare to avoid timing attacks.
return hmac.compare_digest(expected, signature_hex)
def receive(request, db):
if not verify(request.body, request.headers["X-Timestamp"],
request.headers["X-Signature"], SECRET):
return 401
event = json.loads(request.body)
# Store and acknowledge fast; a worker processes it later.
db.execute(
"INSERT INTO webhook_events (provider, event_id, payload, status) "
"VALUES ('pg', %s, %s, 'pending') ON CONFLICT (provider, event_id) DO NOTHING",
(event["id"], request.body),
)
return 200 # duplicates also get 200, so the sender stops retryingKey patterns
Store, acknowledge, process later
When: Every webhook receiver
How: Verify signature, insert raw event with a unique event id, return 2xx in about a second; workers do the slow work.
Thin events plus fetch
When: Ordering or freshness matters
How: Treat the webhook as 'object X changed' and fetch the current object from the provider API; arrival order stops mattering.
Per-endpoint isolation
When: Sending to many third-party endpoints
How: Separate queues or concurrency limits per endpoint, timeouts, circuit breaking for failing endpoints.
Reconciliation job
When: Money, orders, inventory driven by webhooks
How: Periodically list recent objects from the provider and compare with local state; fix drift and alert on it.
Signed, timestamped requests
When: Always
How: HMAC-SHA256 over timestamp + raw body, constant-time compare, reject stale timestamps, rotate secrets with overlap.
Real-world use
Stripe
Signs webhooks with a Stripe-Signature header containing a timestamp and HMAC-SHA256 signatures, recommends a timestamp tolerance to prevent replays, retries failed deliveries in live mode for up to three days with exponential backoff, and does not guarantee event order.
Razorpay
Sends an X-Razorpay-Signature header (HMAC-SHA256 of the raw body with the webhook secret), retries failed deliveries with backoff, and documents that events can be delivered more than once, so merchants should deduplicate by event id.
GitHub
Signs payloads with X-Hub-Signature-256 and includes a unique delivery id header. It expects a 2xx response within 10 seconds, and failed deliveries can be redelivered from the UI or API.
Standard Webhooks and Svix
The Standard Webhooks specification defines common headers (webhook-id, webhook-timestamp, webhook-signature) and signing rules; services like Svix and Hookdeck offer sending and receiving infrastructure with retries, logs and replay.
UPI and payment aggregators
Payment status in UPI flows can stay pending for a while; merchants combine the aggregator's callback with status-check APIs and daily settlement reconciliation, because a callback alone is not a guarantee.
In a project
You run a SaaS invoicing platform that sends webhooks to 5,000 customer endpoints. One large customer's server started responding in 30 seconds; your single shared delivery queue backed up and all customers received webhooks hours late.
- Split delivery into per-endpoint queues (or a shared queue with per-endpoint concurrency limits and fair scheduling), so one slow endpoint only delays itself.
- Set a 10-second timeout and at most 10 concurrent deliveries per endpoint.
- Retry with backoff and jitter over 3 days; after 24 hours of continuous failure, email the customer and mark the endpoint degraded; disable after 3 days, with one-click re-enable and replay.
- Sign every delivery with per-endpoint secrets, include webhook-id and webhook-timestamp headers, and document that receivers must dedupe and tolerate reordering.
- Add SSRF protection: resolve and block private, loopback and metadata IPs at send time, not only at registration (DNS can change).
- Give customers a delivery log with request, response code, latency and a resend button, and an API to list events for their own reconciliation.
Impact: A slow customer endpoint no longer affects others, p99 delivery delay for healthy endpoints stays under 5 seconds, support tickets about 'missing webhooks' drop because customers can see and replay deliveries themselves.
Trade-offs
Use it when
- Notifying external systems about events in near real time without polling
- Payment, shipping and partner integrations that complete asynchronously
- Platform products where customers build automations on your events
Avoid it when
- Internal service-to-service events inside your own system; use a message broker
- The receiver needs a guaranteed complete, ordered history; offer an events API or a stream they can page through, with webhooks only as a hint
- Doing slow work (LLM calls, emails, heavy DB writes) inside the webhook request
- Relying on webhooks alone for money movement without reconciliation
Common mistakes
- Parsing and re-serialising JSON before verifying the signature, so valid signatures fail (or worse, skipping verification).
- Doing slow processing before returning 200, causing provider timeouts and more retries and duplicates.
- Assuming each event arrives once and in order.
- Assuming every event arrives; no reconciliation job, so missed webhooks mean orders stuck forever.
- Sender: one shared queue for all endpoints, so one slow customer delays everyone.
- Sender: no SSRF protection, letting customers make your servers call internal addresses.
- Returning 500 for a duplicate event, which makes the sender retry it again.
Interview questions
Quick revision
- Webhooks are at-least-once, unordered, and occasionally missing.
- Receiver: verify on raw body, store, return 2xx fast, process async.
- Dedupe on (provider, event_id) with a unique key.
- Ordering: check status/version or fetch current state from the API.
- Reconcile periodically: the provider API is the source of truth.
- Sender: outbox, per-endpoint queues, timeout, backoff over days, then disable.
- Sign with HMAC-SHA256 over timestamp + body; reject stale timestamps.
- Sender must block SSRF to private and metadata IPs.
- Give customers delivery logs and replay.
Check yourself
1. Why should a webhook receiver return 200 quickly and process the event later?
2. The signature check fails for every valid webhook after a code change. What is the most likely cause?
3. Which mechanism catches payment webhooks that never arrived?
4. An outgoing webhook service lets customers register any URL. What security risk must it handle?
See it in a full design
These walkthroughs use this lesson.
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.