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:
| Requirement | Single-tenant | Multi-tenant |
|---|---|---|
| Signing secret | One global secret | Per-customer secret |
| Data isolation | N/A | Strict — never cross-contaminate |
| Retry behavior | Global config | Per-customer config |
| Delivery logs | Internal only | Customer-visible |
| Custom domains | N/A | Customer sees your brand, not GetHook |
| Dead-letter queue | Engineer reviews | Customer self-service |
| Secret rotation | Engineering work | Customer-initiated |
Tenant Data Model
Start with the data model. Everything else flows from it.
-- 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.
// 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:
-- 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:
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:
- ›Customer (you) adds a CNAME DNS record pointing
webhooks.yourapp.comto GetHook's infrastructure - ›GetHook provisions a TLS certificate (via Let's Encrypt) for your domain
- ›All outbound deliveries use your custom domain in the
Hostheader
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:
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:
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:
| Decision | Choice | Reason |
|---|---|---|
| Key format | hk_live_... / hk_test_... | Environment-scoped, identifiable |
| Storage | SHA-256 hash only | Breach doesn't expose keys |
| Display after creation | One-time display | Non-recoverable by design |
| Multiple keys per tenant | Yes (named) | Rotation without downtime |
| Per-key permissions | Phase 2 | MVP: all keys have full access |
// 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:
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:
-- 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 - ›
Getmethods 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 →