Webhook Fanout Architecture: Delivering Events to Multiple Internal Consumers
Learn how to build a webhook fanout system that receives a single inbound event and reliably delivers it to multiple internal consumers without coupling or data loss.
You receive a single inbound webhook — say, a Stripe payment_intent.succeeded event — and three separate internal services need to act on it: the order service needs to mark the order paid, the email service needs to send a receipt, and the analytics service needs to record the conversion. If all three run in the same handler, a failure in one blocks the others, and a shared deployment boundary means a slow analytics write can cause your receipt email to time out.
Webhook fanout solves this by separating receipt from processing: receive once, deliver reliably to many consumers, independently.
The Problem with Monolithic Webhook Handlers
The naive approach is a single function that calls all three downstream services sequentially:
inbound POST → verify signature → process payment → send email → record analytics → return 200
This breaks in several ways:
- If the email service is slow, you risk the sender's timeout, triggering a retry for all three steps.
- If analytics fails, you need to retry everything, including the payment update that already succeeded.
- Coupling all three domains into one function means changing any one of them requires understanding — and testing — all of them.
- Partial failures produce inconsistent state with no clean recovery path.
The Fanout Pattern
The pattern separates two concerns: the gateway that receives and acknowledges events, and the consumers that process them.
[Inbound webhook]
|
[Gateway: verify signature, dedup, store event, return 200]
|
[Internal message bus / queue]
/ | \
[Order] [Email] [Analytics]
The gateway is minimal and never fails for business logic reasons. The consumers are independent and can fail, retry, or deploy without affecting each other.
Implementing the Gateway
The gateway does exactly four things and nothing more:
- Validate the HMAC signature.
- Check the event ID against the dedup store; return 200 immediately if already seen.
- Write the raw event payload to a message queue or event bus.
- Return 200.
No business logic. No downstream calls. It should complete in well under 500ms.
app.post('/webhooks/stripe', express.raw({ type: '*/*' }), async (req, res) => {
// 1. Verify signature
verifyStripeSignature(req);
// 2. Dedup
const event = JSON.parse(req.body);
const alreadySeen = await dedup.checkAndSet(event.id);
if (alreadySeen) return res.sendStatus(200);
// 3. Publish to internal bus
await eventBus.publish('stripe.events', {
eventId: event.id,
eventType: event.type,
payload: event,
receivedAt: new Date().toISOString()
});
// 4. Acknowledge
res.sendStatus(200);
});Message Bus Options
Redis Streams: Low latency, persistent, supports consumer groups. Consumer groups let multiple consumers read from the same stream independently, each tracking their own offset. Good for in-house infrastructure with moderate volume.
SQS (AWS): Managed, durable, supports dead-letter queues natively. Fan out by publishing to an SNS topic and subscribing multiple SQS queues — one per consumer. Each consumer has full isolation.
Kafka or Redpanda: High throughput, log-compaction, replay from any offset. Worth the operational overhead when event volume is high or when you need historical replay.
In-process with Postgres LISTEN/NOTIFY: Works well for teams already on Postgres who want to avoid a separate broker. Write the event to a table, use a trigger or polling worker to notify consumers.
Consumer Design Principles
Each consumer should be independently deployable and operate on its own retry schedule. Key properties:
Idempotent handlers: consumers may receive duplicates from the bus. Each consumer must check whether it has already processed a given event ID, just as the gateway checks at the entry point.
Independent retry budgets: if the email service is having trouble, its retry queue backs up without affecting the order service or analytics. Use separate dead-letter queues per consumer.
Explicit acknowledgment: do not acknowledge the message from the bus until you have successfully completed processing. Most message brokers require explicit ack — only ack after your database write or API call succeeds.
Consumer monitoring: track per-consumer lag (how far behind the consumer is from the head of the queue). Alert when lag grows unexpectedly.
Handling Event Type Routing
Not every consumer cares about every event type. Implement routing in the gateway or at the bus level:
- Topic-per-event-type: publish
stripe.payment_intent.succeededto one topic,stripe.customer.subscription.deletedto another. Consumers subscribe only to relevant topics. - Filter in the consumer: simpler but wastes compute; each consumer receives all events and filters locally.
- Router worker: a dedicated worker reads all events and routes them to consumer-specific queues based on event type.
Topic-per-event-type scales best and is easiest to reason about for large systems.
When to Reach for This Pattern
Fanout architecture adds operational complexity — you now have a message bus, consumer groups, and multiple worker processes to run. It is overkill for integrations with one or two consumers or low event volumes.
Reach for it when:
- Three or more internal services need to react to the same inbound event.
- Consumers have different SLAs (payment records need near-immediate processing; analytics can tolerate minutes).
- You need the ability to add new consumers without modifying existing code.
- You need to replay historical events to bootstrap a new service.
Designing event-driven integration architectures is a core part of what Clixo builds for product teams. If you are designing a system with multiple internal consumers and need the architecture to hold at scale, talk to us.