All articles

pillar

The Complete Guide to Webhooks

What webhooks are, how they work, how signatures and retries are handled, and how to debug them — the definitive, vendor-neutral guide.

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/stripe with 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

  1. You register an endpoint. In the provider's dashboard you give them a public HTTPS URL and pick which events you care about.
  2. An event happens. A customer pays, a PR is opened, an order ships.
  3. The provider sends an HTTP POST to your URL with a payload (usually JSON) and a set of headers, including a signature.
  4. Your server responds. A 2xx status means "got it." Anything else (or a timeout) means "failed — please retry."
  5. 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:

  1. The provider computes HMAC-SHA256(secret, raw_request_body).
  2. It sends that digest in a header.
  3. 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 2xx for), 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.created arrives before order.paid. Reconcile against state, don't rely on sequence.
  • Respond fast, work later. Return 2xx immediately, 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/5xx you 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:

  1. Spin up ngrok to expose localhost.
  2. Add console.log everywhere.
  3. Trigger a real event (make a real $1 payment, open a real PR) and hope they can reproduce it.
  4. 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


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

Try it in 30 seconds

Capture your first webhook — from any provider — with one command. No account required.

npx @anonymilyhq/cli listen 3000Open Dashboard →