Back to Blog
platform engineeringwebhooksdeveloper experiencemulti-tenant

Outbound Webhook Subscriptions: Building a Self-Serve Portal for Your Customers

If your SaaS product sends webhooks, your customers need a way to register endpoints, rotate secrets, filter event types, and debug deliveries without filing a support ticket. Here's how to build that portal correctly.

Y
Yuki Tanaka
Founding Engineer
March 29, 2026
11 min read

Most SaaS products reach a point where "email us your endpoint URL and we'll add it manually" stops scaling. Your customers want to register their own webhook endpoints, choose which events they receive, rotate their signing secrets without involving support, and see delivery history when something goes wrong.

Building that self-serve portal is a non-trivial engineering project. The UI surface is small but the backend contracts are strict: you're now responsible for reliable delivery to customer-controlled endpoints, which means your infrastructure absorbs their downtime, their misconfigured TLS, and their 2am traffic spikes.

This post covers the full scope — data model, API design, portal UX, delivery mechanics, and the operational concerns that only surface after you've shipped it to real customers.


What Customers Actually Need

Before designing anything, be precise about what "webhook portal" means. The table below captures the full feature surface. You don't need all of it on day one, but you need to know what you're building toward.

FeatureDescriptionPriority
Endpoint registrationRegister an HTTPS URL to receive eventsMust-have
Event type filteringSubscribe to specific event types onlyMust-have
Signing secret managementView, rotate, or regenerate HMAC secretMust-have
Delivery historySee recent delivery attempts with status and responseMust-have
ReplayRetry a failed delivery manuallyMust-have
Pause / disableStop delivery to an endpoint without deleting itShould-have
Multiple endpointsRegister more than one endpoint per accountShould-have
Custom headersSend additional headers with each webhookNice-to-have
Failure alertsEmail or Slack notification on dead-letterNice-to-have
Endpoint testingSend a test event to verify configurationNice-to-have

Start with the must-haves. Every one of them has a non-obvious implementation detail — don't skip to the nice-to-haves until the foundation is solid.


The Data Model

The core entities are webhook_subscriptions and webhook_deliveries. These live in your database, scoped to your customer's account.

sql
CREATE TABLE webhook_subscriptions (
    id                UUID PRIMARY KEY DEFAULT gen_random_uuid(),
    account_id        UUID NOT NULL REFERENCES accounts(id) ON DELETE CASCADE,
    url               TEXT NOT NULL,
    description       TEXT,
    event_types       TEXT[] NOT NULL DEFAULT '{}', -- empty = all events
    signing_secret    TEXT NOT NULL, -- AES-256-GCM encrypted, never plaintext
    status            TEXT NOT NULL DEFAULT 'active', -- active | paused | disabled
    created_at        TIMESTAMPTZ NOT NULL DEFAULT now(),
    updated_at        TIMESTAMPTZ NOT NULL DEFAULT now()
);

CREATE TABLE webhook_deliveries (
    id               UUID PRIMARY KEY DEFAULT gen_random_uuid(),
    subscription_id  UUID NOT NULL REFERENCES webhook_subscriptions(id) ON DELETE CASCADE,
    event_id         UUID NOT NULL,
    event_type       TEXT NOT NULL,
    attempt_number   INT NOT NULL DEFAULT 1,
    http_status      INT,
    outcome          TEXT NOT NULL, -- success | timeout | http_4xx | http_5xx | network_error
    request_body     TEXT,         -- store for replay; truncate at 64KB
    response_body    TEXT,         -- truncate at 4KB
    latency_ms       INT,
    attempted_at     TIMESTAMPTZ NOT NULL DEFAULT now()
);

CREATE INDEX ON webhook_subscriptions (account_id, status);
CREATE INDEX ON webhook_deliveries (subscription_id, attempted_at DESC);
CREATE INDEX ON webhook_deliveries (event_id);

A few design decisions worth calling out:

