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.

Why the signature must be checked against the raw body The provider signs the exact bytes it sends; if the framework parses the body and the handler re-serialises it, key order and whitespace change, the computed signature differs, and verification fails — so the handler must read and verify the raw bytes before parsing. provider signs exact raw bytes framework parses JSON object re-serialised different bytes — fails provider signs exact raw bytes verify raw body before parsing then parse signature matches
Tests that send a pre-parsed object never exercise the raw-body path, which is where this bug lives.

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);
});
Rejection cases and duplicate delivery A missing header, a wrong secret, a stale timestamp and a tampered body must each return 400, while a correctly signed duplicate must return 200 and change nothing the second time. What the endpoint must do no signature 400 wrong secret 400 stale timestamp 400 tampered body 400 valid duplicate: 200 both times and exactly one ledger entry
Four rejections prove the check runs; the duplicate proves the handler survives the provider's retry policy.

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.

Two properties to confirm beyond the functional tests Removing the signature check must make every rejection test fail, proving the tests reach it, and the comparison must use a constant-time function, which is enforced by review or lint rather than by a test. check is load-bearing remove it, rejections fail proves the path is exercised comparison is constant-time timingSafeEqual, never === enforced by review or lint
Some security properties are better guarded by a rule than by a test; this is one of them.

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.