Quick answer: Outbound webhooks invert the usual testing problem: your system is the caller, and you need a receiver that accepts signed payloads, verifies the signature, and behaves like a real endpoint including failures and retries. A mock webhook receiver lets you test the full delivery path locally — signing the body, setting the signature header, retrying on a 500, and honoring the signature on the consumer side. The key is to make the mock verify the signature rather than just accept requests, so a broken signing secret fails the test instead of silently passing.
Teams often test webhook delivery by pointing at a public request-bin style endpoint, which proves a request was sent but not that it is correct. It cannot verify the HMAC, cannot model a consumer that rejects a tampered body, and cannot force a retry on demand. A local mock receiver that implements the same verification logic as a real consumer turns webhooks into a deterministic part of the test suite and keeps the signing secret out of any third-party service.
What the receiver must model
Four behaviors separate a useful mock webhook from a black hole that logs requests.
| Behavior | Purpose | What a green test proves |
|---|---|---|
| Signature verification | Reject bad HMAC | Signing is correct |
| Replay protection | Reject reused timestamp | Freshness headers work |
| Transient 500 | Trigger retry | Retry with backoff |
| Idempotent accept | Same event twice | No duplicate processing |
Sign the raw body, not the parsed object
The single most common webhook bug is signing a re-serialized object while the receiver computes the HMAC over the exact bytes sent. The mock must read the raw request body and verify against that, mirroring a real consumer.
const crypto = require("crypto"); function verify(rawBody, signature, secret) { const expected = crypto .createHmac("sha256", secret) .update(rawBody) // raw bytes, exactly as received .digest("hex"); // constant-time compare to avoid timing leaks const a = Buffer.from(expected); const b = Buffer.from(signature || ""); return a.length === b.length && crypto.timingSafeEqual(a, b); } // mock receiver const server = http.createServer((req, res) => { const chunks = []; req.on("data", (c) => chunks.push(c)); req.on("end", () => { const raw = Buffer.concat(chunks); const sig = req.headers["x-webhook-signature"]; if (!verify(raw, sig, process.env.WEBHOOK_SECRET)) { return res.writeHead(401).end("invalid signature"); } res.writeHead(200).end("accepted"); }); });
Force the retry path deterministically
A configurable mock can fail the first delivery with a 500 and succeed on the second, letting the test assert the producer retries with backoff and delivers the same event id rather than generating a new one.
it("retries a failed delivery with the same event id", async () => { mockReceiver.failNext(500); await producer.deliver("invoice.paid", { id: "inv_1", total: "49.99" }); const deliveries = mockReceiver.deliveriesFor("inv_1"); expect(deliveries).toHaveLength(2); expect(deliveries[0].status).toBe(500); expect(deliveries[1].status).toBe(200); // same event id, not a duplicate event expect(deliveries[0].headers["x-event-id"]) .toBe(deliveries[1].headers["x-event-id"]); });
Keep the contract and the mock aligned
When webhook events are documented in an OpenAPI document, the event names, payload schemas, and headers are already declared. Generating the mock from that spec means the receiver validates each delivered payload against the documented schema and rejects a producer that sends an undocumented field or wrong type. This keeps webhook testing local, reproducible in CI, and honest about the exact bytes and signatures that a real consumer will see.
Frequently asked questions
Why verify the signature in the mock if we control both sides?
Because the signature is the behavior under test. A receiver that accepts anything cannot catch a wrong secret, a re-serialized body, or a missing header, all of which fail against a real consumer.
Should the mock store delivered events?
Yes, recording deliveries lets tests assert retry counts, event ids, and ordering. Clear the store between tests so execution order cannot change results.
How do I test replay protection?
Have the mock reject a request whose timestamp is older than a tolerance or whose event id was already accepted, then assert the producer uses fresh timestamps and unique ids.
Where should the signing secret live?
In a local environment file or secret store, never in the spec or committed code. The mock reads it from the environment just as the producer does, so the test exercises real secret handling without exposing it.
This is part of the API Mocking Guide — start there for the full picture of contract-driven mocks.
Top comments (0)