event_types as a Postgres array. An empty array means "subscribe to all events." A non-empty array means "subscribe only to these types." This keeps filtering a simple WHERE event_types = '{}' OR $event_type = ANY(event_types) query — no separate join table needed until you have thousands of event types.

signing_secret is always encrypted. Never store signing secrets in plaintext. Use AES-256-GCM or equivalent. Decrypt only at delivery time, inside the process that makes the outbound HTTP request. The secret should never appear in logs, API responses (except at creation time), or error messages.

request_body is stored for replay. You need to store the exact payload you sent so that a manual or automatic retry sends the same bytes. Truncate at a sensible limit (64KB is generous for most event payloads) and enforce it.


API Design

Your internal API for subscription management needs at minimum:

POST   /v1/webhook-subscriptions        — create
GET    /v1/webhook-subscriptions        — list (paginated)
GET    /v1/webhook-subscriptions/{id}   — get one
PATCH  /v1/webhook-subscriptions/{id}  — update url, event_types, status, description
DELETE /v1/webhook-subscriptions/{id}  — delete

GET    /v1/webhook-subscriptions/{id}/deliveries          — delivery history
POST   /v1/webhook-subscriptions/{id}/rotate-secret       — generate new signing secret
POST   /v1/webhook-subscriptions/{id}/test                — send test event
POST   /v1/webhook-deliveries/{id}/replay                 — replay a specific delivery

The rotate-secret endpoint deserves special attention. Rotating a secret creates a gap: the old secret stops working immediately. Your delivery layer needs to support dual-signing during the rotation window — signing outbound webhooks with both the old and new secret simultaneously, so customers can verify against either one while they update their code.

Here's how to model dual signing in your response:

json
{
  "data": {
    "id": "ws_01HX...",
    "signing_secret_prefix": "whsec_abc...",
    "rotation": {
      "new_secret": "whsec_xyz...",
      "expires_at": "2026-03-29T02:00:00Z",
      "message": "Update your webhook handler to verify against the new secret before expiry."
    }
  }
}

During the rotation window (typically 24–48 hours), your delivery worker signs with both secrets and includes both signatures in the X-Signature header:

X-Signature: t=1743206400,v1=<new_hmac>,v0=<old_hmac>

The customer's handler can verify against either. After the window, the old secret is dropped from your database and only the new signature is sent.


Delivery Mechanics

When your platform fires an internal event (e.g., invoice.paid), the delivery pipeline looks like this:

  1. Query all active subscriptions for the account where event_types = '{}' OR 'invoice.paid' = ANY(event_types)
  2. For each matching subscription, insert a delivery job into your queue
  3. Worker picks up the job, decrypts the signing secret, builds the request, signs the payload, delivers it
  4. On success: mark delivered, store response body (truncated)
  5. On failure: schedule retry with exponential backoff, update outcome

The retry schedule you choose is visible to customers and affects their trust in your platform. Be explicit about it in your docs:

AttemptDelay after previous
1Immediate
230 seconds
35 minutes
430 minutes
52 hours
Dead-letterNo further automatic retries

After dead-lettering, the event stays in delivery history so customers can replay it manually after they fix their endpoint.

One important rule: return 200 OK to your internal event emitter immediately after enqueuing the delivery jobs — never after the deliveries complete. If you block on delivery, a slow or unreachable customer endpoint will jam your internal event pipeline.


The Portal UI

The portal UI has three main surfaces: the subscription list, the subscription detail / config page, and the delivery history viewer.

Subscription list should show status (active / paused), URL (truncated), event type filter summary, and the number of deliveries in the last 24 hours. A visual health indicator (green / yellow / red based on recent failure rate) lets customers self-triage.

Subscription detail is where customers manage configuration. The signing secret display should show only the first 8 characters and mask the rest — with a "Rotate Secret" button that walks through the dual-signing rotation flow described above. Never show the full secret after creation.

Delivery history is the most operationally important surface. Each row should show:

  • Event type
  • Attempt number
  • HTTP status code
  • Outcome
  • Latency
  • Timestamp
  • A "View details" link that shows the full request/response bodies

