Back to Blog
multi-tenantarchitecturesecuritySaaS

Multi-Tenant Webhook Architecture: How to Deliver Events to Thousands of Customers

Per-customer signing secrets, tenant data isolation, white-labeled portals, and the database-level patterns that make multi-tenant webhook delivery safe and scalable.

C
Camille Beaumont
Backend Architect
December 10, 2025
9 min read

If you're building a SaaS platform, you'll eventually need to send webhooks to your customers — not just receive them from providers like Stripe or GitHub.

This is the "platform builder" use case: your customers configure webhook endpoints in your product, and you deliver events to them when things happen in your system (user signed up, order placed, subscription changed, etc.).

Getting multi-tenant webhook delivery right is non-trivial. Here's the architecture.


The Problem Space

Multi-tenant webhook delivery has requirements that don't exist in single-tenant setups:

RequirementSingle-tenantMulti-tenant
Signing secretOne global secretPer-customer secret
Data isolationN/AStrict — never cross-contaminate
Retry behaviorGlobal configPer-customer config
Delivery logsInternal onlyCustomer-visible
Custom domainsN/ACustomer sees your brand, not GetHook
Dead-letter queueEngineer reviewsCustomer self-service
Secret rotationEngineering workCustomer-initiated

Tenant Data Model

Start with the data model. Everything else flows from it.

sql
-- Top-level tenant table
CREATE TABLE accounts (
    id          UUID PRIMARY KEY DEFAULT gen_random_uuid(),
    name        TEXT NOT NULL,
    plan        TEXT NOT NULL DEFAULT 'free',
    created_at  TIMESTAMPTZ NOT NULL DEFAULT NOW()
);

-- Per-tenant API keys (for management API access)
CREATE TABLE api_keys (
    id          UUID PRIMARY KEY DEFAULT gen_random_uuid(),
    account_id  UUID NOT NULL REFERENCES accounts(id) ON DELETE CASCADE,
    name        TEXT NOT NULL,
    hashed_key  TEXT NOT NULL,  -- SHA-256 hash of actual key
    key_prefix  TEXT NOT NULL,  -- "hk_live_..." for display
    revoked_at  TIMESTAMPTZ,
    created_at  TIMESTAMPTZ NOT NULL DEFAULT NOW()
);

-- Per-tenant webhook destinations (where to deliver)
CREATE TABLE destinations (
    id              UUID PRIMARY KEY DEFAULT gen_random_uuid(),
    account_id      UUID NOT NULL REFERENCES accounts(id) ON DELETE CASCADE,
    name            TEXT NOT NULL,
    url             TEXT NOT NULL,
    signing_secret  BYTEA,  -- AES-256-GCM encrypted
    timeout_seconds INTEGER NOT NULL DEFAULT 30,
    created_at      TIMESTAMPTZ NOT NULL DEFAULT NOW()
);

-- Per-tenant events
CREATE TABLE events (
    id          UUID PRIMARY KEY DEFAULT gen_random_uuid(),
    account_id  UUID NOT NULL REFERENCES accounts(id) ON DELETE CASCADE,
    -- ... other fields
);

The account_id column on every tenant-owned table is the linchpin of tenant isolation. Every query must filter by account_id.


Enforcing Tenant Isolation at the Query Layer

The most common multi-tenancy security bug: forgetting to add WHERE account_id = $1 to a query.

GetHook's pattern: Every store method takes accountID as its first parameter, and the parameter is required — there's no default, no override.

go
// destinations store
type Store struct{ db *sql.DB }

func (s *Store) List(ctx context.Context, accountID uuid.UUID) ([]Destination, error) {
    rows, err := s.db.QueryContext(ctx, `
        SELECT id, name, url, timeout_seconds, created_at
        FROM destinations
        WHERE account_id = $1
        ORDER BY created_at DESC
    `, accountID)
    // ...
}

