How to test webhook delivery
Start a listener you control, send the producer's payload to it, and read what arrives: curl -X POST http://127.0.0.1:8199/hook -H "x-signature: $SIG" --data-raw "$BODY". The delivery is correct when the listener logs a 200, the signature verifies against the raw body, and a retry carries the same delivery id.
Why check this
A webhook handler is tested by calling its function directly, which skips the transport, the signature check and the retry path. The bug that reaches production is a customer charged twice because the producer retried after a timeout and the handler treated the second delivery as a new event. Run this before sign-off on any integration that receives callbacks, and again after the signing secret is rotated.
Prerequisites
- Node 22 and the listener below, saved as
hook.js. It verifies the HMAC over the raw body and answers 503 to the first two attempts of each delivery id, so the sender's retry path is visible. - curl 7.71 or later, for
--retry-all-errors. See the curl manual. - openssl, or any tool that computes an HMAC over bytes rather than over a parsed object.
// hook.js - a webhook receiver. Verifies the signature, refuses twice per delivery id.
const http = require('node:http');
const crypto = require('node:crypto');
const SECRET = process.env.HOOK_SECRET || 'test-secret';
const attempts = new Map();
http.createServer((req, res) => {
let body = '';
req.on('data', (c) => (body += c));
req.on('end', () => {
const id = String(req.headers['x-delivery-id'] || 'none');
const sent = Buffer.from(String(req.headers['x-signature'] || ''));
const want = Buffer.from('sha256=' + crypto.createHmac('sha256', SECRET).update(body).digest('hex'));
if (sent.length !== want.length || !crypto.timingSafeEqual(sent, want)) {
console.log(`${id} signature MISMATCH body=${body}`);
return res.writeHead(401).end('bad signature');
}
const n = (attempts.get(id) || 0) + 1;
attempts.set(id, n);
const code = n < 3 ? 503 : 200;
console.log(`${id} attempt ${n} signature ok -> ${code} body=${body}`);
res.writeHead(code).end(String(code));
});
}).listen(8199, '127.0.0.1', () => console.log('listener on http://127.0.0.1:8199/hook'));
Steps
- Step 1.
Start the listener in its own terminal and keep a copy of the log on disk.
node hook.js | tee hook.loglistener on http://127.0.0.1:8199/hook - Step 2.
Sign the exact bytes you are about to send.
BODY='{"event":"invoice.paid","id":"inv_42"}' SIG="sha256=$(printf '%s' "$BODY" | openssl dgst -sha256 -hmac 'test-secret' -r | cut -d' ' -f1)" echo "$SIG"sha256=c439550fd8eab969dbd7897e94749cb065dfa9563fea75b7bcbed5534385e2ffprintfis used instead ofechobecause a trailing newline changes the digest. - Step 3.
Deliver it, and let curl stand in for the producer's retry policy.
curl -s -o /dev/null -w '%{http_code} after %{num_retries} retries\n' --retry 3 --retry-delay 1 --retry-all-errors -X POST 'http://127.0.0.1:8199/hook' -H 'content-type: application/json' -H 'x-delivery-id: d-1' -H "x-signature: $SIG" --data-raw "$BODY"200 after 2 retries - Step 4.
Read the listener log and count the attempts.
cat hook.loglistener on http://127.0.0.1:8199/hook d-1 attempt 1 signature ok -> 503 body={"event":"invoice.paid","id":"inv_42"} d-1 attempt 2 signature ok -> 503 body={"event":"invoice.paid","id":"inv_42"} d-1 attempt 3 signature ok -> 200 body={"event":"invoice.paid","id":"inv_42"}One event, three deliveries, one delivery id. Any handler that writes on receipt has now written three times unless it checks the id first.
- Step 5.
Change one byte of the body and keep the signature from step 2.
curl -s -o /dev/null -w '%{http_code}\n' -X POST 'http://127.0.0.1:8199/hook' -H 'content-type: application/json' -H 'x-delivery-id: d-2' -H "x-signature: $SIG" --data-raw '{"event":"invoice.paid","id":"inv_43"}'401The listener log gains
d-2 signature MISMATCH. A receiver that answers 200 here accepts any payload from anyone who knows the URL. - Step 6.
Stop the listener with Ctrl+C so port 8199 is free, and confirm the log holds no line you did not send.
How to read the result
| What you see | What it means | What to do |
| --- | --- | --- |
| 200 and one log line per attempt | The callback arrives and verifies | Record the delivery id format. The handler needs it for deduplication. |
| 401 and signature MISMATCH | The digest differs from the header | Compare the bytes, not the object. Re-serialised JSON produces a different digest. |
| Three attempts, one delivery id | At-least-once delivery, as designed | The handler must be idempotent on that id. Retrying is not a bug, duplicating the side effect is. |
| No log line at all | Nothing reached the listener | The producer never sent, or the URL, port or tunnel is wrong. Check before touching handler code. |
| 200 but no log line | Something else answered | A proxy, tunnel landing page or old process holds the port. Confirm the listener owns it. |
| 200 after the handler finished the work | The answer waits on the job | Answer first, queue the work. A slow 200 becomes a producer timeout and another retry. |
Common mistakes
What to check next
- How to test retry-after header: what your 503 should carry so the sender waits instead of hammering.
- How to test API idempotency: the property the retries in step 4 demand from the handler.
- How to verify JWT signature: the same raw-bytes rule, applied to a token instead of a body.
- How to check HTTP status code: the sender decides whether to retry on the status alone.
- Api testing checklist: where the callback path sits in a release pass.
FAQ
How to test webhooks locally?
Run the listener above on 127.0.0.1 and post to it yourself, as steps 2 and 3 do. Local testing covers the signature, the retry path and deduplication. Only the producer's own scheduling needs a public URL.
How to test a webhook URL?
Send it a signed POST with curl and read the status and the body. A browser visit sends a GET and proves nothing, because most receivers reject GET.
How to check if a webhook is working?
Look for one log line per delivery, with the delivery id and a 2xx. No line means the request never arrived, which is a network or URL problem, not a handler problem.
Do I need a public tunnel to test webhooks?
Only for the final pass with the real producer. Everything in this procedure runs on localhost. When a tunnel is used, test the tunnel URL with curl first, since some insert an interstitial page that a sender will not pass.
Verified
Verified by Maks Vernycurl 8.21.0openssl 3.1.1node 22.23.2
Each output block is what the command above it printed on that date, on the host named in the step. Figures read from a live site move between runs. Compare the shape of the answer rather than the digits, and see the methodology for how a page is re-verified.
Related on this site
intermediate8 minpublished updated Maks Verny