The "Replay" action on a failed delivery is frequently what saves a customer from filing a support ticket. Make it prominent.


Validating Customer Endpoints

Before accepting an endpoint URL, you need to validate it. At minimum:

go
func validateEndpointURL(rawURL string) error {
    u, err := url.Parse(rawURL)
    if err != nil {
        return fmt.Errorf("invalid URL: %w", err)
    }
    if u.Scheme != "https" {
        return fmt.Errorf("endpoint URL must use HTTPS")
    }
    if u.Host == "" {
        return fmt.Errorf("endpoint URL must have a host")
    }
    // Block internal network ranges to prevent SSRF
    ip := net.ParseIP(u.Hostname())
    if ip != nil && (ip.IsLoopback() || ip.IsPrivate() || ip.IsLinkLocalUnicast()) {
        return fmt.Errorf("endpoint URL must not resolve to a private IP address")
    }
    return nil
}

The SSRF check is not optional. Without it, a malicious customer could register http://169.254.169.254/latest/meta-data/ (AWS metadata endpoint) or http://10.0.0.1/admin and use your delivery infrastructure to probe your internal network.

You should also DNS-resolve the hostname at validation time and reject private IPs. Note that DNS can change after validation, so also resolve and check at delivery time before making the outbound request.


Rate Limiting Per Subscription

Without per-subscription rate limiting, a single misconfigured or malicious subscription can monopolize your delivery workers. Set a sensible default and allow it to be configured per subscription:

sql
ALTER TABLE webhook_subscriptions
    ADD COLUMN max_deliveries_per_minute INT NOT NULL DEFAULT 100;

In your delivery worker, before making an outbound request, check a sliding window counter:

go
key := fmt.Sprintf("webhook:ratelimit:%s", subscriptionID)
count, err := cache.IncrBy(ctx, key, 1)
if err == nil && count == 1 {
    cache.Expire(ctx, key, time.Minute)
}
if count > subscription.MaxDeliveriesPerMinute {
    // Re-queue with a 60-second delay instead of delivering now
    return ErrRateLimited
}

If you're not using Redis, you can implement this in Postgres with a simple count query over the last 60 seconds on webhook_deliveries, though this adds query overhead per delivery attempt.


Testing and Operational Readiness

Before you ship the portal, run through this checklist:

  • Test event flow: Sending a test event from the portal should work end-to-end even if the customer's endpoint returns a non-2xx response. Don't mark it as a failure — it's a test.
  • Dead-letter alerts: Customers should receive a notification (email or in-app) when an endpoint is dead-lettered. The alert should include the last HTTP status and a direct link to replay.
  • Secret visibility: Audit that the signing secret never appears in logs, error responses, or the delivery history view.
  • SSRF mitigation: Verify that internal IP ranges are blocked at both registration and delivery time.
  • Replay idempotency: Replaying a delivery sends the same event_id and payload as the original. Customers should design their handlers to be idempotent — document this clearly.
  • Subscription deletion: Deleting a subscription should cascade-delete delivery history and cancel any queued delivery jobs for that subscription.

If you're building on top of GetHook, the route, destination, and delivery attempt models map directly to the subscription and delivery tables described here. GetHook handles the delivery worker, retry scheduling, HMAC signing, and delivery history storage — you wire up the portal UI on top. See how it works →


The Support Ticket That Never Gets Filed

The payoff for building a complete self-serve portal isn't just developer experience — it's support cost reduction. Every customer who can look at a delivery timeline, see a 503 Service Unavailable response from their own server, and hit "Replay" after they fix the issue is a support ticket that never gets filed.

The signal that your portal is working: your webhook-related support volume drops within a week of shipping delivery history and the replay button.

Start with endpoint registration, filtering, signing, and delivery history. Ship that. Then add rotation, testing, and alerting. The must-haves alone will change how your customers relate to your webhook infrastructure.

Get started building your webhook infrastructure with GetHook →

Stop losing webhook events.

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