func (s *Store) Get(ctx context.Context, accountID, destID uuid.UUID) (*Destination, error) {
    row := s.db.QueryRowContext(ctx, `
        SELECT id, name, url, timeout_seconds, created_at
        FROM destinations
        WHERE id = $1
          AND account_id = $2  -- ALWAYS include this
    `, destID, accountID)
    // ...
}

The Get method takes both destID and accountID. Even if an attacker knows another tenant's destination ID, they can't retrieve it without the correct accountID.


Per-Customer Signing Secrets

When you deliver an event to a customer's endpoint, you need to sign it with a secret that's unique to that customer. This is critical for security:

  • Why per-customer: If you use one global signing secret and it leaks, all customers are compromised. Per-customer secrets limit the blast radius.
  • Why encrypted at rest: A database breach shouldn't expose signing secrets in plaintext.

The workflow:

1. Customer creates a destination endpoint in your portal 2. GetHook generates a random 32-byte signing secret 3. Secret is AES-256-GCM encrypted before storing 4. The plaintext secret is returned ONCE to the customer (show and close) 5. Customer stores it and uses it to verify events you send On delivery: 6. GetHook retrieves and decrypts the secret for the destination 7. Signs the event payload with HMAC-SHA256 8. Delivers: POST {url} with X-GetHook-Signature: t=...,v1=... 9. Customer's handler verifies the signature using their stored secret

Secret Rotation

Customers need to be able to rotate their signing secrets. This is a common security hygiene requirement (and sometimes a compliance requirement).

The rotation flow must be designed carefully to avoid breaking production delivery:

Old secret ─────────────────────────────────→ Eventually revoked Overlap window (configurable) New secret ─────────────────────────────────────────────→ Primary

During the rotation overlap window, both the old and new secret are valid. This allows the customer to update their handler without breaking existing deliveries in flight.

Implementation:

sql
-- Support "pending" rotation state
ALTER TABLE destinations ADD COLUMN rotating_secret BYTEA;
ALTER TABLE destinations ADD COLUMN rotation_started_at TIMESTAMPTZ;
ALTER TABLE destinations ADD COLUMN rotation_ends_at TIMESTAMPTZ;

When delivering during a rotation window, sign with the new secret but also include the old signature in the headers:

http
X-GetHook-Signature: t=1714556800,v1=<new-hmac>
X-GetHook-Signature-Previous: t=1714556800,v1=<old-hmac>

White-Labeling: Your Brand, Not GetHook's

When your customers see webhook delivery notifications, they should see your company name and domain — not GetHook.

Custom Domains

Instead of your events arriving from webhooks.gethook.to, they arrive from webhooks.yourapp.com.

