InstaWebhook
August 31, 2026By InstaWebhook TeamWebhook Security

Building Multi-Tenant Webhook Dispatch Systems in B2B SaaS

Building Multi-Tenant Webhook Dispatch Systems in B2B SaaS In modern B2B SaaS platforms, webhooks are the connective tissue between your core product and the rest of an enterprise...

Building Multi Tenant Webhook Dispatch Systems In B2 B Saa S

Building Multi-Tenant Webhook Dispatch Systems in B2B SaaS

In modern B2B SaaS platforms, webhooks are the connective tissue between your core product and the rest of an enterprise customer's stack — notifying an ERP of a completed checkout, syncing a CRM, or kicking off a DevOps pipeline. Reliable event delivery is table stakes.

The hard part isn't sending an HTTP POST. It's sending millions of them, to thousands of independently owned, independently misbehaving endpoints, without one customer's broken integration taking down delivery for everyone else. That's the multi-tenancy problem, and it's the reason webhook infrastructure has quietly become its own engineering discipline.

This guide covers why naive single-queue systems fail under multi-tenant load, the architectural patterns that fix it, and how the current (2026) landscape of build-vs-buy options actually stacks up — with sources, not vibes.

1. The "Noisy Neighbor" Problem in Webhook Dispatch

AWS's own SaaS architecture guidance treats this as a first-class design concern: noisy-neighbor behavior is one of the main reasons teams choose to isolate parts of an otherwise shared, multi-tenant system in the first place. The pattern is well documented across cloud providers, not unique to webhooks — it shows up anywhere multiple tenants share compute, queues, database connections, or disk I/O, and one tenant's spike degrades everyone else's experience.

The Naive Architecture: One Shared FIFO Queue

Most early-stage SaaS products handle webhooks with a single global FIFO queue (Redis, SQS, or a library like BullMQ) backed by a shared worker pool:

Code example
[ Application Event ]
        │
        ▼
┌────────────────────────────────────────────────────────┐
│ Global FIFO Queue (Shared Redis / SQS)                  │
│ [Tenant A] [Tenant A] [Tenant B] [Tenant C] [Tenant A]  │
└──────────────────────────┬───────────────────────────────┘
                            │
        ┌───────────────────┼───────────────────┐
        ▼                   ▼                   ▼
┌─────────────┐     ┌─────────────┐     ┌─────────────┐
│ Worker Pod  │     │ Worker Pod  │     │ Worker Pod  │
└─────────────┘     └─────────────┘     └─────────────┘

This works fine with one tenant. With hundreds, it becomes a liability in two specific, very common failure modes.

Head-of-line blocking. If Tenant A's endpoint is dropping TCP packets, every request to it hangs until your HTTP timeout fires. If Tenant A generates a burst of events (a batch import, say), every worker in the shared pool ends up parked waiting on Tenant A's timeouts. Tenant B's payment-confirmation webhook lands at the back of that same queue and either arrives late or not at all — despite Tenant B having done nothing wrong.

Burst starvation. If Tenant C pushes 50,000 events into the shared queue in a few seconds, strict FIFO ordering means Tenant C's backlog can occupy most of the queue depth and worker capacity for a long stretch, starving low-volume tenants of timely delivery.

Amazon's own SQS documentation describes this exact dynamic (excess in-flight messages from one tenant driving up "dwell time" — how long a message sits before being processed — for everyone else sharing the queue), which is notable: it's common enough that AWS shipped a managed feature specifically to solve it (more on that in Pattern 2).

2. Core Architectural Patterns for Isolation

Pattern 1 — Per-Tenant Queue Partitioning

Instead of one bucket, events are routed into isolated queues keyed by tenant_id — Redis keys like queue:webhook:{tenant_id}, or Kafka topic partitions keyed by tenant.

Code example
                ┌───► [ Queue: Tenant A ] ───► Worker (Tier 1)
                │
[ Dispatcher ] ─┼───► [ Queue: Tenant B ] ───► Worker (Tier 2)
                │
                └───► [ Queue: Tenant C ] ───► Worker (Tier 2)

An outage on Tenant A's server now only backs up queue:webhook:tenant_a. Tenants B and C are unaffected.

Pattern 2 — Fair Scheduling (and a real, managed version of it)

Partitioning alone isn't enough if a worker pool just drains queues in the order it finds them — a huge backlog on one tenant's queue can still eat a disproportionate share of worker time. You need either:

  • Round-robin dequeue: pull a fixed batch (e.g., 5 jobs) from Tenant A, then Tenant B, then Tenant C, regardless of how deep each queue is.
  • Weighted tiers: give enterprise tenants a larger concurrency allocation (e.g., 50 slots) than free-tier tenants (e.g., 5 slots).

