The Webhook Fan-Out Pattern: Routing One Event to Multiple Microservices
The Webhook Fan-Out Pattern: Routing One Event to Multiple Microservices When a customer completes a checkout on your platform, a single event occurs: a payment succeeds.

The Webhook Fan-Out Pattern: Routing One Event to Multiple Microservices
When a customer completes a checkout on your platform, a single event occurs: a payment succeeds. Inside a modern distributed system, though, that one event triggers a cascade of downstream actions across multiple microservices:
- Order Service — updates the core Postgres database (
orders.status = 'paid') - Notification Service — triggers a transactional receipt email
- Internal Ops — posts an alert to the
#sales-winsSlack channel - CRM Integration — updates customer lifetime value in HubSpot or Salesforce
- Data/Analytics — streams the transaction into a warehouse for reporting
If your backend receives a payment webhook directly from a provider like Stripe and tries to handle all five of those operations synchronously, inside one HTTP request handler, your system will break in predictable and well-documented ways. This is why software teams reach for the webhook fan-out pattern: ingest an event once, acknowledge it immediately, then distribute it asynchronously to every consumer that cares about it.
This article covers why inline webhook handling fails at scale, what real provider timeout budgets look like, how the fan-out pattern works end to end, how it relates to the older pub/sub fan-out pattern used in general messaging systems, and the current landscape of tools (open-source and managed) teams actually use to implement it.
The Naive Approach: Why Synchronous Webhook Handling Fails
In a naive architecture, your API exposes a single endpoint (e.g., POST /api/webhooks/stripe), and the handler synchronously works through every downstream dependency:
// ANTI-PATTERN: Monolithic synchronous webhook handler
app.post('/api/webhooks/stripe', async (req, res) => {
const event = req.body;
if (event.type === 'payment_intent.succeeded') {
try {
await updateDatabase(event.data); // DB write
await sendEmailReceipt(event.data); // External API call
await postSlackNotification(event.data); // External API call
await updateHubspotCRM(event.data); // External API call
return res.status(200).send({ received: true });
} catch (error) {
// Which step failed? What should we retry?
return res.status(500).send({ error: error.message });
}
}
});
The provider timeout problem is real — and stricter than most guides suggest
Webhook providers don't wait indefinitely for a response, and the exact budget varies more by provider than a lot of tutorials imply. Published, current figures:
| Provider | Response window before it's treated as a failure | Consequence of missing it |
|---|---|---|
| Stripe | ~20 seconds | Delivery marked "Timed out" in the dashboard; event is queued for retry |
| GitHub | ~10 seconds | Delivery marked failed; retried according to the hook's redelivery settings |
| Shopify | ~5 seconds | Delivery considered failed |
| Sentry | 1 second | If a webhook times out 1,000 times in 24 hours, Sentry auto-unsubscribes it |
Even Stripe's comparatively generous 20-second window disappears fast once you chain a database write, an email send, a Slack post, and a CRM update — especially with cold starts in a serverless environment, connection-pool setup, or a single slow third party in the chain.
The four fatal pitfalls of inline processing
- Head-of-line blocking. If any one downstream call (say, the CRM) suffers a latency spike, your entire webhook handler blocks behind it, and you risk blowing through the provider's timeout window for a completely unrelated reason.
- Cascading failures and duplicate delivery. If Slack returns a 503 partway through, the function throws, the handler returns a 5xx, and the provider retries the whole webhook later. On retry, the earlier steps (database write, email send) run again — duplicate charges recorded, duplicate receipts sent.
- Coupled latency and availability. Your API's uptime and response speed become hostage to the uptime of every third-party service you call inside the handler.
- The noisy-neighbor problem. A burst of high-frequency events (bulk price syncs, CI/CD webhooks, IoT telemetry) can exhaust your application's worker pool and take down endpoints that have nothing to do with webhooks.
What Is the Webhook Fan-Out Pattern?
The webhook fan-out pattern (also called event broadcasting) ingests a single incoming event once, acknowledges it immediately, and distributes it asynchronously into independent delivery pipelines — one per destination.
┌───────────────────────┐ ──> [Database Service]
│ Independent Queue 1 │
└───────────────────────┘
▲
┌────────────────┐ ┌──────────┐ │
│ Webhook Source │ ──> │ Ingestion│ ────┼───────────────────> [Email Service]
│ (e.g., Stripe) │ │ Gateway │ │
└────────────────┘ └──────────┘ ▼
┌───────────────────────┐ ──> [Slack Webhook]
│ Independent Queue 3 │
└───────────────────────┘
Instead of calling destinations sequentially inside a request handler, the event is written to durable storage or a broker, and the fan-out engine creates N independent delivery tasks for N destination URLs. Each destination:
- Fails independently — a broken Slack webhook doesn't delay the database sync.
- Retries on its own schedule, with its own backoff and its own dead-letter queue (DLQ).
- Doesn't block ingestion from the primary event source.
This isn't a new idea — it's the pub/sub fan-out pattern applied to webhooks
Fan-out predates webhook tooling specifically. On AWS, the canonical version is publishing once to an SNS topic that broadcasts to multiple SQS queues, each consumed independently — the same "publish once, deliver everywhere" shape, just built from general-purpose messaging primitives rather than an HTTP-webhook-specific product. If you already run SNS/SQS, EventBridge, Kafka, or a similar broker, you can build webhook fan-out on top of it directly; dedicated webhook infrastructure exists mainly to add the HTTP-specific and consumer-facing pieces — signature verification, per-endpoint dashboards, replay UIs — on top of that same broadcast idea.
Architectural comparison
| Feature | Monolithic synchronous handling | Webhook fan-out pattern |
|---|---|---|
| Ingestion speed | Slow (sum of every downstream call) | Fast (event stored, ack returned immediately) |
| Fault isolation | Poor — one destination failing breaks all | High — destinations fail independently |
| Retry granularity | All-or-nothing, causes duplicate side effects | Per-destination retry schedules |
| Extensibility | Requires touching core API code | Register a new target URL in configuration |
| Observability | Scattered across application logs | Centralized, per-endpoint delivery history |
Anatomy of a Fan-Out Engine
A production-grade fan-out setup generally has four layers:
[Ingestion Layer] ──> [Storage & Fan-Out] ──> [Dispatch Workers] ──> [Destinations]
(verify + accept) (queue expansion) (parallel delivery) (microservices)
1. Ingestion layer. A stable, high-availability endpoint that verifies the payload's signature (Stripe-Signature, X-Hub-Signature-256, etc.), writes the raw payload to durable storage, and returns a 2xx immediately — before doing any downstream work.
2. Fan-out and routing engine. Once stored, the engine checks its destination registry: which endpoints are subscribed to this event type? It creates a discrete delivery job per matching destination, applying any filtering or payload-transformation rules along the way.
3. Asynchronous worker pool. Background workers lease jobs and execute the outbound HTTP POSTs in parallel, with concurrency and per-destination rate limits (e.g., a hard cap on requests/sec to Slack) so one target's limits don't affect deliveries to another.
4. Retry engine and dead-letter queue. Transient errors (429, 502, 503, timeouts) trigger a backoff-and-retry for that specific delivery job only. Once retries are exhausted, the event lands in that destination's DLQ for inspection and manual replay — other destinations are unaffected.
For the backoff scheduling itself, the standard reference is Marc Brooker's 2015 AWS Architecture Blog post on exponential backoff and jitter: pure exponential backoff still produces synchronized retry bursts after an outage, because many clients hit the same delay values at the same time; adding randomized jitter spreads those retries out and avoids a second thundering herd when the downstream service comes back up. Most AWS SDKs — and most serious webhook infrastructure — implement some form of this today.
Signing and Verifying: A (Slowly) Standardizing Mess
Every provider signs its webhooks a little differently: Stripe uses Stripe-Signature, GitHub uses X-Hub-Signature-256, and plenty of platforms roll their own HMAC scheme with their own header names. A fan-out engine sitting between many inbound providers and many outbound consumers has to handle that heterogeneity on the way in, then give its own consumers something consistent on the way out.
That's the gap the open-source Standard Webhooks specification is trying to close. It defines a common approach — HMAC-SHA256 (with an option for asymmetric signatures), a signed timestamp to reject stale replays, and a consistent header format — so that consumers can use a shared verification library instead of hand-rolling HMAC logic for every provider they integrate with. It's backed by several companies in the webhook-infrastructure space and has reference implementations in multiple languages, though adoption among the big platform providers (Stripe, GitHub, Shopify) is still partial — most still use their own legacy header formats.
Regardless of which scheme a source uses, the verification logic downstream should always do three things: recompute the HMAC over the raw request body (not a re-serialized copy, which breaks the signature), compare it using a timing-safe equality check, and reject requests whose timestamp is too old to guard against replay.
Build vs. Buy: The Managed Webhook Infrastructure Landscape
Once you accept that fan-out needs a queue, a worker pool, retry/backoff logic, and a way to inspect and replay failed deliveries, teams generally pick one of three paths:
DIY on general-purpose messaging. SNS → multiple SQS queues (or EventBridge, or Kafka) plus Lambda/worker consumers. Full control, and you likely already have the infrastructure — but you're on the hook for the consumer-facing bits: a dashboard, replay tooling, per-endpoint throttling, and signature handling.
Self-hosted / open-source webhook servers. Projects like Svix's open-source core or Hookdeck's Outpost give you a delivery engine you run yourself, with retries, replay, and (in Svix's case) a full feature set including FIFO ordering and payload transformations built in.
Managed webhook infrastructure vendors. A hosted layer that handles ingestion, fan-out, retries, signing, and a delivery-timeline UI for you. As of mid-2026 the field includes:
- Svix — the most feature-complete of the managed/open-core options, with FIFO delivery, transformations, per-endpoint throttling, and compliance certifications aimed at teams sending webhooks out to their own customers.
- Hookdeck / Hookdeck Outpost — started as an inbound webhook debugging and queuing tool, has since added an outbound "Outpost" product; usage-based pricing that's inexpensive per event but with a narrower feature set than Svix.
- Hook0 — a small, bootstrapped, EU-based option, source-available, aimed at teams that need EU-only data residency and don't need massive volume.
- Convoy — open-source and still usable, but worth knowing the company behind it wound down as an active business; it's now maintained more as a side project, so treat it as a self-hosting-only option rather than a production vendor relationship.
- InstaWebhook — a hosted ingestion + fan-out gateway focused on a single durable ingest URL per project, per-destination delivery timelines, one-click replay, HMAC-signed outgoing requests, and a "bring your own Postgres" mode for teams that need payloads to stay inside their own infrastructure.
Public pricing shifts constantly and vendor comparison pages are, unsurprisingly, written by the vendors — treat head-to-head claims (uptime numbers, feature gaps in competitors) with the same skepticism you'd apply to any competitive marketing copy, and verify current numbers on each vendor's own pricing page before committing.
Worked Example: Routing a Stripe Event Through a Managed Gateway
The mechanics look similar across most managed fan-out tools. Using InstaWebhook as a concrete example:
1. Create an ingestion endpoint. The dashboard generates a unique ingest URL, e.g. https://instawebhook.com/api/ingest/YOUR_ENDPOINT_TOKEN. This is the single URL you register with Stripe under Developers → Webhooks, subscribed to payment_intent.succeeded.
2. Attach downstream destinations. Inside the tool, you register the URLs that should each get a copy of matching events:
[
{
"name": "Database Sync Service",
"target_url": "https://api.yourdomain.com/v1/webhooks/orders",
"filter_events": ["payment_intent.succeeded"],
"max_retries": 5
},
{
"name": "Transactional Email Worker",
"target_url": "https://email-service.internal.yourdomain.com/webhooks/stripe",
"filter_events": ["payment_intent.succeeded"],
"max_retries": 3
},
{
"name": "Slack Operations Bot",
"target_url": "https://hooks.slack.com/services/T00/B00/X00",
"filter_events": ["payment_intent.succeeded", "charge.failed"],
"max_retries": 2
}
]
3. Failure isolation in practice. On a real purchase: Stripe posts to the ingest URL, the gateway stores the event and acknowledges Stripe well within its ~20-second window, then expands the event into three independent delivery jobs. If the database and email destinations both return 200 but the Slack webhook times out, only the Slack delivery goes into backoff-and-retry — the other two pipelines are unaffected. (Exact ingestion latency and specific SLA numbers vary by vendor and should be checked against the provider's own published docs rather than assumed.)
Overcoming the Distributed-Systems Challenges Fan-Out Introduces
Fan-out solves coupling and latency, but it doesn't remove the underlying challenges of distributed delivery — it just isolates them per destination.
1. Idempotency and duplicate prevention
Webhooks operate on at-least-once delivery semantics. Network retries, timeout ambiguity, and manual replays all mean downstream consumers will eventually see the same event more than once. (This is also why Stripe's own idempotency keys, for example, are honored for 24 hours — the same order of magnitude used in the example below.)
// Downstream endpoint: idempotency guard
async function handleWebhook(req, res) {
const eventId = req.headers['x-instawebhook-event-id'] || req.body.id;
const lockKey = `idempotency:${eventId}`;
// Only set the key if it doesn't already exist (24h expiry)
const isNewEvent = await redis.set(lockKey, 'processing', 'EX', 86400, 'NX');
if (!isNewEvent) {
console.log(`[IDEMPOTENCY] Duplicate event received: ${eventId}. Skipping.`);
return res.status(200).json({ status: 'ignored', reason: 'duplicate_event' });
}
try {
await processBusinessLogic(req.body);
await redis.set(lockKey, 'completed', 'EX', 86400);
return res.status(200).json({ status: 'success' });
} catch (error) {
await redis.del(lockKey); // release the lock so a retry can actually run
return res.status(500).json({ error: 'Processing failed' });
}
}
2. Out-of-order delivery
Because fan-out workers process destinations concurrently, events can arrive out of sequence — an order.updated might land before order.created finishes retrying. Guard against this with a monotonic sequence number or timestamp, and discard (or hold) anything older than the last-applied update:
async function updateCustomerRecord(customerId, payloadTimestamp, newData) {
const customer = await db.findCustomer(customerId);
if (customer && new Date(customer.last_updated_at) > new Date(payloadTimestamp)) {
console.warn(`[ORDERING] Stale event received for Customer ${customerId}. Discarding.`);
return;
}
await db.updateCustomer(customerId, { ...newData, last_updated_at: payloadTimestamp });
}
3. Noisy-neighbor / rate-limit protection
A burst of thousands of events in a few seconds shouldn't hit every destination at full speed — a database can absorb far more throughput than, say, Slack's API. Per-destination rate limiting (and a leaky-bucket or token-bucket limiter for stricter targets) keeps one high-traffic destination from starving or overwhelming another.
Production-Style Downstream Consumer
A representative Node/Express consumer for a fanned-out webhook: verify the signature, check idempotency, then process.
const express = require('express');
const crypto = require('crypto');
const Redis = require('ioredis');
const app = express();
const redis = new Redis(process.env.REDIS_URL);
const WEBHOOK_SECRET = process.env.WEBHOOK_SIGNING_SECRET;
// Capture the raw body — signature verification must run against the exact bytes sent
app.use(express.json({
verify: (req, res, buf) => { req.rawBody = buf.toString(); }
}));
function verifyWebhookSignature(req, res, next) {
const signatureHeader = req.headers['x-webhook-signature'];
const timestampHeader = req.headers['x-webhook-timestamp'];
if (!signatureHeader || !timestampHeader) {
return res.status(401).send('Missing webhook signature or timestamp headers');
}
// Reject anything older than 5 minutes to guard against replay
const fiveMinutesAgo = Math.floor(Date.now() / 1000) - 300;
if (parseInt(timestampHeader, 10) < fiveMinutesAgo) {
return res.status(401).send('Webhook timestamp too old');
}
const payloadToSign = `${timestampHeader}.${req.rawBody}`;
const expectedSignature = crypto
.createHmac('sha256', WEBHOOK_SECRET)
.update(payloadToSign)
.digest('hex');
const timingSafeMatch = crypto.timingSafeEqual(
Buffer.from(signatureHeader),
Buffer.from(expectedSignature)
);
if (!timingSafeMatch) {
return res.status(401).send('Invalid signature: verification failed');
}
next();
}
app.post('/v1/webhooks/orders', verifyWebhookSignature, async (req, res) => {
const event = req.body;
const eventId = req.headers['x-webhook-event-id'] || event.id;
const lockKey = `webhook_processed:${eventId}`;
const setSuccess = await redis.set(lockKey, 'true', 'EX', 86400, 'NX');
if (!setSuccess) {
return res.status(200).json({ message: 'Event already processed' });
}
try {
if (event.type === 'payment_intent.succeeded') {
await db.orders.updateStatus(event.data.order_id, 'PAID');
}
return res.status(200).json({ status: 'completed' });
} catch (err) {
await redis.del(lockKey); // let a retry try again
console.error(`[WORKER ERROR] ${err.message}`);
return res.status(500).json({ error: 'Internal processing error' });
}
});
app.listen(3000, () => console.log('Downstream webhook service running on port 3000'));
Architectural Best Practices Checklist
- Acknowledge fast. Return 2xx well inside the provider's timeout budget (see the table above) — don't make external calls before you respond.
- Enforce idempotency on every downstream consumer using a unique event key, not just "hope it doesn't duplicate."
- Verify signatures on both ingestion and, ideally, downstream re-delivery — don't trust the network path in between.
- Isolate queues per destination so one slow or broken service never blocks the others.
- Configure dead-letter queues with alerting once retries are exhausted.
- Use exponential backoff with jitter, not fixed-interval retries, to avoid synchronized retry storms.
- Prefer a shared signing standard (or at least document your own scheme clearly) so consumers aren't reverse-engineering your HMAC format.
Summary
Coupling microservices directly to an inbound webhook handler produces brittle systems: delayed responses that blow past provider timeout windows, duplicate operations on retry, and one flaky downstream dependency taking the whole pipeline down with it. The fan-out pattern — ingest once, acknowledge immediately, deliver to every destination independently — isn't webhook-specific; it's the same pub/sub broadcast idea used in general-purpose messaging (SNS→SQS, Kafka consumer groups) applied to inbound HTTP events. Whether you build it on top of infrastructure you already run or adopt one of the managed gateways in this space (Svix, Hookdeck, Hook0, InstaWebhook, and others), the core requirements are the same: fast acknowledgment, per-destination isolation, idempotent consumers, and backoff-with-jitter retries.
Sources & further reading
- Stripe: investigating and fixing "Timed out" webhooks
- Svix — Webhook Timeout Best Practices
- GitHub Docs — Validating webhook deliveries
- Sentry — Integration Platform webhook timeout changelog
- Standard Webhooks specification (GitHub)
- AWS Architecture Blog — Exponential Backoff and Jitter
- Hook0 — Webhook cost comparison, August 2026
- InstaWebhook
Pricing, uptime figures, and vendor feature sets in the landscape section change frequently — verify current details on each vendor's own site before publishing or making a purchasing decision.