I ran into a nasty issue last week: Stripe fired a single checkout webhook, and I needed to do three things at once—update my database, ping Slack, and sync the user’s data to our CRM. Doing this sequentially inside a single Express route is a terrible idea. If the CRM API takes 8 seconds to respond (which it often does), Stripe times out, retries, and suddenly my database is dealing with duplicate events.
We are going to build a lightweight webhook router in Node.js. It takes the incoming payload, immediately fires back a 202 Accepted status, and then routes the data to our downstream endpoints in the background. We’ll isolate each request so that if one endpoint fails, it doesn’t bring down the rest.

Why simple webhook forwarding breaks in production
When you first try this, you’ll probably just write a simple for loop inside your Express route handler. You loop through your target URLs, make an HTTP POST to each, and wait for them all to finish before responding to the sender.
This falls apart instantly in production for three main reasons:
- Accumulated latency: If Endpoint A takes 200ms, Endpoint B takes 1.5 seconds, and Endpoint C takes 3 seconds, your client waits 5 seconds. Webhook providers like Stripe or GitHub will drop the connection if you don’t respond within a few seconds.
- Cascading failures: If Endpoint A throws a 500 and crashes your loop, Endpoints B and C never get the data.
- No retries: If one endpoint is down for maintenance, that data is gone forever unless you have a way to retry just that specific failed target.
To fix this, we have to decouple receiving the webhook from delivering it. The receiver should grab the payload, say “got it” immediately, and let a background process handle the distribution.
Setting up the project
Let’s set up a clean, minimal project. We’ll use Express to receive the webhooks and Axios to forward them. We’ll organize our files to keep the router logic separated from our server configuration.
If you want to read more about organizing your backend code, check out our guide on how to setup the best folder structure for your next project.
Run these commands to initialize the project and install what we need:
mkdir webhook-router
cd webhook-router
npm init -y
npm install express axios dotenv
npm install --save-dev nodemon
Next, create a .env file in your root folder. In production, you’d probably pull these targets from a database, but environment variables are perfect for getting this up and running.
PORT=3000
TARGET_ENDPOINT_SLACK=https://httpbin.org/post?target=slack
TARGET_ENDPOINT_CRM=https://httpbin.org/post?target=crm
TARGET_ENDPOINT_ANALYTICS=https://httpbin.org/post?target=analytics
Building the Express receiver
Now let’s write our main server entry point in server.js. This script starts an Express server, listens for incoming POST requests, and hands the payload over to our routing engine.
We are using the official Express framework to handle the HTTP routing. This code sets up the basic middleware and runs the server on our designated port.
require('dotenv').config();
const express = require('express');
const { routeWebhook } = require('./router'); const app = express();
app.use(express.json()); app.post('/webhook', (req, res) => { const payload = req.body; const headers = req.headers; // 1. Immediately acknowledge receipt res.status(202).json({ status: 'accepted', message: 'Webhook queued for routing' }); // 2. Process routing asynchronously in the background setImmediate(() => { routeWebhook(payload, headers).catch(err => { console.error('Background routing failed:', err.message); }); });
}); const PORT = process.env.PORT || 3000;
app.listen(PORT, () => { console.log(`Webhook receiver listening on port ${PORT}`);
});
The magic here is setImmediate(). It pushes the execution of our routing function to the next iteration of the Node.js event loop. Express can send the 202 response back to the client instantly without waiting for the downstream HTTP requests to finish.
Writing the routing logic (without cascading failures)
Now let’s build the actual routing engine in router.js. We need to make sure a failure in one destination doesn’t stop the others. We’ll use Promise.allSettled() to run all forwarding requests concurrently and inspect the results of each individual request.
We will use Axios to perform the outgoing POST requests. This ensures we have clean timeout configurations and simple request bodies.
const axios = require('axios'); const TARGETS = [ process.env.TARGET_ENDPOINT_SLACK, process.env.TARGET_ENDPOINT_CRM, process.env.TARGET_ENDPOINT_ANALYTICS
].filter(Boolean); async function routeWebhook(payload, originalHeaders) { console.log(`Starting routing for payload to ${TARGETS.length} endpoints...`); const deliveryPromises = TARGETS.map(async (url) => { try { const response = await axios.post(url, payload, { headers: { 'Content-Type': 'application/json', 'X-Forwarded-By': 'NodeJS-Webhook-Router', // Forward specific security headers if needed, e.g., signature 'X-Webhook-Signature': originalHeaders['x-webhook-signature'] || '' }, timeout: 5000 // 5 second timeout per target }); return { url, status: 'success', statusCode: response.status }; } catch (error) { const statusCode = error.response ? error.response.status : 'TIMEOUT_OR_NETWORK_ERROR'; return { url, status: 'failed', statusCode, error: error.message }; } }); const results = await Promise.allSettled(deliveryPromises); results.forEach((result) => { if (result.status === 'fulfilled') { const detail = result.value; if (detail.status === 'success') { console.log(`[SUCCESS] -> ${detail.url} (Status: ${detail.statusCode})`); } else { console.error(`[FAILURE] -> ${detail.url} (Reason: ${detail.error}, Status: ${detail.statusCode})`); // Here is where you would trigger a retry queue } } else { console.error('[UNEXPECTED ERROR] Promise rejected entirely:', result.reason); } });
} module.exports = { routeWebhook };
This code isolates each request. If our CRM target returns a 500 or times out, the Slack and Analytics requests still execute normally. We catch the error inside the map function, which prevents the entire Promise.allSettled array from failing.
Testing it out locally
Let’s run our server and simulate an incoming webhook payload. We’ll use nodemon to start our server so it restarts automatically when we make changes.
Run this command to start your development server:
npx nodemon server.js
Now, open a second terminal window and use curl to send a mock webhook payload to your local server:
curl -X POST http://localhost:3000/webhook
-H "Content-Type: application/json"
-H "X-Webhook-Signature: t=1672531199,v1=9997672"
-d '{"event": "user.created", "data": {"id": "usr_123", "email": "test@example.com"}}'
In your server terminal, you will see output similar to this:
Webhook receiver listening on port 3000
Starting routing for payload to 3 endpoints...
[SUCCESS] -> https://httpbin.org/post?target=slack (Status: 200)
[SUCCESS] -> https://httpbin.org/post?target=crm (Status: 200)
[SUCCESS] -> https://httpbin.org/post?target=analytics (Status: 200)
Notice that your curl command completed almost instantly, while the logs for the forwarding operations appeared a split second later. This proves our asynchronous routing is working.
What about retries?
While our current setup prevents failures from cascading, we still lose payloads if an endpoint is down when we try to forward to it. In production, you need a way to retry failed destinations without retrying the successful ones.
For a production-grade system, you should offload these retries to a queue system like BullMQ. If you are ready to build a fully resilient system, you can learn how to build a webhook retry system with exponential backoff in Node.js.
If your endpoints have strict rate limits, you will also need to throttle your outgoing connections. You can read our tutorial on how to handle external API rate limits with BullMQ and Redis to prevent getting blocked by downstream services.
Frequently Asked Questions
What happens if the Node.js process crashes mid-routing?
If you use setImmediate(), the queue exists only in the memory of your running Node.js process. If your server crashes or restarts while routing is in progress, those pending deliveries are lost. For critical financial or user data, you must persist the incoming webhook to a database or a Redis instance before acknowledging receipt.
How do I verify the authenticity of the incoming webhook?
You should verify the signature of the incoming webhook *before* sending the 202 Accepted response. If the signature is invalid, reject the request with a 401 Unauthorized status immediately to prevent bad actors from spamming your router.
Can I forward the original request headers to the target endpoints?
Yes, but be selective. Do not forward headers like Host, Content-Length, or connection headers, as these can confuse the destination server. Forward security tokens or custom event identifiers specifically using custom keys.
Next Steps
Now that you have a working webhook router, the next logical step is to secure your incoming endpoints and manage delivery failures. Take a look at our guide on how to build a webhook retry system with exponential backoff in Node.js to make your routing architecture production-ready.

