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.
| Feature | Description | Priority |
|---|---|---|
| Endpoint registration | Register an HTTPS URL to receive events | Must-have |
| Event type filtering | Subscribe to specific event types only | Must-have |
| Signing secret management | View, rotate, or regenerate HMAC secret | Must-have |
| Delivery history | See recent delivery attempts with status and response | Must-have |
| Replay | Retry a failed delivery manually | Must-have |
| Pause / disable | Stop delivery to an endpoint without deleting it | Should-have |
| Multiple endpoints | Register more than one endpoint per account | Should-have |
| Custom headers | Send additional headers with each webhook | Nice-to-have |
| Failure alerts | Email or Slack notification on dead-letter | Nice-to-have |
| Endpoint testing | Send a test event to verify configuration | Nice-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.
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 deliveryThe 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:
{
"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:
- ›Query all active subscriptions for the account where
event_types = '{}' OR 'invoice.paid' = ANY(event_types) - ›For each matching subscription, insert a delivery job into your queue
- ›Worker picks up the job, decrypts the signing secret, builds the request, signs the payload, delivers it
- ›On success: mark delivered, store response body (truncated)
- ›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:
| Attempt | Delay after previous |
|---|---|
| 1 | Immediate |
| 2 | 30 seconds |
| 3 | 5 minutes |
| 4 | 30 minutes |
| 5 | 2 hours |
| Dead-letter | No 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:
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:
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:
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_idand 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 →