Testing Webhook Handlers With Signed Payloads
A webhook endpoint is a public URL that accepts instructions from the internet — “this payment succeeded”, “this subscription was cancelled” — and trusts them because of a signature. That makes the signature check the most security-relevant line in the handler, and it is routinely the first thing tests skip: the verification is stubbed, or the handler is called with a parsed object so the check never runs. This guide covers testing webhooks the way providers actually send them — a raw body, a timestamp, an HMAC signature computed with the shared secret — so verification stays on the test path, and then covers the delivery behaviours that matter just as much: replays, duplicates and out-of-order events. It sits under event-driven and queue mocking.
Root Cause Analysis
Webhook verification is uniquely easy to break without noticing, for a mechanical reason: signatures are computed over the raw request body, byte for byte, and most web frameworks parse the body before your handler sees it. Re-serialising the parsed object produces different bytes — different key order, different whitespace — and the signature no longer matches. The quick fix, under deadline pressure, is to skip verification in tests or behind a flag, and that fix has a habit of spreading.
The second problem is that providers deliver webhooks at least once, not exactly once, and not necessarily in order. A payment provider may send payment_succeeded twice after a timeout, or send subscription_updated before subscription_created. A handler tested only with single, well-ordered deliveries will double-credit an account or crash on a missing record the first time the provider retries.
Third, a correct signature check includes a timestamp tolerance to prevent replay attacks — an attacker who captured a valid request cannot resend it an hour later. Tests that never vary the timestamp cannot tell whether the tolerance is enforced at all.
Reproducible Setup
A webhook route that verifies a timestamped HMAC over the raw body before doing anything else, in the style most payment and messaging providers use.
// src/webhooks/verify.ts
import { createHmac, timingSafeEqual } from 'node:crypto';
const TOLERANCE_SECONDS = 300;
export function verifySignature(rawBody: Buffer, header: string | undefined, secret: string, now = Date.now()) {
if (!header) return { ok: false as const, reason: 'missing_signature' };
const parts = Object.fromEntries(header.split(',').map((p) => p.split('=') as [string, string]));
const timestamp = Number(parts.t);
if (!Number.isFinite(timestamp)) return { ok: false as const, reason: 'malformed' };
if (Math.abs(now / 1000 - timestamp) > TOLERANCE_SECONDS) return { ok: false as const, reason: 'stale' };
const expected = createHmac('sha256', secret).update(`${timestamp}.`).update(rawBody).digest('hex');
const given = Buffer.from(parts.v1 ?? '', 'hex');
const ok = given.length === 32 && timingSafeEqual(given, Buffer.from(expected, 'hex'));
return ok ? { ok: true as const } : { ok: false as const, reason: 'bad_signature' };
}
// src/webhooks/route.ts — raw body first, parse second
import express from 'express';
import { verifySignature } from './verify';
import { handleEvent } from './handle-event';
export const webhookRouter = express.Router();
webhookRouter.post('/webhooks/payments', express.raw({ type: 'application/json' }), async (req, res) => {
const check = verifySignature(req.body, req.header('x-signature'), process.env.WEBHOOK_SECRET!);
if (!check.ok) return res.status(400).json({ error: check.reason });
const event = JSON.parse(req.body.toString('utf8'));
await handleEvent(event);
return res.status(200).end();
});
Implementation
Step 1 — Build a signer that mirrors the provider. Tests produce the header exactly as the provider would, over the exact bytes they send.
// test/webhooks/sign.ts
import { createHmac } from 'node:crypto';
export function signedRequest(event: object, { secret = 'whsec_test', timestamp = Math.floor(Date.now() / 1000) } = {}) {
const body = JSON.stringify(event);
const signature = createHmac('sha256', secret).update(`${timestamp}.${body}`).digest('hex');
return { body, header: `t=${timestamp},v1=${signature}` };
}
Step 2 — Send the raw body through the real route. The content type and the exact string matter; this is what makes the verification path genuinely exercised.
// src/webhooks/route.test.ts
import request from 'supertest';
import { test, expect, vi, beforeEach } from 'vitest';
import { app } from '../app';
import { signedRequest } from '../../test/webhooks/sign';
beforeEach(() => vi.stubEnv('WEBHOOK_SECRET', 'whsec_test'));
test('accepts a correctly signed event', async () => {
const { body, header } = signedRequest({ id: 'evt_1', type: 'payment.succeeded', data: { paymentId: 'p1' } });
await request(app).post('/webhooks/payments')
.set('content-type', 'application/json').set('x-signature', header).send(body)
.expect(200);
});
Step 3 — Cover every rejection path. Each reason in the verifier deserves a test, because each is a distinct way an attacker or a misconfiguration can present.
test.each([
['a wrong secret', () => signedRequest({ id: 'e' }, { secret: 'whsec_other' }), 'bad_signature'],
['a stale timestamp', () => signedRequest({ id: 'e' }, { timestamp: Math.floor(Date.now() / 1000) - 3600 }), 'stale'],
])('rejects a request signed with %s', async (_label, make, reason) => {
const { body, header } = make();
const res = await request(app).post('/webhooks/payments')
.set('content-type', 'application/json').set('x-signature', header).send(body).expect(400);
expect(res.body.error).toBe(reason);
});
test('rejects a body altered after signing', async () => {
const { body, header } = signedRequest({ id: 'e', amount: 100 });
await request(app).post('/webhooks/payments')
.set('content-type', 'application/json').set('x-signature', header)
.send(body.replace('100', '100000')).expect(400);
});
Step 4 — Make the handler idempotent on the event id and test the duplicate. Providers retry when they do not receive a timely 2xx, so the same event will arrive twice in production.
test('applies a duplicated delivery only once', async () => {
const event = { id: 'evt_42', type: 'payment.succeeded', data: { paymentId: 'p42', amountPence: 5000 } };
for (let i = 0; i < 2; i++) {
const { body, header } = signedRequest(event);
await request(app).post('/webhooks/payments')
.set('content-type', 'application/json').set('x-signature', header).send(body).expect(200);
}
expect(await ledger.creditsFor('p42')).toHaveLength(1);
});
Step 5 — Test out-of-order events. An update that arrives before the create it depends on should be deferred or tolerated, not crash; decide which, and pin the decision with a test.
test('an update for an unknown subscription is accepted and parked', async () => {
const { body, header } = signedRequest({ id: 'evt_9', type: 'subscription.updated', data: { subscriptionId: 's_new' } });
await request(app).post('/webhooks/payments')
.set('content-type', 'application/json').set('x-signature', header).send(body).expect(200);
expect(await parked.all()).toContainEqual(expect.objectContaining({ eventId: 'evt_9' }));
});
Step 6 — Replay a captured real payload once. Provider payloads carry fields and shapes a hand-written fixture will miss; a sanitised capture from the provider’s test mode, signed with your test secret, keeps the handler honest about what real events look like.
Verification
Prove verification is load-bearing by removing it. Comment out the signature check in the route and run the suite: every rejection test must fail. If any still passes, it is not reaching the code you think it is.
npx vitest run src/webhooks --reporter=verbose
# with the check removed:
# ✗ rejects a request signed with a wrong secret — expected 400, got 200
# ✗ rejects a request signed with a stale timestamp — expected 400, got 200
# ✗ rejects a body altered after signing — expected 400, got 200
Then confirm the comparison is constant-time. This is not something a functional test can measure reliably, so the check is a review rule: signature comparison uses timingSafeEqual, never ===, and a lint rule can enforce it on the verifier file.
Troubleshooting
Symptom: every valid request is rejected with a bad signature. Diagnosis: a JSON body parser runs before the webhook route, so req.body is an object and the raw bytes are gone. Fix: mount the raw parser on the webhook route specifically, before any global JSON middleware, and assert in a test that req.body is a Buffer.
Symptom: tests pass locally and fail in CI with “stale”. Diagnosis: the signer uses a timestamp from one clock and the verifier from another, or fake timers are active. Fix: pass the timestamp explicitly in tests that control time, and inject now into the verifier rather than reading the clock inside it.
Symptom: the duplicate test creates two ledger entries. Diagnosis: idempotency is checked on a derived key — the payment id — rather than the event id, and the provider sent two different events for the same payment. Fix: decide which key represents “the same thing happening twice” for each event type, and test the case where the provider legitimately sends two distinct events.
Symptom: a captured provider payload fails schema validation. Diagnosis: the hand-written schema was narrower than reality. Fix: widen it deliberately, and keep the captured payload as a fixture so the next schema change is tested against real data.
FAQ
Can I use the provider’s SDK to verify signatures in tests?
Yes, and in production too — most provider SDKs ship a verification helper. The testing principle is unchanged: sign the raw body with a test secret, send it through the real route, and let the SDK verify it. What you must not do is mock the SDK’s verification function, which removes the check from the test path.
Should webhook handlers do the work synchronously?
Usually not. Verify, record the event, return 200 quickly, and process asynchronously — providers time out and retry if the response is slow, which multiplies duplicates. The processing then becomes an ordinary queue consumer, testable as described in testing background jobs queued with BullMQ.
How large should the timestamp tolerance be?
Five minutes is the common default and a reasonable balance between clock skew and replay protection. Whatever you choose, test both sides of it — a request just inside must pass and one just outside must fail — so the boundary is pinned rather than assumed.
What about testing the outbound side, when we send webhooks?
That is a producer problem: assert on the payload and headers you send, including your own signature, using MSW to capture the request. Publishing your signing scheme as a small verification snippet for consumers, and testing that snippet against your own output, is a cheap way to catch mismatches before customers do.
Related
- Back to Event-Driven & Queue Mocking
- Testing Stripe payment flows without live keys — a provider whose webhooks follow this pattern.
- Mocking JWT auth in Vitest API tests — the same principle of real verification with a test key.
- Testing background jobs queued with BullMQ — where accepted webhooks are processed.