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:
- Node.js 22 or newer
- a Hookspot account and the Hookspot CLI
- the completed
hookspot-node-example/directory from the local webhook tutorial - three terminals for the receiver, Hookspot CLI, and sender
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-examplecp receiver.js order-created-zero.json webhook-regression-example/cd webhook-regression-exampleEnter 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.jsThe receiver prints:
Listening on http://localhost:3000In the Hookspot terminal, start the CLI from the same webhook-regression-example/ directory:
hookspot listen --forward-to http://localhost:3000The 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 requiredSelect 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>.jsonhookspot-fixtures/<request_uid>_<route_uid>.bodyThe .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:
- Rename both files so the request and route UIDs are not in their names.
- Check
pathfor tenant, account, or resource identifiers. - Check
queryfor credentials and identifying values. - Remove or replace unneeded headers, including signature, token, tracing, user-agent, and provider-specific headers.
- Inspect every field in the raw
.bodyfile 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 fixturesUsing 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:
{ "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:
{ "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 testSave the following file as 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=specThe 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 !== 200The 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=specThe 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 0You 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.