Send One Webhook to Multiple Endpoints: Fan-Out Patterns
Stripe fires a payment_intent.succeededevent. Production needs it to fulfill the order. Staging needs it to test the new fulfillment code against real payload shapes. The data team wants it in their pipeline. And someone in #payments wants a Slack message when a charge fails. One event, four destinations — and the provider gives you a webhook URL field that expects exactly one answer.
This article covers webhook fan-out: how to send one webhook to multiple endpoints without losing deliveries, breaking signatures, or building a distributed system you didn't sign up for. We'll walk through the DIY patterns honestly — queues, relays, serverless functions — and then the managed approach with Hooklistener. If you just want a URL that captures everything so you can experiment, grab a free webhook tester endpoint first.
Why You Can't Just Register More URLs at the Provider
The obvious first move is to add a second webhook URL in the provider's dashboard. Sometimes that works. Often it doesn't, and even when it does, it ages badly.
Some providers cap you at one URL
Plenty of SaaS products — especially smaller APIs and internal platforms — let you configure exactly one webhook URL per account or per event type. There is no second slot. If you need two consumers, the provider can't help you.
Multiple endpoints multiply configuration drift
Stripe allows multiple webhook endpoints, and GitHub lets you add several webhooks per repository. But each endpoint gets its own signing secret, its own event subscription list, and its own failure state. Six months in, nobody remembers which endpoint subscribes to which events, the staging endpoint has been silently failing for weeks (and some providers auto-disable endpoints that keep failing), and rotating secrets means touching every consumer separately.
Some destinations can't be registered at all
Shopify ties webhook subscriptions to the app that created them, so a second team can't simply attach their consumer. A Slack incoming webhook expects a specific JSON shape, not a raw Stripe payload, so registering it directly is useless. And your laptop behind a NAT can't be registered anywhere without a tunnel.
No single place to see what was delivered
With three provider-side endpoints, answering “did everyone receive event evt_123?” means checking three delivery logs in the provider's dashboard — if the provider keeps delivery logs at all. Many keep them for a few days or not at all.
The structural fix is the same one event-driven architectures always land on: receive the event once, at one well-monitored entry point, and fan it out yourself. The question is who builds and operates the fan-out layer.
DIY Pattern 1: A Message Queue (SNS/SQS, Pub/Sub)
The textbook answer. You stand up one receiver endpoint that does nothing except publish the webhook to a topic — AWS SNS fanning out to SQS queues, or Google Pub/Sub with one subscription per consumer. Each downstream system consumes at its own pace with its own retry policy.
import express from "express";
import { SNSClient, PublishCommand } from "@aws-sdk/client-sns";
const app = express();
const sns = new SNSClient({});
// The ONLY URL the provider knows about
app.post(
"/webhooks/stripe",
express.raw({ type: "*/*" }), // keep the raw bytes
async (req, res) => {
await sns.send(
new PublishCommand({
TopicArn: process.env.WEBHOOK_TOPIC_ARN,
Message: req.body.toString("base64"), // raw body, encoded
MessageAttributes: {
"stripe-signature": {
DataType: "String",
StringValue: req.headers["stripe-signature"] ?? "",
},
"content-type": {
DataType: "String",
StringValue: req.headers["content-type"] ?? "",
},
},
})
);
res.status(200).end();
}
);Notice the ceremony already creeping in: the body has to travel as base64 because SNS messages are strings, and the signature header has to ride along as a message attribute so consumers can still verify it. Every consumer now needs decode logic before it can do anything.
Where it shines
- • Genuinely durable — messages survive consumer outages
- • Per-consumer retry policies and dead-letter queues
- • Scales to very high volume without re-architecture
- • Everything stays inside your VPC if compliance requires it
What you now own
- • The receiver service itself — deploys, certs, monitoring
- • Topic, queues, DLQs, IAM policies, alarms per consumer
- • Ordering is not guaranteed end-to-end without FIFO queues and more work
- • Non-queue destinations (Slack, partner APIs) still need an HTTP-pushing consumer you write
- • Debugging means tracing one event across four AWS consoles
DIY Pattern 2: A Small Relay Service
If a queue feels heavy, the next instinct is a tiny HTTP relay: one endpoint that receives the webhook, ACKs immediately, and POSTs a copy to each destination. Forty lines of Express, what could go wrong?
const DESTINATIONS = [
"https://api.example.com/webhooks/stripe", // production
"https://staging.example.com/webhooks/stripe", // staging
"https://pipeline.example.com/ingest/stripe", // data pipeline
];
app.post(
"/webhooks/stripe",
express.raw({ type: "*/*" }),
async (req, res) => {
// ACK first -- never make the provider wait on your fan-out
res.status(200).end();
const results = await Promise.allSettled(
DESTINATIONS.map((url) =>
fetch(url, {
method: "POST",
headers: {
"content-type":
req.headers["content-type"] ?? "application/json",
"stripe-signature": req.headers["stripe-signature"] ?? "",
},
body: req.body, // raw Buffer -- byte-for-byte
})
)
);
// Staging returned 500. Now what? Retry? How many times?
// With what backoff? Logged where? Alerted to whom?
}
);The happy path really is forty lines. The unhappy path is the product. Staging is down for a deploy — do you retry, and does your retry state survive the relay restarting? The data pipeline is slow — do its timeouts delay anything else? Your process crashes after ACKing but before forwarding — that event is simply gone, and the provider believes it was delivered. Solving these properly means adding persistence and a retry scheduler, at which point you've rebuilt the queue pattern with extra steps. Our guide on webhook retries best practices covers how deep that rabbit hole goes.
DIY Pattern 3: A Serverless Function
Same relay logic, deployed as a Lambda function or Cloudflare Worker instead of a server. This is a real improvement on the operational side: no host to patch, scales to zero, and the platform gives you basic invocation logs.
But the hard parts of fan-out are not hosting problems, and serverless doesn't make them disappear:
- • Retries: a failed downstream POST still needs durable retry state. You end up bolting on SQS or a Durable Object anyway.
- • Execution limits: forwarding to a slow destination eats your invocation time budget; Workers need
waitUntilbookkeeping to forward after responding. - • Observability: CloudWatch tells you the function ran, not which of four destinations got the payload and what each one answered. You build that logging yourself.
- • Replay: when a destination was down for an hour, re-driving the missed events means you stored them somewhere first.
The Signature Caveat: HMAC Validates the Raw Bytes
This one bites almost everyone who builds a relay. Providers sign webhooks with an HMAC computed over the exact bytes of the request body. Your downstream consumers verify by recomputing the HMAC over the body they receive. If your relay changes even one byte, verification fails everywhere.
// BROKEN: express.json() parses the body, and re-serializing
// it produces different bytes (key order, whitespace, unicode
// escapes, number formatting). The HMAC will never match.
app.use(express.json());
app.post("/relay", async (req, res) => {
await fetch(destination, {
method: "POST",
body: JSON.stringify(req.body), // <- different bytes
});
});
// CORRECT: capture the raw buffer and pass it through untouched.
app.post("/relay", express.raw({ type: "*/*" }), async (req, res) => {
await fetch(destination, {
method: "POST",
headers: { "stripe-signature": req.headers["stripe-signature"] },
body: req.body, // <- the original bytes
});
});Three rules for any relay you build:
- • Pass the body through byte-for-byte. No parsing, no re-encoding, no “helpful” pretty-printing.
- • Forward the signature and timestamp headers (
Stripe-Signature,X-Hub-Signature-256,X-Shopify-Hmac-Sha256, etc.) so consumers can verify against the original secret. - • Watch the timestamp tolerance: if your relay retries a delivery hours later, consumers enforcing replay protection will reject it even though the signature is valid. Decide deliberately whether retried deliveries skip the timestamp check or get re-signed with an internal secret.
If signing is fuzzy territory, read our HMAC verification best practices guide before building anything — raw-body handling is the difference between a relay that works and one that silently breaks every consumer's security check.
The Managed Approach: Fan-Out with Hooklistener
The alternative to owning a fan-out layer is pointing the provider at a Hooklistener endpoint and letting it do the duplication. Every webhook is captured first — full headers, body, timing — so you get inspection, search, and replay for free, and then three mechanisms handle the fan-out. The forwarding features below are on paid plans.
Option A: Auto-forwarding destinations (copy everything)
Auto-forwarding is the “duplicate this webhook to N URLs” primitive. You add destinations to an endpoint, and every incoming webhook is automatically forwarded as a copy to each enabled destination — no code, no conditions. This is distinct from manual forwarding (which also exists: pick any captured request in the dashboard and forward it to a URL on demand). Setup looks like this:
- 1. Create an endpoint and register its URL (e.g.
https://my-project.hook.events/stripe) as the single webhook URL at your provider. - 2.In the endpoint's forwarding settings, add a destination per consumer: production API, staging, data pipeline.
- 3.Optionally set custom headers per destination — an internal auth token for the pipeline, an
x-environmenttag for staging. - 4. Enable each destination. From now on every captured webhook is copied to all of them, with automatic retries with backoff when a destination is down.
Because each destination receives a copy of the captured request, the original body travels through unchanged and provider signatures keep validating downstream. A destination doesn't have to be a server, either — point one at a CLI tunnel URL and production webhooks mirror straight to your laptop while you debug.
Option B: Scripts (conditional fan-out in JavaScript)
When “copy everything everywhere” is too blunt, a Script gives you code-level control. You write a JavaScript handle(request)function that runs after each captured webhook and returns up to 10 outbound HTTP actions — objects with url, method, headers, and body. Here's a realistic router by event type:
function handle(request) {
const body = request.body || {};
const event = String(body.type || "");
const actions = [];
// Every event goes to the data pipeline
actions.push({
url: "https://pipeline.example.com/ingest/stripe",
method: "POST",
headers: {
"content-type": "application/json",
"x-pipeline-token": "internal-token",
},
body: body,
});
// Payment events are also mirrored to staging
if (event.startsWith("payment_intent.")) {
actions.push({
url: "https://staging.example.com/webhooks/stripe",
method: "POST",
headers: {
"content-type": "application/json",
"x-mirrored-from": "production",
},
body: body,
});
}
// Failures and disputes notify the on-call Slack channel,
// reshaped into the payload Slack actually expects
if (
event === "payment_intent.payment_failed" ||
event === "charge.dispute.created"
) {
actions.push({
url: "https://hooks.slack.com/services/T0000/B0000/XXXXXXXX",
method: "POST",
headers: { "content-type": "application/json" },
body: {
text:
"Stripe event needs attention: " +
event +
" (" +
String(body.data?.object?.id || "unknown") +
")",
},
});
}
return actions; // up to 10 actions per run
}The execution model is what makes this safe to put in a production delivery path:
- • Actions are executed asynchronously with retriesafter capture. A failing destination never affects the response already sent to the webhook source — Stripe still gets its 200.
- • The script runs in an isolated sandbox with a 5-second CPU timeout and no network access from the script itself. It decides whatto send; Hooklistener's delivery layer does the actual HTTP.
- • You can test the script from the UI against the endpoint's latest captured request and see exactly which actions it would emit before enabling it. No deploy-and-pray.
One honest caveat: a Script constructs new outbound bodies, so destinations receiving script output should treat it as an internal call (authenticate with your own header or token) rather than re-verifying the provider's HMAC. If a destination must verify the original signature, use an auto-forwarding destination for it instead, since that passes the captured request through as-is. For deeper coverage of the sandbox, see Run Custom JavaScript on Every Webhook.
Option C: Automations with HTTP Request actions
Automations are the no-code middle ground. An automation runs per webhook, uses condition steps to decide routing, and its HTTP Request action calls any external API with values interpolated from the captured request:
{
"type": "http_request",
"phase": "async",
"config": {
"method": "POST",
"url": "https://pipeline.example.com/ingest/stripe",
"headers": {
"content-type": "application/json",
"x-event-type": "$request.body.type$"
},
"body": "$request.body$"
}
}Pair it with a condition step (“body field type starts with invoice.”) and you have content-based routing without writing JavaScript. For a full walkthrough of condition-driven pipelines, see Conditional Webhook Routing and Transform Webhook Payloads Without Writing a Server.
DIY vs Managed: Decision Table
| Concern | Queue (SNS/Pub/Sub) | DIY relay / function | Hooklistener |
|---|---|---|---|
| Time to first fan-out | Days | Hours | Minutes |
| Retries with backoff | Per consumer, you configure | You build it | Built in |
| Byte-for-byte body (HMAC survives) | If you're careful | If you're careful | Yes (auto-forwarding) |
| Conditional routing | Subscription filters | Custom code | Scripts / Automations |
| Per-destination headers | Consumer-side code | Custom code | Built in |
| Delivery logs & inspection | You build it | You build it | Captured + searchable |
| Replay missed events | DLQ re-drive | Only if you stored them | One click from history |
| Strict ordering guarantees | Yes (FIFO, with work) | No | No (HTTP fan-out) |
| Stays inside your VPC | Yes | Yes | No (managed service) |
| Ongoing operational cost | Highest | Medium, grows over time | Subscription, near-zero ops |
The honest summary: build the queue pattern when webhook fan-out is core infrastructure for you — very high volume, strict ordering, or compliance that forbids a third-party hop. Use a managed layer when fan-out is plumbing you want to stop thinking about, and you'd rather get capture, inspection, and a webhook debugger in the same move. The DIY relay is the option to be most skeptical of: it looks cheapest on day one and quietly becomes the queue pattern's workload without its guarantees.
Fan Out Your First Webhook in Minutes
Create an endpoint, point your provider at it, and add your destinations — production, staging, a pipeline, a Slack hook. Every delivery is captured, retried with backoff, and replayable when something downstream was having a bad day. No relay service to deploy, no queue to babysit.
Start Forwarding Webhooks →Related Resources
Conditional Webhook Routing
Route one webhook stream to different services based on payload content
Run Custom JavaScript on Every Webhook
Sandboxed scripting, action limits, and forwarding without a server
Webhook Signing & HMAC Verification
Raw-body verification, replay protection, and key rotation done right