Fan-Out Webhook Delivery

BackendArchitectureWebhooksAPI
Share on LinkedIn

Your SaaS product ships webhooks — "we'll POST to your URL when events happen." One customer signs up, easy. Five hundred customers with three endpoints each, handling subscriber downtime, SSL cert expiry, and 3-second timeouts on shared hosting — that's fan-out delivery infrastructure. Stripe, GitHub, and Twilio all solved this with signed payloads, aggressive retries, and subscriber-facing delivery logs. Here's how to build the same.

Architecture

Event bus → Webhook dispatcher → delivery queue (per endpoint)
                                      ↓
                              HTTP POST + signature
                                      ↓
                              2xx → mark delivered
                              4xx/5xx/timeout → retry queue
                                      ↓
                              max retries → DLQ + alert subscriber

Decouple event ingestion from delivery. The dispatcher writes delivery jobs; workers execute HTTP calls.

Data model

CREATE TABLE webhook_endpoints (
    id            UUID PRIMARY KEY,
    account_id    UUID NOT NULL,
    url           TEXT NOT NULL,
    secret        VARCHAR(255) NOT NULL,
    events        TEXT[] NOT NULL,  -- ['order.created', 'order.updated']
    enabled       BOOLEAN DEFAULT true,
    created_at    TIMESTAMPTZ DEFAULT now()
);

CREATE TABLE webhook_deliveries (
    id            UUID PRIMARY KEY,
    endpoint_id   UUID REFERENCES webhook_endpoints(id),
    event_id      UUID NOT NULL,
    event_type    VARCHAR(100) NOT NULL,
    payload       JSONB NOT NULL,
    status        VARCHAR(20) DEFAULT 'pending',
    attempt_count INT DEFAULT 0,
    next_retry_at TIMESTAMPTZ,
    response_code INT,
    response_body TEXT,
    delivered_at  TIMESTAMPTZ,
    created_at    TIMESTAMPTZ DEFAULT now()
);

Signing payloads

function signPayload(payload: string, secret: string, timestamp: number): string {
  const signedContent = `${timestamp}.${payload}`;
  return createHmac('sha256', secret).update(signedContent).digest('hex');
}

async function deliverWebhook(delivery: WebhookDelivery, endpoint: WebhookEndpoint) {
  const payload = JSON.stringify(delivery.payload);
  const timestamp = Math.floor(Date.now() / 1000);
  const signature = signPayload(payload, endpoint.secret, timestamp);

  const response = await fetch(endpoint.url, {
    method: 'POST',
    headers: {
      'Content-Type': 'application/json',
      'X-Webhook-Id': delivery.id,
      'X-Webhook-Timestamp': String(timestamp),
      'X-Webhook-Signature': `sha256=${signature}`,
      'User-Agent': 'MyApp-Webhooks/1.0',
    },
    body: payload,
    signal: AbortSignal.timeout(30_000),
  });

  return { status: response.status, body: await response.text().catch(() => '') };
}

Subscribers verify:

function verifyWebhook(payload: string, signature: string, timestamp: string, secret: string): boolean {
  const age = Date.now() / 1000 - parseInt(timestamp);
  if (age > 300) return false; // reject replays older than 5 min

  const expected = signPayload(payload, secret, parseInt(timestamp));
  return timingSafeEqual(
    Buffer.from(signature.replace('sha256=', '')),
    Buffer.from(expected)
  );
}

Retry schedule

const RETRY_INTERVALS_SECONDS = [0, 60, 300, 1800, 7200, 28800, 86400];

function nextRetryTime(attemptCount: number): Date {
  const delaySec = RETRY_INTERVALS_SECONDS[Math.min(attemptCount, RETRY_INTERVALS_SECONDS.length - 1)];
  return new Date(Date.now() + delaySec * 1000);
}

Use full jitter on top to prevent synchronized retries to the same failing endpoint.

Endpoint verification on signup

Challenge subscribers to prove URL ownership:

async function verifyEndpoint(url: string, secret: string): Promise<boolean> {
  const challenge = randomBytes(16).toString('hex');
  const response = await fetch(url, {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify({ type: 'endpoint.verification', challenge }),
    signal: AbortSignal.timeout(10_000),
  });
  const body = await response.json();
  return body.challenge === challenge;
}

Subscriber-facing delivery log

Expose GET /webhooks/deliveries?endpoint_id=... showing status, response code, and replay button. This is the #1 support deflection tool — subscribers debug their own 500s instead of opening tickets.

Auto-disable failing endpoints

After 50 consecutive failures or repeated 410 responses, disable the endpoint and email the account owner. Re-enable requires manual confirmation or successful verification challenge.

Fan-out at scale

Shard delivery workers by endpoint ID. Rate-limit per endpoint (max 10 concurrent deliveries) to avoid overwhelming subscriber servers. Batch internal event processing; deliver individually.

Webhook event design

Payload design affects subscriber integration quality:

{
  "id": "evt_abc123",
  "type": "order.created",
  "created": "2025-03-15T10:30:00Z",
  "data": {
    "object": {
      "id": "ord_xyz",
      "total": 5000,
      "currency": "usd"
    }
  },
  "api_version": "2025-03-01"
}

Rules for webhook payloads:

Subscriber endpoint requirements

Document what you expect from subscriber endpoints:

Security beyond HMAC signing

Delivery dashboard features

The delivery log is your best support tool. Include:

Scaling fan-out delivery

At thousands of endpoints and millions of events daily:

Failure modes

Production checklist

Resources

Frequently asked questions

How do webhooks differ from polling?

Webhooks push events to subscriber URLs in near-real-time when something happens. Polling requires subscribers to repeatedly ask 'anything new?' Webhooks are efficient for the subscriber but push delivery complexity — retries, signing, endpoint validation — onto the publisher.

How should webhook payloads be signed?

Include an HMAC-SHA256 signature of the raw request body in a header (e.g., X-Signature-SHA256), using a per-subscriber secret. Subscribers verify the signature before processing. Never sign parsed JSON — whitespace differences break verification. Include a timestamp to prevent replay attacks.

What retry policy works for webhook delivery?

Exponential backoff over 24–72 hours: attempt at 0s, 1m, 5m, 30m, 2h, 8h, 24h. Stop after 7–10 attempts and move to dead letter. Return the failed event in a dashboard so subscribers can manually replay. Disable endpoints that fail consistently (410 Gone or 50+ consecutive failures).

Hiring a senior Android / Flutter engineer?

I architect and ship production mobile software — Kotlin, Jetpack Compose, Flutter — for robotics, EV infrastructure, fintech, and real-time systems. Open to remote roles in Europe and the US.

Get in touch →