All posts
webhooksarchitecturebackendevent-driven

Fan-Out: Delivering One Webhook to Multiple Destinations

A single Stripe or GitHub webhook often needs to reach more than one service: billing, notifications, analytics. How to fan out delivery without coupling those services to the provider or to each other.

14 September 20264 min read

A single Stripe payment_intent.succeeded event often needs to reach more than one place: a billing service that updates the customer's account, a notification service that emails a receipt, and an analytics pipeline that records the transaction. Three unrelated services, one event.

The naive approach makes one of them responsible for triggering the other two. It's the wrong shape, and it's worth understanding why before building around it.

The coupling problem with a single handler

// One handler, three unrelated responsibilities
app.post('/webhooks/stripe', async (req, res) => {
  const event = verifySignature(req);
  res.status(200).send('ok');

  await updateBilling(event);       // billing service's job
  await sendReceiptEmail(event);    // notification service's job
  await recordAnalyticsEvent(event); // analytics service's job
});

This works until any one of the three has a problem. If the analytics call is slow, it delays (or with a shared timeout, breaks) the billing update that has nothing to do with it. If the notification service is being redeployed, the whole handler starts failing, and now Stripe is retrying an event that billing already processed successfully the first time, purely because an unrelated service was briefly down.

The three concerns are coupled to the same request lifecycle, the same failure domain, and the same deploy, despite having nothing to do with each other functionally.

Fan-out: one event, independent deliveries

The fix is to treat the incoming event as something to distribute, not something to process directly. A single ingest point receives and verifies the event, then delivers a copy to each interested destination independently.

                    ┌──→ billing service
Stripe → ingest ────┼──→ notification service
                    └──→ analytics service
const DESTINATIONS = [
  { name: 'billing', url: process.env.BILLING_URL },
  { name: 'notifications', url: process.env.NOTIFICATIONS_URL },
  { name: 'analytics', url: process.env.ANALYTICS_URL },
];

app.post('/webhooks/stripe', async (req, res) => {
  const event = verifySignature(req);
  res.status(200).send('ok');

  // Each destination gets its own delivery, its own retry, its own failure state
  for (const destination of DESTINATIONS) {
    void deliverToDestination(destination, event).catch((err) => {
      logger.error(`Delivery to ${destination.name} failed`, err);
    });
  }
});

async function deliverToDestination(destination: Destination, event: WebhookEvent) {
  await queue.add(`deliver-${destination.name}`, { destination, event }, {
    attempts: 5,
    backoff: { type: 'exponential', delay: 60_000 },
  });
}

Each destination now has its own queue and its own retry schedule. A billing service outage doesn't touch the notification service's delivery, and vice versa. The event was received once; what happens to each downstream copy is that destination's own concern.

Fan-out at the ingest layer also means each destination only needs to know how to consume the event, not where it came from or who else is receiving it. Adding a fourth destination later is a config change, not a code change in the other three services.

Isolating failures per destination

The point of fan-out breaks down if a failure in one destination's retry logic can affect another's. Keep the queues, retry counts, and backoff schedules entirely separate per destination, even if they share the same underlying queue infrastructure.

// Separate named queues, not one shared queue with a destination field
const billingQueue = new Queue('deliver-billing', { connection: redis });
const notificationsQueue = new Queue('deliver-notifications', { connection: redis });
const analyticsQueue = new Queue('deliver-analytics', { connection: redis });

This also makes monitoring simpler: queue depth and failure rate per destination tell you exactly which downstream service is struggling, rather than one blended metric across all three.

When a new destination needs historical events

A common need once fan-out exists: a fourth destination gets added later, and it needs the last week of events, not just new ones going forward. If the original event is still stored somewhere (not just the fan-out records), you can replay historical events to the new destination without needing the provider to resend anything, since providers generally don't let you request a backfill of past events on demand.

Pausing one destination without affecting the others

A destination going down for maintenance shouldn't require pausing the whole pipeline. If each destination has its own delivery state, disabling one just means new events stop queuing for it. Everything else keeps flowing normally, and you re-enable it once the maintenance window closes.


Fan-out turns "one webhook, three unrelated integrations" from a shared point of failure into three independent, individually recoverable pipelines. The cost is a bit more infrastructure up front; the payoff is that a bad day for one destination stops being everyone's bad day.

This is built into Webhook Watchtower rather than something you need to wire up separately: attach multiple destinations to one source and each gets its own independent delivery and retry queue. The destinations guide has the production-and-staging pattern for running both in parallel.

Frequently asked questions

What is webhook fan-out?

Fan-out means delivering a single incoming event to multiple destinations independently, rather than one destination doing all the work. A Stripe payment event might need to update a billing record, send a notification, and update an analytics pipeline, each handled by a separate service rather than one handler doing all three.

Why not just have one service handle everything?

It works until it doesn't. A single handler doing billing, notifications, and analytics couples three unrelated concerns to one deploy, one failure domain, and one team's release schedule. A bug in the analytics code can take down billing updates if they're not isolated, which is a strange kind of blast radius for something unrelated to payments.

Do all destinations need to succeed for the event to count as delivered?

No, and treating it that way causes problems. Each destination should have its own independent success or failure state. If your notification service is down, that shouldn't block or retry the billing service's already-successful delivery.

Share this post

Webhook Watchtower

Stop debugging webhooks in logs.

Capture every event, replay failures, and get alerted when deliveries fail, without changing your existing setup.

Try it free →