belun.app Blog
RU

How to Test Webhooks Without Deploying Anything

Debug webhooks with a disposable capture URL. What providers actually send, why signature checks fail, and the mistakes that eat the most time.

Developer inspecting webhook HTTP requests in a terminal on a laptop screen

The annoying thing about webhooks is the order you have to do everything in. You need a public URL before the provider will send you anything. You need to see what it sends before you can write the handler. So people deploy a stub, watch the logs, redeploy, watch again, and burn forty minutes on what should be a two-minute question: what does this payload actually look like?

A capture URL skips that loop. Paste one into the provider’s dashboard, hit “send test event”, and you have the exact request in front of you before you’ve opened your editor.

Read the request before writing the handler

Provider docs are usually accurate about payload structure and vague about everything wrapped around it. The headers are where the surprises live.

Stripe puts its signature in Stripe-Signature, and the value is a compound string with a timestamp and one or more v1= hashes, not a plain hex digest. GitHub sends X-Hub-Signature-256 plus an X-GitHub-Event header telling you which event you got — miss that second one and you’ll be sniffing the body to figure out whether it’s a push or a pull request. Shopify base64-encodes its HMAC instead of hex-encoding it. None of that is hidden, but reading it in a doc and seeing it land on a real request are different kinds of knowing.

Capture one delivery and you get all of it at once: method, path, query string, every header, and the body.

The signature bug everyone hits once

Here’s the one that costs people an afternoon. Signature verification needs the raw request body — the exact bytes the provider hashed. If your framework parses the JSON and you re-serialize it to check the signature, key order or whitespace shifts by one character and the hash no longer matches.

In Express, express.json() consumes the stream before your handler sees it. The fix is to keep a copy:

app.use(express.json({
  verify: (req, _res, buf) => { req.rawBody = buf; },
}));

Then hash req.rawBody, not JSON.stringify(req.body). Fastify, Django, and Rails each have their own version of this trap.

Retries are not a detail

Return anything other than a 2xx and most providers will send the event again. Stripe retries live-mode events with exponential backoff for up to three days. GitHub doesn’t retry at all, though you can redeliver by hand from the repository’s webhook settings.

That asymmetry matters for how you build the handler. If your endpoint is slow enough to time out, Stripe will hammer you with duplicates of an event you may have already processed, which is why every serious handler stores the event id and ignores repeats. Capturing the retries first shows you how fast they arrive and what changes between attempts (usually nothing except a delivery id).

Don’t trust Content-Type

Plenty of services send JSON with Content-Type: text/plain, and plenty of legacy integrations send form-urlencoded bodies with a JSON blob stuffed into one field. Sniff the body instead of trusting the header. The Webhook Tester does exactly that, so a JSON body labelled as plain text still comes out pretty-printed.

One rule worth keeping: send test events, not real ones. A public capture URL is public. Anyone who has the link can read what arrives, so keep production keys and customer data far away from it.

Grab a URL from the Webhook Tester and fire your first test delivery at it — you’ll know more in thirty seconds than the docs will tell you in ten minutes.

Try the tool

Webhook Tester →