System Design
Intermediate 13 min read Messaging & events

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

  1. 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.
  2. 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.
  3. 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.
  4. 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.
  5. 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.
  6. 6Receiver: read the raw body, verify the signature and timestamp before parsing, and reject anything older than about 5 minutes.
  7. 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.
  8. 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.
  9. 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

  1. 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.
  2. 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.
  3. 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.
  4. 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.
  5. A 10-minute deploy problem makes the endpoint return 502s. The gateway retries with backoff, and all events arrive within the next hour.
  6. 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 retrying

Key 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.

  1. 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.
  2. Set a 10-second timeout and at most 10 concurrent deliveries per endpoint.
  3. 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.
  4. Sign every delivery with per-endpoint secrets, include webhook-id and webhook-timestamp headers, and document that receivers must dedupe and tolerate reordering.
  5. Add SSRF protection: resolve and block private, loopback and metadata IPs at send time, not only at registration (DNS can change).
  6. 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?

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