This requires:

  1. Customer (you) adds a CNAME DNS record pointing webhooks.yourapp.com to GetHook's infrastructure
  2. GetHook provisions a TLS certificate (via Let's Encrypt) for your domain
  3. All outbound deliveries use your custom domain in the Host header
sql
CREATE TABLE custom_domains (
    id                  UUID PRIMARY KEY DEFAULT gen_random_uuid(),
    account_id          UUID NOT NULL REFERENCES accounts(id),
    domain              TEXT NOT NULL UNIQUE,
    type                TEXT NOT NULL,  -- 'portal' or 'webhook'
    status              TEXT NOT NULL DEFAULT 'pending',  -- pending, verified, active
    tls_status          TEXT NOT NULL DEFAULT 'pending',
    verification_token  TEXT NOT NULL,  -- TXT record for DNS verification
    created_at          TIMESTAMPTZ NOT NULL DEFAULT NOW()
);

Brand Settings

Your customer portal (where your customers configure their webhook endpoints) should be on-brand:

sql
CREATE TABLE brand_settings (
    id              UUID PRIMARY KEY DEFAULT gen_random_uuid(),
    account_id      UUID NOT NULL REFERENCES accounts(id) UNIQUE,
    company_name    TEXT,
    logo_url        TEXT,
    primary_color   TEXT DEFAULT '#3b82f6',  -- hex color
    docs_title      TEXT,
    support_email   TEXT,
    created_at      TIMESTAMPTZ NOT NULL DEFAULT NOW()
);

Tenant-Visible Delivery Logs

Your customers will ask: "Did webhook X arrive at my endpoint?" This is a support burden unless you give them self-service access to delivery logs.

The data model for delivery attempts:

sql
CREATE TABLE delivery_attempts (
    id              UUID PRIMARY KEY DEFAULT gen_random_uuid(),
    event_id        UUID NOT NULL REFERENCES events(id),
    destination_id  UUID NOT NULL REFERENCES destinations(id),
    attempt_number  INTEGER NOT NULL,
    outcome         TEXT NOT NULL,  -- success, timeout, http_4xx, http_5xx, etc.
    response_status INTEGER,
    response_body   TEXT,
    duration_ms     INTEGER,
    attempted_at    TIMESTAMPTZ NOT NULL DEFAULT NOW()
);

This gives you a full delivery timeline per event that customers can query:

Event evt_abc123 — payment.succeeded ├─ Attempt 1 — 2025-11-14 14:30:00 — 200 OK — 245ms ✅ │ Destination B (slack-notifier) ├─ Attempt 1 — 2025-11-14 14:30:00 — 503 — RETRY ├─ Attempt 2 — 2025-11-14 14:30:35 — 200 OK — 189ms ✅

API Key Design for Multi-Tenancy

Your customers need API keys to configure their webhook destinations. Key design decisions:

DecisionChoiceReason
Key formathk_live_... / hk_test_...Environment-scoped, identifiable
StorageSHA-256 hash onlyBreach doesn't expose keys
Display after creationOne-time displayNon-recoverable by design
Multiple keys per tenantYes (named)Rotation without downtime
Per-key permissionsPhase 2MVP: all keys have full access
go
// Key generation
func GenerateAPIKey() (plaintext, prefix, hashed string) {
    raw := make([]byte, 32)
    rand.Read(raw)
    plaintext = "hk_live_" + base64.URLEncoding.EncodeToString(raw)
    prefix = plaintext[:14]  // "hk_live_XXXXXX" — enough to identify, not enough to use
    hash := sha256.Sum256([]byte(plaintext))
    hashed = hex.EncodeToString(hash[:])
    return
}

Performance at Scale: Tens of Thousands of Tenants

At 10,000+ tenants, the naive approach breaks down. You can't do:

sql
SELECT * FROM events WHERE account_id = $1

...across 10,000 active tenants simultaneously without proper indexing and query optimization.

Key indexes for multi-tenant performance:

sql
-- Critical: all per-tenant queries must hit this index
CREATE INDEX idx_events_account_time
  ON events (account_id, created_at DESC);

-- Worker poll: doesn't filter by account, filters by status+time
CREATE INDEX idx_events_queue
  ON events (status, next_attempt_at)
  WHERE status IN ('queued', 'retry_scheduled');

-- Delivery attempts by event (for timeline view)
CREATE INDEX idx_attempts_event
  ON delivery_attempts (event_id, attempted_at DESC);

For very large tenants (millions of events), consider table partitioning by account_id or by created_at to keep index scans efficient.


The Checklist

Before shipping multi-tenant webhook delivery to production:

  • Every query filters by account_id
  • Get methods verify both resource ID and account ID
  • Signing secrets encrypted at rest (AES-256-GCM minimum)
  • API keys SHA-256 hashed (not stored plaintext)
  • Plaintext key returned only at creation time
  • Per-tenant delivery logs visible to the tenant
  • Dead-letter queue with manual replay capability
  • Secret rotation with overlap window
  • Custom domain support for outbound delivery
  • Integration tests that verify cross-tenant isolation

Multi-tenant webhook infrastructure is GetHook's core competency. If you're building a SaaS platform that needs outbound webhooks, get started with GetHook →

Stop losing webhook events.

GetHook gives you reliable delivery, automatic retry, and full observability — in minutes.