How to test webhooks locally with Node.js
A webhook handler that accepts an ordinary order can still reject one with a total of zero. It’s an easy mistake to make with !total: zero looks just like a missing value.
The Node.js app below has that bug left in. You’ll send it two orders through Hookspot, then replay the failed request after fixing the validation.
Set up the example
You’ll need Node.js 22 or newer, a Hookspot account, and the Hookspot CLI. The app uses Node’s built-in HTTP server, with no npm packages to install.
If you haven’t set up Hookspot yet, follow the quickstart to create a source and log in with the CLI. Give your route the Path in your local app /webhooks/orders and copy the source’s complete Webhook URL. Keep that URL private: don’t put it in screenshots, logs, or version control.
Open three terminals: one for the Node receiver, one for the Hookspot CLI, and one for sending requests with curl. Create a directory for the example:
mkdir hookspot-node-examplecd hookspot-node-exampleUse hookspot-node-example/ as the working directory in all three terminals. Save this as receiver.js, leaving the !total check in place for now:
const http = require('node:http');
const PORT = 3000;
function sendJson(response, statusCode, value) { response.writeHead(statusCode, { 'content-type': 'application/json' }); response.end(`${JSON.stringify(value)}\n`);}
async function handleOrderWebhook(request, response) { const url = new URL(request.url, 'http://localhost');
if (request.method !== 'POST' || url.pathname !== '/webhooks/orders') { sendJson(response, 404, { error: 'not found' }); return; }
request.setEncoding('utf8'); let rawBody = ''; try { for await (const chunk of request) { rawBody += chunk; } } catch (error) { console.error(`400 request body error: ${error.message}`); sendJson(response, 400, { error: 'could not read request body' }); return; }
let event; try { event = JSON.parse(rawBody); } catch { console.log('400 invalid JSON'); sendJson(response, 400, { error: 'invalid JSON' }); return; }
if (event?.type !== 'order.created') { console.log(`422 unsupported event: ${String(event?.type)}`); sendJson(response, 422, { error: 'unsupported event type' }); return; }
const orderId = event.data?.id; const total = event.data?.total;
// Intentional bug: zero is valid, but this check treats it as missing. if (typeof orderId !== 'string' || orderId.length === 0 || !total) { console.log(`422 order.created ${String(orderId)}: id and total are required`); sendJson(response, 422, { error: 'id and total are required' }); return; }
console.log(`200 order.created ${orderId} total=${total}`); sendJson(response, 200, { received: true, orderId, total });}
function createAppServer() { return http.createServer(handleOrderWebhook);}
if (require.main === module) { createAppServer().listen(PORT, () => { console.log(`Listening on http://localhost:${PORT}`); });}
module.exports = { createAppServer };The createAppServer() export is there for the follow-up regression test, which starts this same handler on a temporary port.
Save two test orders
Save the first order as order-created.json:
{ "type": "order.created", "data": { "id": "ord_test_123", "total": 4200, "currency": "USD" }}Then save order-created-zero.json. This order has a total of 0, which our app should accept:
{ "type": "order.created", "data": { "id": "ord_test_zero", "total": 0, "currency": "USD" }}Both files contain made-up order data. Use your provider’s schema when you move on to real webhooks.
Start the receiver and Hookspot
In the receiver terminal, run:
node receiver.jsListening on http://localhost:3000Leave Node running and start the CLI in the Hookspot terminal:
hookspot listen --forward-to http://localhost:3000The CLI adds /webhooks/orders from your route, so the full local address is http://localhost:3000/webhooks/orders. Leave the path out of --forward-to, or you’ll add it twice.
Send a successful webhook
First, check that the ordinary order reaches your app. In the third terminal, set WEBHOOK_URL to the complete private URL you copied from Hookspot:
export WEBHOOK_URL='paste the complete webhook URL here'From hookspot-node-example/, send the first file:
curl --request POST \ --header 'Content-Type: application/json' \ --data-binary @order-created.json \ "$WEBHOOK_URL"The curl response comes from Hookspot. It normally returns 200 after storing the request, even if your app later rejects it. Here, curl takes the place of the sender, the service that would normally send you a webhook.
To check whether your handler worked, look at the receiver terminal:
200 order.created ord_test_123 total=4200Your app returns:
{"received":true,"orderId":"ord_test_123","total":4200}HTTP 200You can also inspect it in the CLI. Select the request with the up or down arrow, then use the left or right arrow to switch tabs. Request shows the JSON you sent, and Response shows your app’s 200 and response body.
Trigger the zero-total bug
Now send the zero-total order from the same terminal:
curl --request POST \ --header 'Content-Type: application/json' \ --data-binary @order-created-zero.json \ "$WEBHOOK_URL"This time the receiver complains about a missing field:
422 order.created ord_test_zero: id and total are requiredThe response is:
{"error":"id and total are required"}HTTP 422Select the failed request in the CLI and open Request. The total is right there: "total": 0. Switch to Response and you’ll see that the handler rejected it anyway.
The problem is !total. JavaScript treats 0 as falsy, so the check rejects it along with a missing value. We’re using 422 here because it doesn’t trigger Hookspot’s automatic retries, giving you time to inspect the failure.
Fix the validation
In receiver.js, delete the Intentional bug comment and replace this condition:
if (typeof orderId !== 'string' || orderId.length === 0 || !total) {with a check for the value’s type:
if ( typeof orderId !== 'string' || orderId.length === 0 || typeof total !== 'number') {Zero is a number, so it now passes. A missing total or a string still fails.
Stop Node with Ctrl-C in the receiver terminal and restart it to load the change:
node receiver.jsLeave the Hookspot CLI running while you do this.
Replay the failed request
Go back to the CLI, select the failed zero-total request, and press r. It sends the same request to your app again, using the body you’ve already inspected.
The receiver should now print:
200 order.created ord_test_zero total=0And the response becomes:
{"received":true,"orderId":"ord_test_zero","total":0}HTTP 200The payload hasn’t changed. The handler now accepts the order because you fixed how it checks total.
If you look in the Hookspot dashboard, the original delivery still shows its failed outcome. That’s expected: CLI replays stay local, and the result appears in the CLI’s list. Use dashboard Retry when you need Hookspot to record another delivery attempt.
Before using real orders
This receiver is for local testing. It doesn’t verify provider signatures, authenticate requests, or limit body size, and it only logs orders rather than saving or processing them. Add the checks and order processing your application needs before using it outside this example.
Keep the zero-total request. It’s a useful input for a regression test that will fail if someone brings back !total. You can also use the same app to try recovering a webhook that arrived while your laptop was off.