The Complete Guide to Webhooks
If you have ever integrated Stripe, GitHub, Shopify, Twilio, or Slack into an app, you have used webhooks — whether you knew it or not. Webhooks are how one system tells another that something just happened, in real time, without anyone polling for changes.
This guide is the definitive, vendor-neutral reference: what webhooks are, how they actually work on the wire, how providers sign and retry them, and — the part most tutorials skip — how to debug them when they silently fail.
Tooling note: Throughout this guide we use Anonymily, a universal webhook platform that captures, inspects, replays, and AI-diagnoses webhooks from any provider. The concepts here are provider-agnostic; Anonymily is just the fastest way to see them in action.
What is a webhook?
A webhook is an HTTP request that a provider sends to a URL you control, triggered by an event on their side.
Compare the two models:
- Polling (pull): Your app repeatedly asks "anything new?" — wasteful, slow, and rate-limited.
- Webhooks (push): The provider calls you the instant something happens —
POST https://yourapp.com/webhooks/stripewith a JSON body describing the event.
That's the whole idea. A webhook is "a reverse API call": instead of you calling the provider, the provider calls you.
POST /webhooks/stripe HTTP/1.1
Host: yourapp.com
Content-Type: application/json
Stripe-Signature: t=1719500000,v1=5257a8...
{
"id": "evt_1Nyz...",
"type": "payment_intent.succeeded",
"data": { "object": { "amount": 2000, "currency": "usd" } }
}
Your job is to receive that request, verify it's genuine, and act on it (mark the order paid, kick off a job, send an email).
How webhooks work, step by step
- You register an endpoint. In the provider's dashboard you give them a public HTTPS URL and pick which events you care about.
- An event happens. A customer pays, a PR is opened, an order ships.
- The provider sends an HTTP
POSTto your URL with a payload (usually JSON) and a set of headers, including a signature. - Your server responds. A
2xxstatus means "got it." Anything else (or a timeout) means "failed — please retry." - The provider retries failed deliveries on a backoff schedule, then eventually gives up.
The deceptively hard part is step 4 and 5: if your handler is slow, throws, or returns the wrong status, the provider quietly retries or drops the event — and you find out hours later when data is missing.
Webhook signatures: how to know a request is genuine
Your endpoint is public, so anyone can POST to it. To prove a request really came from the provider (and wasn't tampered with), providers sign each payload with a shared secret using HMAC.
The pattern is almost always the same:
- The provider computes
HMAC-SHA256(secret, raw_request_body). - It sends that digest in a header.
- You recompute the same HMAC on your side and compare — using a constant-time comparison.
Header names differ by provider:
| Provider | Header | Algorithm |
|---|---|---|
| Stripe | Stripe-Signature |
HMAC-SHA256 over timestamp.payload |
| GitHub | X-Hub-Signature-256 |
HMAC-SHA256, sha256= prefix |
| Shopify | X-Shopify-Hmac-Sha256 |
HMAC-SHA256, base64-encoded |
| Slack | X-Slack-Signature |
HMAC-SHA256 over v0:timestamp:body |
Critical gotcha: verify against the raw request body bytes, not a re-serialized JSON object. If your framework parses the body to JSON and you re-stringify it, the bytes change and the signature will never match. We cover this in depth in How to verify webhook signatures (HMAC).
Retries, idempotency, and ordering
Three realities every webhook consumer must design for:
- Retries → duplicates. Because providers retry on failure (and sometimes on success they never saw a
2xxfor), you will receive the same event more than once. Make handlers idempotent: key off the event ID and ignore events you've already processed. - No ordering guarantees. Events can arrive out of order. Don't assume
order.createdarrives beforeorder.paid. Reconcile against state, don't rely on sequence. - Respond fast, work later. Return
2xximmediately, then do heavy work in a background job. Most providers time out in 5–30 seconds; slow handlers get marked as failures and retried, multiplying your load.
Why webhooks fail silently (and how to catch them)
The defining pain of webhooks is that failures are invisible. There's no user clicking a button and seeing an error — the request happens server-to-server, and if it fails, nothing in your UI tells you. Common causes:
- Signature verification rejects a valid request (the raw-body bug above).
- Your handler throws on an event shape you didn't expect.
- The endpoint was down during a deploy, and the retries expired before it came back.
- A
4xx/5xxyou returned during testing told the provider to stop sending.
The fix is visibility: you need to see the exact request the provider sent — headers, body, signature — and be able to replay it against your handler until it works.
The painful manual way
Historically people:
- Spin up ngrok to expose localhost.
- Add
console.logeverywhere. - Trigger a real event (make a real $1 payment, open a real PR) and hope they can reproduce it.
- Re-trigger by making another real event, because there's no replay.
It's slow, it pollutes real systems with test data, and the moment the tunnel restarts you lose your URL.
The Anonymily way
Point the provider at an Anonymily endpoint, then run one command to stream every webhook to your local server — fully inspectable and replayable:
npx @anonymilyhq/cli listen 3000
That gives you a persistent capture URL, forwards every request to localhost:3000, and shows you the full payload and headers. When something fails, you replay the exact captured request instead of manufacturing a new real-world event:
# Re-fire a previously captured request at your handler
npx @anonymilyhq/cli replay <hookId> <requestId>
And when you can't tell why a webhook failed, Anonymily's AI diagnosis reads the payload, your handler's response, and the signature, and explains the failure in one click — then drafts the fix.
A minimal, correct webhook handler
Here's the shape of a handler that respects everything above — raw body, signature check, fast 2xx, idempotency:
import express from "express";
import crypto from "crypto";
const app = express();
// IMPORTANT: capture the RAW body for signature verification
app.post(
"/webhooks/provider",
express.raw({ type: "application/json" }),
(req, res) => {
const signature = req.header("X-Signature-256") || "";
const expected =
"sha256=" +
crypto
.createHmac("sha256", process.env.WEBHOOK_SECRET)
.update(req.body) // req.body is a Buffer of raw bytes
.digest("hex");
// constant-time compare to avoid timing attacks
const ok =
signature.length === expected.length &&
crypto.timingSafeEqual(Buffer.from(signature), Buffer.from(expected));
if (!ok) return res.status(401).send("invalid signature");
const event = JSON.parse(req.body.toString("utf8"));
// idempotency: skip events we've already handled
if (alreadyProcessed(event.id)) return res.status(200).send("dup");
// respond fast, then do the real work in a background job
enqueue(event);
return res.status(200).send("ok");
}
);
app.listen(3000);
Where to go next
- How to test webhooks locally — get real provider events hitting your laptop in 30 seconds.
- How to verify webhook signatures (HMAC) — the raw-body trap and constant-time comparison, done right.
- How to test Stripe webhooks on localhost — the most common provider, end to end.
- Anonymily vs ngrok — why a webhook-native tool beats a generic tunnel for this job.
TL;DR
A webhook is a provider POSTing to your URL when an event happens. Verify the signature against the raw body with a constant-time compare, return 2xx fast, make handlers idempotent, and design for retries and out-of-order delivery. When things break — and they will, silently — you need to see and replay the exact request. That's the gap Anonymily closes for every provider, not just one:
npx @anonymilyhq/cli listen 3000