This is no longer just a DIY pattern. In 2025, AWS shipped SQS fair queues as a native feature of standard (non-FIFO) queues: you tag each message with a MessageGroupId identifying the tenant, and SQS automatically detects when one tenant has a disproportionate number of in-flight messages and de-prioritizes further delivery to that tenant in favor of others — with no consumer-side code changes required. It's a useful existence proof that this is a solved, productized problem now, not just a bespoke pattern you have to build from scratch on Redis.

If you're on Kafka instead, the equivalent is partitioning topics by tenant ID (or hashing large tenants across multiple partitions) so one tenant's consumer lag doesn't block others reading from a shared partition.

Pattern 3 — Per-Tenant Concurrency Limits (Token Bucket)

Cap how many concurrent outbound HTTP requests any one tenant can have in flight — e.g., a hard limit of 10 for Tenant A — so a slow tenant can't monopolize your outbound connection pool or database connections. Anything beyond the cap just waits in that tenant's isolated queue.

One implementation note worth flagging if you're using BullMQ: earlier versions supported a groupKey option on the rate limiter (not the job itself) for per-group throttling, but this was removed from open-source BullMQ in v3.0 because the implementation wasn't reliable at scale. Per-tenant rate limiting and per-group concurrency limits are now BullMQ Pro features (group: { id, limit, concurrency }), not something the free tier does natively. If you're on open-source BullMQ, you'll need to implement the token bucket yourself (as shown in the code section below) or partition into genuinely separate queues per tenant/tier.

Pattern 4 — Circuit Breakers

