Webhooks
A webhook is a way for one system to notify another that something happened, by making an HTTP request to a URL you provide. Instead of your app repeatedly asking a service "any updates yet?" (polling), the service calls you the moment an event occurs — "a payment succeeded," "a pull request was opened," "an email bounced." Webhooks are how the integrated web talks to itself: Stripe, GitHub, Slack, Shopify, and virtually every SaaS platform uses them to push events to your backend in real time.
They're simple in concept — "reverse API calls" — but building them reliably involves genuine subtleties: verifying the request really came from the sender, tolerating duplicate and out-of-order deliveries, responding fast enough not to time out, and handling the retries that failures trigger. Getting these right is the difference between a robust integration and one that silently double-charges customers or misses events.
TL;DR
- A webhook is an HTTP POST the sender makes to your URL when an event occurs — push, not pull. It's event-driven integration over plain HTTP.
- Verify every webhook — check the signature (HMAC of the payload with a shared secret) to confirm it's genuinely from the sender, not a forged request to your public URL.
- Assume duplicates: senders retry on failure and networks hiccup, so the same event can arrive more than once — process idempotently (dedupe by event ID).
- Respond fast (2xx quickly), process async: acknowledge receipt immediately and do the real work in a background job, or the sender times out and retries.
- Delivery is at-least-once, not guaranteed-once or ordered — design for retries, out-of-order arrival, and occasional missed events (with a reconciliation fallback).
- Sending webhooks well means signing payloads, retrying with backoff, and giving consumers tools (logs, replay, a secret).
How Webhooks Work
The flow when you receive a webhook:
- You register a URL with the sender (
https://api.yourapp.com/webhooks/stripe). - An event occurs on their side (a payment succeeds).
- They POST a JSON payload (plus a signature header) to your URL.
- You verify the signature, return 2xx fast, and process the event.
- If you don't return 2xx (error, timeout), they retry later.
Quick Example
Receiving and verifying a webhook (Node/Express — the signature check is non-negotiable):
Three things that make it correct: verify the signature (authenticity), dedupe by event ID (idempotency), enqueue and return 2xx immediately (don't time out).
Core Concepts (Receiving)
Signature Verification
Your webhook URL is public — anyone who discovers it can POST forged events. The defense: senders sign each payload with an HMAC using a shared secret, and you recompute and compare. A matching signature proves the payload came from someone holding the secret (the sender) and wasn't tampered with. Critical details:
- Verify against the raw request body — parsing and re-serializing JSON changes the bytes and invalidates the signature (the #1 webhook bug).
- Use constant-time comparison (
timingSafeEqual) to avoid timing attacks. - Check the timestamp (most signing schemes include one) to reject replayed old requests.
Unverified webhooks are an open door — this is the API security fundamental for the pattern.
Idempotency
Webhook delivery is at-least-once: senders retry on any non-2xx or timeout, and networks duplicate requests, so the same event will eventually arrive twice. If processing isn't idempotent, you double-charge, double-ship, or double-email. Every event carries a unique ID — record processed IDs and skip repeats:
Respond Fast, Process Async
Senders enforce a short timeout (often a few seconds). If your handler does slow work (database writes, third-party calls) inline, you risk timing out — the sender marks it failed and retries, causing duplicates and load. The pattern: verify → enqueue → return 2xx immediately, and do the actual processing in a background job. Your endpoint's only synchronous job is to authenticate and accept.
Handle Retries and Missed Events
Because delivery isn't guaranteed exactly-once or in order:
- Retries: expect the same event multiple times (idempotency handles this).
- Out-of-order: a
payment.refundedmight arrive beforepayment.succeeded— don't assume ordering; use timestamps/state, not arrival order. - Missed events: webhooks can be lost entirely (your endpoint was down past the retry window). For critical data, add a reconciliation fallback — periodically poll or re-fetch state to catch anything missed. Webhooks are an optimization over polling, not always a total replacement for it.
Sending Webhooks
If you're the one emitting webhooks (building a platform), you owe consumers reliability and tooling:
- Sign every payload (HMAC with a per-consumer secret) and document the verification scheme.
- Retry with exponential backoff on non-2xx, over a generous window (minutes to hours to days), then give up and surface the failure.
- Deliver asynchronously from a queue — never block your event-producing code on HTTP calls to consumers' (possibly slow or down) endpoints.
- Provide a dashboard: delivery logs, response codes, and a manual replay button so consumers can recover from outages.
- Include event IDs and types, a stable payload schema, and versioning — consumers depend on the shape.
- Guard against SSRF and abuse: consumers control the destination URL, so validate it and treat outbound requests carefully.
This is real infrastructure; many teams use webhook-delivery services (Svix, Hookdeck) rather than building retry/signing/replay from scratch.
Common Mistakes
Not Verifying Signatures
Trusting any POST to your webhook URL lets attackers forge events — fake "payment succeeded" to unlock paid features, fake "user deleted" to cause havoc. Always verify the HMAC signature against the raw body. This is the most common and most dangerous webhook mistake.
Parsing Before Verifying
Frameworks that auto-parse JSON change the raw bytes, so the recomputed signature won't match. Capture the raw body and verify before parsing — a subtle bug that makes verification silently fail (or tempts developers to skip it).
Assuming Exactly-Once, In-Order Delivery
Webhooks are at-least-once and unordered. Code that assumes each event arrives exactly once, in sequence, breaks on the inevitable duplicate or reordered delivery. Dedupe by ID; derive state from payloads and timestamps, not arrival order.
Slow Synchronous Handlers
Doing heavy processing inside the webhook response causes timeouts → sender retries → duplicates and cascading load. Acknowledge immediately (2xx) and offload work to a background job.
No Fallback for Missed Events
Treating webhooks as a guaranteed delivery channel means silently losing data when your endpoint is down past the retry window. For anything critical (payments, orders), add reconciliation — periodically re-fetch state to catch missed events.
FAQ
Webhooks vs polling — which should I use?
Prefer webhooks for real-time notification without wasteful repeated requests — you're told the instant something happens. Use polling when the source doesn't offer webhooks, or as a reconciliation fallback for missed webhook events on critical data. The robust pattern is often webhooks for immediacy plus periodic polling as a safety net.
How do I secure a webhook endpoint?
Verify the HMAC signature against the raw body with constant-time comparison; check the timestamp to reject replays; use HTTPS; and treat the payload as untrusted input (validate it). Optionally restrict to the sender's known IP ranges. The signature is the core control — see API Security.
Why do I keep getting duplicate webhook events?
Because delivery is at-least-once — any timeout, non-2xx response, or network glitch causes the sender to retry, and you get the event again. This is expected, not a bug. Handle it with idempotency: dedupe on the event's unique ID so reprocessing is a no-op.
My webhook handler times out — what's wrong?
You're likely doing slow work (database writes, external API calls) inside the response. Senders enforce short timeouts; exceeding them triggers retries. Fix: verify and enqueue the event, return 2xx immediately, and process asynchronously in a background job.
How do I test webhooks locally?
Expose your local server to the internet with a tunneling tool (ngrok, Cloudflare Tunnel) so the sender can reach it, or use the sender's CLI (e.g. Stripe CLI) which forwards events to localhost and can trigger test events. Also build a way to replay captured payloads against your handler for repeatable testing.
Related Topics
- REST API Design — The request/response model, and polling
- Event-Driven Architecture — The paradigm webhooks embody
- API Security — Signature verification and input validation
- Background Jobs — Processing webhook events asynchronously
- Message Queues — Internal event delivery (webhooks' cousin)
- Payment Processing — A canonical webhook consumer
- Rate Limiting — Protecting webhook endpoints from abuse