The server calling the client, over HTTP. It inverts the usual direction — and inherits every unreliable-network problem, now from the receiving end.
Prerequisites: Idempotency, Client–Server Model Time to read: ~18 minutes
A customer’s payment completes on Stripe. Your system needs to know so it can fulfil the order. How does Stripe tell you?
Polling: your server asks “any updates?” every few seconds. ❌ Wasteful (most responses are empty), high latency (up to the poll interval), and it doesn’t scale — a million integrations each polling is a lot of pointless traffic. → Realtime Communication
Webhooks: Stripe calls your URL when the event happens. ✅ Real-time, efficient — the server pushes only when there’s news.
🚨 A webhook is just an HTTP POST from a provider to a URL you registered. It’s the server-to-server
version of the server-push problem:
you register https://myapp.com/webhooks/stripe, and Stripe POSTs event data there when something
happens.
sequenceDiagram
participant U as User
participant S as Provider (Stripe)
participant M as Your server
U->>S: pays
Note over S: payment.succeeded event
S->>M: POST /webhooks/stripe {event data}
M->>M: verify signature, dedupe, enqueue
M-->>S: 200 OK (fast!)
Note over M: process async: fulfil order
🚨 The critical design rule: acknowledge fast, process async.
def handle_webhook(request):
verify_signature(request) # reject forgeries
event = parse(request.body)
if already_processed(event.id): # dedupe
return 200
queue.publish(event) # enqueue, don't process inline
return 200 # ACK immediately
# The actual work happens in a worker, off the request path.
Why acknowledge before processing: providers have short timeouts (often 5–30 seconds) and retry on any non-2xx or timeout. If you fulfil the order inline — a slow, multi-step operation — and it takes 40 seconds, the provider times out, marks it failed, and retries — even though you succeeded. Now you’ve processed it twice. Verify, enqueue, ACK in milliseconds; do the real work in a worker.
Webhooks inherit every property of the unreliable network, now as the receiver. Three problems dominate, and interviewers probe all three.
Providers use at-least-once delivery and retry aggressively — on timeouts, on non-2xx, and sometimes just because. You will receive the same event multiple times.
This is the exactly-once-is-impossible problem from the receiving side: the provider can’t tell “your server crashed before processing” from “your server processed it but the ACK was lost,” so it redelivers.
The fix: idempotent processing. Every event has a unique ID; record processed IDs and skip repeats:
with db.transaction():
if db.exists("processed_webhooks", event.id):
return # already handled
fulfil_order(event)
db.insert("processed_webhooks", event.id) # atomic with the side effect
🚨 A webhook handler without deduplication is a bug waiting to fire — double-fulfilled orders, duplicate emails, double-counted revenue. Every serious provider’s docs say explicitly “you will receive duplicates; make your handler idempotent.” → Idempotency ⭐
Your webhook endpoint is a public URL. Anyone can send a fake “payment succeeded” event and trick you into fulfilling an unpaid order. This is a genuine, exploited attack.
The fix: signature verification. The provider signs each payload with a shared secret; you verify the signature before trusting anything:
expected = hmac_sha256(webhook_secret, request.body)
if not constant_time_compare(expected, request.headers["Signature"]):
return 401 # forged — reject
🚨 Two details that matter:
== leaks timing information an attacker can exploit.Never trust a webhook payload’s contents over the provider’s API. 🚨 A stronger pattern for high-value events: the webhook tells you that something happened; you then call the provider’s API to fetch the authoritative state, rather than acting on the webhook’s data directly. This defends against both forgery and stale/out-of-order events.
Events can arrive out of order. A subscription.updated may arrive before the
subscription.created it depends on, because they took different network paths or were retried at
different times.
Fixes:
if event.version <= last_processed_version: skip.If you’re building the webhook system, you face the mirror-image problems, and they’re a real design question:
Retries with backoff. The receiver might be down. Retry with exponential backoff and jitter (e.g. after 1 min, 5 min, 30 min, 2 hr, up to 24–72 hr), then give up and mark the endpoint failed. → Retries
A durable queue. Never deliver inline from your main flow (that’s the dual-write problem). Emit events to a queue; a delivery worker POSTs them. This decouples your core system from slow or dead receivers.
Timeouts and circuit breakers. A receiver taking 30 seconds shouldn’t tie up delivery workers. Short timeouts, and stop trying a persistently-failing endpoint. → Resilience Patterns
A dead-letter queue and a dashboard. After max retries, park the event and let the customer see failed deliveries and replay them. 🚨 This is essential — receivers will have outages, and “your webhooks were silently lost during your downtime” destroys trust.
Delivery logs and manual replay. Let customers see every attempt and re-trigger delivery. Stripe’s webhook dashboard is the reference.
SSRF protection. 🚨 The receiver URL is customer-controlled, so validate it — block internal IPs
(10.x, 169.254.169.254 the cloud metadata endpoint, localhost), or an attacker registers a
webhook pointing at your own internal services and uses your delivery worker to reach them. This is a
real, serious vulnerability. → OWASP
The receiver-behind-a-firewall problem. Webhooks require the receiver to have a public HTTPS
endpoint. For local development, tunnels (ngrok, Stripe CLI’s listen) expose your local server.
Corporate receivers behind firewalls can’t receive webhooks at all — which is a real limitation and
why some providers also offer polling.
Event filtering. Let receivers subscribe to specific event types rather than firing everything.
Verification handshakes. Some providers require you to prove you own the URL (echo back a challenge) before they’ll send events.
Versioning. Webhook payloads are an API and evolve like one. → Versioning
| Approach | Latency | Efficiency | Receiver needs | Reliability |
|---|---|---|---|---|
| Polling | Up to interval | ❌ Wasteful | Nothing special | Simple, no lost events |
| Webhooks | Real-time | ✅ | Public HTTPS endpoint | Duplicates, ordering, delivery to handle |
| WebSockets/SSE | Real-time | ✅ | Persistent connection | Stateful connections → Realtime |
| Message queue | Real-time | ✅ | Queue access | Best for internal systems → Queues |
🎙️ “Webhooks for notifying third parties of events over HTTP — real-time and efficient. I’d acknowledge fast and process async, verify signatures, dedupe on the event ID because delivery is at-least-once, and not assume ordering. If a partner can’t receive webhooks — behind a firewall — I’d offer polling as a fallback.”
For internal event delivery, prefer a message queue — you get durability, ordering, and replay without the HTTP-delivery complexity. Webhooks are specifically for reaching external parties you can’t put a queue between.
| Choice | Gain | Cost |
|---|---|---|
| Webhooks over polling | Real-time, efficient | Duplicates, ordering, delivery reliability to handle |
| ACK fast, process async | Avoids timeout-induced duplicates | Need a queue and workers |
| Signature verification | Rejects forgeries | Secret management; raw-body signing |
| Fetch-state-from-API pattern | Defends against forgery and stale data | An extra API call per event |
| Provider-side DLQ + replay | Receivers survive their own outages | More to build and operate |
169.254.169.254 (cloud metadata) and the provider’s delivery worker dutifully fetches
cloud credentials. Providers must validate destination URLs.1. Build a webhook receiver, then break it. Accept a POST, and process it inline with a 40-second delay. Point Stripe’s test webhooks (or a script) at it with a 10-second timeout. Watch it get retried and processed twice. Then switch to ACK-fast-process-async and watch the duplicates stop.
2. Forge an event. Send a fake “payment succeeded” POST to your own endpoint with no signature check. Watch it get accepted. Then add HMAC verification and watch the forgery get rejected. This is the security lesson, in five minutes.
3. Dedupe. Send the same event ID three times. Without dedup, watch the order get fulfilled three times. Add an idempotency check keyed on the event ID and watch attempts 2 and 3 no-op.
4. Use a tunnel. Run your webhook receiver locally and expose it with ngrok or stripe listen.
Trigger a real test event and watch it arrive. This is how you actually develop webhooks locally,
and it makes the “receiver needs a public endpoint” constraint concrete.