# 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.

- **Published:** 2025-04-15
- **Author:** Clixo
- **Reading time:** 5 min read
- **Tags:** webhooks, architecture, fanout, event-driven, advanced
- **Canonical URL:** https://clixo.sh/blog/webhook-fanout-architecture-multiple-consumers

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.

```mermaid
flowchart TD
  A["Inbound webhook"] --> B["Gateway: verify and dedup"]
  B --> C["Publish to message bus"]
  C --> D["Order service consumer"]
  C --> E["Email service consumer"]
  C --> F["Analytics service consumer"]
  D --> G[("Order DB")]
  E --> H["Send receipt email"]
  F --> I[("Analytics store")]
```

```
[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:

1. Validate the HMAC signature.
2. Check the event ID against the dedup store; return 200 immediately if already seen.
3. Write the raw event payload to a message queue or event bus.
4. Return 200.

No business logic. No downstream calls. It should complete in well under 500ms.

```js
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.succeeded` to one topic, `stripe.customer.subscription.deleted` to 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](https://clixo.sh/#contact).

---

Clixo · 1141 W Bryn Mawr Ave, Itasca, IL 60143, US · [hello@clixo.sh](mailto:hello@clixo.sh)
[Start a build](https://clixo.sh/#contact) · [All services](https://clixo.sh/services) · [Agent guide (llms.txt)](https://clixo.sh/llms.txt)
