Testing
Testing webhook handlers with createTestSigner instead of mocking crypto or hand-computing HMAC signatures.
Testing a webhook handler usually means producing a request with a valid signature, so you can exercise the real verification path rather than mocking it away. @hooksentinel/core/testing exports createTestSigner, which signs a payload exactly the way the matching provider adapter verifies it.
Install
npm install -D @hooksentinel/core@hooksentinel/core/testing is safe to import in test files — it isn't included in the main package export, so it can't end up in a production bundle by accident.
createTestSigner
import { createTestSigner } from '@hooksentinel/core/testing';
const sign = createTestSigner('stripe', { secret: 'whsec_test_secret' });
const { body, headers } = sign({
id: 'evt_test_123',
type: 'checkout.session.completed',
data: { object: { id: 'cs_test_123', customer: 'cus_test_123' } },
});sign() returns the exact body (a string) and headers object your handler needs — for Stripe, that means a correctly formatted Stripe-Signature header with a fresh timestamp and matching HMAC, computed over the serialized body the same way stripe() expects. Every built-in provider adapter has a matching signer, so switching providers in a test is a one-line change to the first argument.
Example: testing an Express handler with supertest
import request from 'supertest';
import express from 'express';
import { createWebhookHandler, stripe } from '@hooksentinel/core';
import { toExpressHandler } from '@hooksentinel/core/express';
import { memoryStore } from '@hooksentinel/core/stores';
import { createTestSigner } from '@hooksentinel/core/testing';
const secret = 'whsec_test_secret';
const sign = createTestSigner('stripe', { secret });
function buildApp(onEvent: Parameters<typeof createWebhookHandler>[0]['onEvent']) {
const app = express();
const handler = createWebhookHandler({
provider: stripe({ secret }),
idempotency: memoryStore(),
onEvent,
});
app.post('/webhooks/stripe', express.raw({ type: 'application/json' }), toExpressHandler(handler));
return app;
}
test('processes a valid checkout.session.completed event', async () => {
const onEvent = jest.fn();
const app = buildApp(onEvent);
const { body, headers } = sign({
id: 'evt_test_1',
type: 'checkout.session.completed',
data: { object: { id: 'cs_test_1' } },
});
const res = await request(app)
.post('/webhooks/stripe')
.set(headers)
.set('Content-Type', 'application/json')
.send(body);
expect(res.status).toBe(200);
expect(onEvent).toHaveBeenCalledTimes(1);
});
test('rejects a tampered payload', async () => {
const onEvent = jest.fn();
const app = buildApp(onEvent);
const { headers } = sign({
id: 'evt_test_2',
type: 'checkout.session.completed',
data: { object: { id: 'cs_test_2' } },
});
const res = await request(app)
.post('/webhooks/stripe')
.set(headers)
.set('Content-Type', 'application/json')
.send('{"id":"evt_test_2","type":"checkout.session.completed","tampered":true}');
expect(res.status).toBe(401);
expect(onEvent).not.toHaveBeenCalled();
});Sending the raw body string exactly as returned by sign() matters — resending a re-serialized version of the payload (e.g. JSON.stringify(JSON.parse(body))) can produce different bytes (key order, whitespace) and fail verification even though the content is "the same," which is itself a useful test of the raw-body requirement described on the missing_raw_body page.
Testing duplicate delivery / idempotency
Send the same signed request twice and assert onEvent only ran once:
test('deduplicates a redelivered event', async () => {
const onEvent = jest.fn();
const app = buildApp(onEvent);
const { body, headers } = sign({
id: 'evt_test_dupe',
type: 'checkout.session.completed',
data: { object: { id: 'cs_test_dupe' } },
});
await request(app).post('/webhooks/stripe').set(headers).send(body);
const second = await request(app).post('/webhooks/stripe').set(headers).send(body);
expect(second.status).toBe(200); // still acked, per provider expectations
expect(onEvent).toHaveBeenCalledTimes(1); // but only handled once
});Testing expired timestamps
Pass a timestamp override to produce a signature outside the tolerance window, to test your handling of timestamp_out_of_tolerance:
const { body, headers } = sign(
{ id: 'evt_old', type: 'checkout.session.completed', data: { object: { id: 'cs_old' } } },
{ timestamp: Math.floor(Date.now() / 1000) - 3600 }, // 1 hour ago
);Other frameworks
createTestSigner produces plain body/headers — it has no framework dependency. For Fastify, use app.inject() instead of a real listener; for Next.js route handlers, construct a Request directly:
import { POST } from '@/app/webhooks/stripe/route';
const { body, headers } = sign({ /* ... */ });
const response = await POST(new Request('http://localhost/webhooks/stripe', {
method: 'POST',
headers,
body,
}));
expect(response.status).toBe(200);Last updated on