hooksentinel

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

stripe-webhook.test.ts
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

On this page