HookspotDashboard
Menu

Turn a captured webhook into a regression test

A captured webhook can preserve the input that exposed a handler bug. In this tutorial, you will export the zero-total request from the Node.js setup, sanitize it, and run it against the real HTTP handler with Node’s built-in test runner.

The test needs no npm packages. Hookspot exports the request files. You will write the test and its assertions.

Prerequisites

You need:

The earlier tutorial finishes with a fixed receiver.js. Keep that file intact. This walkthrough makes a disposable copy and reintroduces the same bug so the regression test can prove that it catches the failure.

Make a disposable copy

Stop the earlier receiver with Ctrl-C and its full-screen Hookspot listener with q. Use Ctrl-C if the CLI is in another view.

In a terminal, enter the hookspot-node-example/ directory from the earlier tutorial. Copy the receiver and zero-total payload into a separate directory:

mkdir webhook-regression-example
cp receiver.js order-created-zero.json webhook-regression-example/
cd webhook-regression-example

Enter hookspot-node-example/webhook-regression-example/ in all three terminals. Run every remaining command from that directory.

Open the copied receiver.js. Replace the fixed validation:

if (
typeof orderId !== 'string' ||
orderId.length === 0 ||
typeof total !== 'number'
) {

with the original buggy condition:

// Intentional bug: zero is valid, but this check treats it as missing.
if (typeof orderId !== 'string' || orderId.length === 0 || !total) {

This change affects only the disposable copy. The truthiness check treats the valid number 0 as a missing value.

Capture the failing request

In the receiver terminal, start the copied app:

node receiver.js

The receiver prints:

Listening on http://localhost:3000

In the Hookspot terminal, start the CLI from the same webhook-regression-example/ directory:

hookspot listen --forward-to http://localhost:3000

The current directory matters because the CLI writes exported fixtures beneath it.

In the sender terminal, set WEBHOOK_URL to the complete private URL, as in the setup tutorial. Keep it out of screenshots, logs, and version control. Send the controlled request:

curl --request POST \
--header 'Content-Type: application/json' \
--data-binary @order-created-zero.json \
"$WEBHOOK_URL"

The buggy receiver prints:

422 order.created ord_test_zero: id and total are required

Select that request in the CLI with the up or down arrow. Use the left or right arrow to confirm that Request contains data.total: 0 and Response contains the app’s 422 response.

Export the selected request

With the failed request selected, press e. The full-screen CLI documentation lists the export key.

The CLI reports the exported paths. The pair uses this naming pattern:

hookspot-fixtures/<request_uid>_<route_uid>.json
hookspot-fixtures/<request_uid>_<route_uid>.body

The .json file stores the request metadata. The sibling .body file stores the raw request body bytes. Exporting the same request and route again overwrites the pair.

Sanitize both exported files

Do not commit or share the raw export yet. The CLI replaces values for a fixed set of sensitive headers by default, including Authorization, cookies, and common API-key headers. That protection does not inspect the query, path, raw body, filename, or unlisted headers.

Review each surface separately:

  1. Rename both files so the request and route UIDs are not in their names.
  2. Check path for tenant, account, or resource identifiers.
  3. Check query for credentials and identifying values.
  4. Remove or replace unneeded headers, including signature, token, tracing, user-agent, and provider-specific headers.
  5. Inspect every field in the raw .body file for secrets and personal data.

Do not start the listener with --show-sensitive-headers for this workflow. That flag disables the default header-value redaction for exported fixtures.

Create a directory for the sanitized pair:

mkdir fixtures

Using the exact paths printed by the CLI, copy the exported pair to fixtures/order-created.json and fixtures/order-created.body. Keep the shared basename. Then replace their contents with the audited, controlled values below.

Save this metadata as fixtures/order-created.json:

fixtures/order-created.json
{
"method": "POST",
"path": "/webhooks/orders",
"query": "",
"headers": {
"Content-Type": [
"application/json"
]
}
}

method, path, and query are strings. query is the raw query without a leading ?. Each header maps to an array of strings so the fixture can preserve repeated values.

Save the sanitized raw body as fixtures/order-created.body:

fixtures/order-created.body
{
"type": "order.created",
"data": {
"id": "ord_test_zero",
"total": 0,
"currency": "USD"
}
}

These values contain only the fake data used throughout the tutorial. If you adapt this workflow to a provider request, preserve only the fields and headers that the behavior under test needs.

Write the HTTP regression test

Create a test directory:

mkdir test

Save the following file as test/orders-webhook.test.js:

test/orders-webhook.test.js
const assert = require('node:assert/strict');
const http = require('node:http');
const { readFile } = require('node:fs/promises');
const path = require('node:path');
const test = require('node:test');
const { createAppServer } = require('../receiver.js');
const fixtureDirectory = path.join(__dirname, '..', 'fixtures');
function listen(server) {
return new Promise((resolve, reject) => {
server.once('error', reject);
server.listen(0, '127.0.0.1', () => {
server.off('error', reject);
resolve();
});
});
}
function close(server) {
return new Promise((resolve, reject) => {
server.close((error) => {
if (error) reject(error);
else resolve();
});
});
}
function sendFixture(port, metadata, body) {
return new Promise((resolve, reject) => {
const query = metadata.query ? `?${metadata.query}` : '';
const request = http.request(
{
hostname: '127.0.0.1',
port,
method: metadata.method,
path: `${metadata.path}${query}`,
},
(response) => {
const chunks = [];
response.on('data', (chunk) => chunks.push(chunk));
response.on('end', () => {
resolve({
statusCode: response.statusCode,
body: Buffer.concat(chunks).toString('utf8'),
});
});
},
);
request.on('error', reject);
for (const [name, values] of Object.entries(metadata.headers)) {
for (const value of values) request.appendHeader(name, value);
}
request.end(body);
});
}
test('accepts an order.created event with a zero total', async (t) => {
const metadata = JSON.parse(
await readFile(path.join(fixtureDirectory, 'order-created.json'), 'utf8'),
);
const body = await readFile(path.join(fixtureDirectory, 'order-created.body'));
const server = createAppServer();
await listen(server);
t.after(() => close(server));
const address = server.address();
assert.notEqual(address, null);
assert.equal(typeof address, 'object');
const response = await sendFixture(address.port, metadata, body);
assert.equal(response.statusCode, 200);
assert.deepEqual(JSON.parse(response.body), {
received: true,
orderId: 'ord_test_zero',
total: 0,
});
});

The test starts the actual receiver on an operating-system-assigned port. It reconstructs the exported path and query, appends every stored header value, and sends the raw body bytes over HTTP. Cleanup closes the server whether the assertions pass or fail.

Run the failing test

Stop the manually started receiver with Ctrl-C. The test starts its own server, so it does not need the CLI, sender, or port 3000.

Run the test from webhook-regression-example/:

node --test --test-reporter=spec

The command exits with status 1. These excerpts are from Node v22.23.2; timings vary by machine.

422 order.created ord_test_zero: id and total are required
✖ accepts an order.created event with a zero total (12.467708ms)
ℹ tests 1
ℹ suites 0
ℹ pass 0
ℹ fail 1
AssertionError [ERR_ASSERTION]: Expected values to be strictly equal:
422 !== 200

The mismatch proves that the fixture reaches the real route and exposes the truthiness bug. Do not change the expected status to 422. The controlled schema defines zero as a valid total, so the handler is wrong.

Fix the handler

In the disposable receiver.js, replace this condition:

// Intentional bug: zero is valid, but this check treats it as missing.
if (typeof orderId !== 'string' || orderId.length === 0 || !total) {

with the same fix used in the local webhook tutorial:

if (
typeof orderId !== 'string' ||
orderId.length === 0 ||
typeof total !== 'number'
) {

The handler now rejects a missing or nonnumeric total while accepting the number 0. The fixture and test remain unchanged.

Run the passing test

Run the identical command:

node --test --test-reporter=spec

The command now exits with status 0. The same Node 22 run produced this excerpt:

200 order.created ord_test_zero total=0
✔ accepts an order.created event with a zero total (11.762042ms)
ℹ tests 1
ℹ suites 0
ℹ pass 1
ℹ fail 0

You now have a sanitized fixture pair and a regression test that exercises routing, body parsing, validation, and the JSON response over HTTP. The CLI captured and exported the request data. The test harness and expected behavior belong to your application.

This small test does not prove provider-signature verification, idempotency, concurrency behavior, or production resilience. Add separate tests for those requirements when your handler implements them.