When an endpoint consistently returns 5xx, 429, or times out, retrying immediately just compounds the problem. The circuit breaker pattern — long-established in distributed systems (Netflix's Hystrix and Michael Nygard's Release It! popularized it; it's now built into libraries like resilience4j and Polly) — wraps each destination endpoint in a small state machine:

  • Closed: requests flow normally.
  • Open: after N consecutive failures, all further requests to that endpoint are diverted straight to a delayed-retry store or DLQ without attempting the HTTP call.
  • Half-open: after a cooldown, a single probe request tests whether the endpoint has recovered; success closes the circuit, failure re-opens it.

3. Reference Architecture

Code example
┌─────────────────────────────────────────────────────────────┐
│                    INGESTION LAYER                           │
│  Internal SaaS Services (Payments, Orders, Auth, Users)      │
└──────────────────────────────┬───────────────────────────────┘
                                │ HTTP / gRPC Event Ingest
                                ▼
┌─────────────────────────────────────────────────────────────┐
│         API Gateway & HMAC Payload Verification              │
│   - Validates Tenant Identity & Event Schema                 │
│   - Assigns Tracking ID & Timestamp                          │
└──────────────────────────────┬───────────────────────────────┘
                                │ Fast Async Enqueue
                                ▼
┌─────────────────────────────────────────────────────────────┐
│                 ISOLATION & DISPATCH LAYER                   │
│   ┌──────────────────┐  ┌──────────────────┐                 │
│   │ Queue: Tenant A  │  │ Queue: Tenant B  │   ... Queue N   │
│   └────────┬─────────┘  └────────┬─────────┘                 │
│            │                     │                            │
│            ▼                     ▼                            │
│   ┌────────────────────────────────────────┐                 │
│   │   Fair Scheduler & Worker Allocator     │                 │
│   │   (Enforces Rate Limits & Weights)      │                 │
│   └──────────────────┬───────────────────────┘                │
└──────────────────────┼────────────────────────────────────────┘
                        │
                        ▼
┌─────────────────────────────────────────────────────────────┐
│                 DELIVERY & RETRY ENGINE                      │
│  ┌───────────────────────┐     ┌───────────────────────┐     │
│  │ Outbound HTTP Worker  │     │ Circuit Breaker Guard │     │
│  └───────────┬───────────┘     └───────────┬───────────┘     │
│              ├─────────────────────────────┘                 │
│              ▼                                                │
│     External Customer Endpoints                               │
│  ┌───────────────────────────────────────────────────────┐   │
│  │ Retry Engine (Exponential Backoff + Full Jitter)      │   │
│  └───────────────────────────┬───────────────────────────┘   │
│                              │ Max Retries Reached            │
│                              ▼                                │
│  ┌───────────────────────────────────────────────────────┐   │
│  │ Dead Letter Queue (DLQ) & Log Persistence             │   │
│  └───────────────────────────────────────────────────────┘   │
└─────────────────────────────────────────────────────────────┘

4. Signing, Retries, and Idempotency — Getting the Details Right

Signature verification

Don't invent your own signing scheme. The Standard Webhooks specification (an open spec that Svix and others helped drive, now used across the ecosystem) defines a well-tested approach: HMAC-SHA256 over a payload that includes a unique message ID, a timestamp, and the raw body, delivered in webhook-id, webhook-timestamp, and webhook-signature headers. Signing the timestamp matters — without it, an attacker who captures one valid request can replay it indefinitely with a still-valid signature. The spec's recommended tolerance is 300 seconds; reject anything outside that window.

Retries: fix the jitter formula

A subtle bug shows up constantly in hand-rolled retry engines: people call it "full jitter" but implement additive jitter (delay = base * 2^attempt + random_amount). That's not what AWS's canonical formulation (from the widely cited Exponential Backoff and Jitter post on the AWS Architecture Blog) actually specifies. True full jitter is:

$$\text{sleep} = \text{random_between}(0, \min(\text{cap}, \text{base} \times 2^{\text{attempt}}))$$

The random value replaces the deterministic delay rather than adding to it — that's what actually flattens a synchronized retry spike ("thundering herd") into a smooth trickle instead of a sawtooth.

For the schedule itself, there's no universal standard, but real providers converge on similar shapes:

ProviderLive retry windowNotes
Stripe~3 daysTest mode: 3 attempts over a few hours instead
Svix (typical default)~35+ hours across 8 attemptsImmediate, then 5s, 5m, 30m, 2h, 5h, 10h, 10h
General best practice24–48 hours, 6–8 attemptsStart ~30s, double with a cap around 8h, full jitter

Cap the maximum delay (commonly 30–60 seconds to a few hours depending on your SLA) — unbounded exponential growth just wastes time without improving delivery odds.

Idempotency

Every retry means the customer's endpoint may see the same event more than once. Include a stable event ID in every payload and document that consumers should deduplicate on it. Your own dedup/idempotency cache (if you maintain one server-side, e.g., to avoid double-processing internally) needs a TTL at least as long as your full retry window, or late retries will look like brand-new events.

5. Code: Isolation Patterns That Actually Work Today

5.1 Per-tenant concurrency limiting (open-source BullMQ, no Pro license required)

Since group-based rate limiting isn't available in open-source BullMQ, here's the token-bucket approach applied directly in the worker, which is what the original naive groupKey-on-queue.add() approach (a pattern that doesn't actually exist in BullMQ's API) was trying to achieve:

Code example
import { Worker, Job, Queue } from 'bullmq';
import crypto from 'crypto';
import axios from 'axios';

const redisConnection = { host: process.env.REDIS_HOST, port: 6379 };
const activeTenantConnections = new Map<string, number>();
const MAX_CONCURRENT_PER_TENANT = 5;

interface WebhookPayload {
  tenantId: string;
  eventId: string;
  eventType: string;
  targetUrl: string;
  payload: Record<string, unknown>;
  signatureSecret: string;
}

function signStandardWebhook(id: string, timestamp: number, body: string, secret: string): string {
  // Standard Webhooks: sign "{id}.{timestamp}.{body}" with the base64-decoded secret
  const toSign = `${id}.${timestamp}.${body}`;
  const key = Buffer.from(secret.replace(/^whsec_/, ''), 'base64');
  const hmac = crypto.createHmac('sha256', key).update(toSign).digest('base64');
  return `v1,${hmac}`;
}

const webhookWorker = new Worker<WebhookPayload>(
  'multi-tenant-webhooks',
  async (job: Job<WebhookPayload>) => {
    const { tenantId, targetUrl, payload, signatureSecret, eventId } = job.data;

    const active = activeTenantConnections.get(tenantId) ?? 0;
    if (active >= MAX_CONCURRENT_PER_TENANT) {
      // BullMQ will treat this as a failure and apply the job's own backoff/attempts config
      throw new Error(`Tenant ${tenantId} at concurrency limit; will retry.`);
    }
    activeTenantConnections.set(tenantId, active + 1);

    try {
      const body = JSON.stringify(payload);
      const timestamp = Math.floor(Date.now() / 1000);
      const signature = signStandardWebhook(eventId, timestamp, body, signatureSecret);

      const response = await axios.post(targetUrl, body, {
        headers: {
          'Content-Type': 'application/json',
          'webhook-id': eventId,
          'webhook-timestamp': String(timestamp),
          'webhook-signature': signature,
        },
        timeout: 5000, // read timeout — keep this strict to avoid head-of-line blocking
      });

      return response.status;
    } finally {
      const updated = activeTenantConnections.get(tenantId) ?? 1;
      activeTenantConnections.set(tenantId, Math.max(0, updated - 1));
    }
  },
  { connection: redisConnection, concurrency: 50 },
);

If you need genuine per-tenant fairness rather than just a cap, pair this with separate queues per tenant tier (Pattern 1) rather than relying on a single shared BullMQ queue — the in-process Map above prevents monopolization but doesn't guarantee round-robin fairness across tenants sharing one queue.

5.2 Native fair queuing on AWS (no custom partitioning logic)

If you're already on SQS, you can get most of Pattern 2 for free using a standard (not FIFO) queue with MessageGroupId set to the tenant ID:

Code example
import boto3
import json

sqs = boto3.client('sqs')
QUEUE_URL = "https://sqs.us-east-1.amazonaws.com/123456789/webhook-dispatch"

def enqueue_webhook(tenant_id: str, event: dict):
    sqs.send_message(
        QueueUrl=QUEUE_URL,
        MessageBody=json.dumps(event),
        MessageGroupId=tenant_id,  # enables SQS fair queues on a standard queue
    )

Unlike FIFO queues, messages sharing a MessageGroupId on a standard queue can still be processed in parallel — SQS uses the group ID purely to detect and de-prioritize noisy tenants, not to enforce strict ordering. No consumer-side changes are required to benefit from it.

6. Build vs. Buy: The 2026 Landscape

Building this in-house is a legitimate choice, especially if webhook delivery is core to your product's value prop. But it's worth knowing what "buy" actually looks like today, since the market has consolidated meaningfully:

  • Svix — the most widely used option for sending webhooks to customers; provides an embeddable customer-facing management portal, automatic retries, and per-tenant management out of the box. Companies like Clerk, Brex, and Lithic run production webhooks through it.
  • Hookdeck — historically focused on reliably receiving inbound webhooks (queuing, transformation, retries at the edge); shipped an outbound product ("Outpost") in early 2026 that's newer and has a narrower feature set than Svix's.
  • Convoy — open-source and self-hostable, but worth flagging clearly: as of 2026 the company behind it has wound down, and it's maintained as a side project rather than a funded product. Treat it as a reference implementation to learn from rather than a production dependency.
  • Hook0 — a small, EU-based, source-available option aimed at teams that specifically need EU data residency and have modest volume.
  • AWS-native (SQS fair queues + EventBridge) — a reasonable middle ground if you're already deep in AWS and want managed fairness/partitioning without adopting a third-party webhook-specific product.

(Disclosure: some of the comparative framing above draws on vendor-published comparison pages, which are naturally not neutral about their own product. Treat the feature claims as a starting point for your own evaluation, not a substitute for it.)

DimensionSelf-built single queueSelf-built, tenant-isolatedManaged platform (e.g., Svix)
Noisy-neighbor protectionNoneYes, if implemented correctlyYes, built-in
Engineering effortLow upfrontHigh, ongoing (queue sharding, SRE)Low; you integrate an SDK
Compliance (SOC 2, HIPAA, PCI-DSS)DIYDIYVaries by vendor — verify directly
Customer-facing portal (their side)You build itYou build itOften included
Vendor/maintenance riskNone (it's yours)None (it's yours)Depends on vendor viability — see Convoy above

7. Production Checklist

Security & authentication

  • HMAC-SHA256 signatures on every payload, ideally following the Standard Webhooks header conventions
  • Signed timestamp with a replay-rejection window (300 seconds is the spec default)
  • Reject non-HTTPS endpoint registrations
  • Rotate signing secrets with an overlap window (support multiple valid signatures during rotation)

Reliability

  • Strict connect/read timeouts on outbound calls (short — a few seconds — to prevent head-of-line blocking)
  • True full-jitter exponential backoff, capped, over a defined retry window (a day or more is typical)
  • Dead-letter queue with manual replay for permanently failing events
  • Idempotency: every payload carries a stable event ID; document dedup expectations for consumers

Isolation & performance

  • Per-tenant queue partitioning or native fair-queuing (e.g., SQS MessageGroupId)
  • Per-tenant concurrency caps to prevent one tenant exhausting your outbound connection pool
  • Per-endpoint circuit breakers with closed/open/half-open states
  • Per-tenant observability: p95/p99 delivery latency, error rate, queue depth

Conclusion

A global FIFO queue is fine until it isn't — and in a multi-tenant B2B product, "isn't" arrives the first time one customer's misconfigured endpoint sits in the same pipeline as everyone else's. The fix is a combination of well-understood patterns (per-tenant partitioning, fair scheduling, concurrency caps, circuit breakers) rather than any single trick, and increasingly those patterns are available as managed primitives — SQS fair queues, BullMQ Pro's group limits, or a dedicated platform like Svix — rather than something every team has to build from first principles.

Whether you build or buy, the checklist above is the same either way: sign your payloads correctly, back off with real jitter, isolate tenants at the queue level, and give customers a way to see and replay what failed.


Sources & further reading