Article
Build your own webhook transport
How to build a webhook registry and delivery system on Postgres using outbox rows, delivery attempts, signatures, retries, and inbound inbox rows.
You may not need a broker when the integration shape is simple: your service needs to tell a few customers or partner systems that something happened.
A webhook registry is an easy transport to build yourself. Store subscriptions in Postgres, create delivery rows from your outbox, sign each HTTP request, retry with backoff, and let receivers put inbound webhooks into their own inbox table.
This post stands alone, but it assumes you already have a durable claim loop (inbox or outbox rows, leases, idempotent handlers). If you are building that foundation, start with the Durable Work in Postgres series.
Webhooks are transport, not coordination
The durable-work loop coordinates local work. A webhook transports an event across a boundary with HTTP. That makes webhooks a good fit when the receiver exposes an endpoint and you do not need shared broker infrastructure.
| Need | Use | Why |
|---|---|---|
Notify a customer app that OrderPaid happened | Webhook delivery | Simple HTTP contract, no shared broker account |
| Publish to many internal services with replay | Broker or event log | Fan-out, retention, consumer groups |
| Guarantee publish intent after your DB commit | Transactional outbox | Intent commits with the aggregate change |
| Process partner callbacks you receive | Inbound inbox | Acknowledge fast, process durably later |
Start with a webhook endpoint registry
The registry says who should receive which event types and how to authenticate delivery. Keep endpoint state explicit so support can pause, rotate secrets, inspect failures, and replay deliveries without editing code.
CREATE TYPE webhook_endpoint_status AS ENUM (
'active', 'paused', 'disabled'
);
CREATE TABLE webhook_endpoints (
id UUID PRIMARY KEY DEFAULT uuidv7(),
tenant_id TEXT NOT NULL,
url TEXT NOT NULL,
status webhook_endpoint_status NOT NULL DEFAULT 'active',
secret_ref TEXT NOT NULL, -- points to KMS/secret store
max_attempts INT NOT NULL DEFAULT 12,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE INDEX idx_webhook_endpoints_active
ON webhook_endpoints (tenant_id)
WHERE status = 'active';
CREATE TABLE webhook_subscriptions (
endpoint_id UUID NOT NULL REFERENCES webhook_endpoints(id),
event_type TEXT NOT NULL,
PRIMARY KEY (endpoint_id, event_type)
);
CREATE INDEX idx_webhook_subscriptions_event_type
ON webhook_subscriptions (event_type, endpoint_id);
Create delivery rows from outbox events
The domain transaction writes an outbox row. A relay worker reads that row and fans it out into one delivery row per matching endpoint. The delivery rows are what HTTP workers claim and send.
CREATE TYPE webhook_delivery_status AS ENUM (
'pending', 'processing', 'delivered', 'failed', 'dead_letter'
);
CREATE TABLE webhook_deliveries (
id UUID PRIMARY KEY DEFAULT uuidv7(),
endpoint_id UUID NOT NULL REFERENCES webhook_endpoints(id),
tenant_id TEXT NOT NULL,
event_id TEXT NOT NULL,
event_type TEXT NOT NULL,
payload JSONB NOT NULL,
status webhook_delivery_status NOT NULL DEFAULT 'pending',
attempts INT NOT NULL DEFAULT 0,
max_attempts INT NOT NULL DEFAULT 12,
claimed_by TEXT REFERENCES workers(id),
lease_expires_at TIMESTAMPTZ,
available_at TIMESTAMPTZ NOT NULL DEFAULT now(),
last_status_code INT,
last_error TEXT,
delivered_at TIMESTAMPTZ,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
UNIQUE (endpoint_id, event_id)
);
CREATE INDEX idx_webhook_deliveries_claim
ON webhook_deliveries (available_at, created_at, id)
WHERE status = 'pending';
CREATE TABLE webhook_delivery_attempts (
id UUID PRIMARY KEY DEFAULT uuidv7(),
delivery_id UUID NOT NULL REFERENCES webhook_deliveries(id),
attempted_at TIMESTAMPTZ NOT NULL DEFAULT now(),
status_code INT,
error TEXT,
duration_ms INT
);
INSERT INTO webhook_deliveries (endpoint_id, tenant_id, event_id, event_type, payload, max_attempts)
SELECT e.id, e.tenant_id, $event_id, $event_type, $payload, e.max_attempts
FROM webhook_endpoints e
JOIN webhook_subscriptions s ON s.endpoint_id = e.id
WHERE e.tenant_id = $tenant_id
AND e.status = 'active'
AND s.event_type = $event_type
ON CONFLICT (endpoint_id, event_id) DO NOTHING;
Deliver webhooks with the same claim loop
The delivery worker is just another durable worker. It claims pending delivery rows, signs the request, sends HTTP, records success, or schedules retry with backoff.
WITH picked AS (
SELECT id
FROM webhook_deliveries
WHERE status = 'pending'
AND available_at <= now()
ORDER BY created_at, id
LIMIT 25
FOR UPDATE SKIP LOCKED
)
UPDATE webhook_deliveries d
SET
status = 'processing',
claimed_by = $worker_id,
lease_expires_at = now() + interval '90 seconds',
attempts = attempts + 1
FROM picked
WHERE d.id = picked.id
RETURNING d.*;
async function deliverWebhook(delivery, endpoint) {
const timestamp = Math.floor(Date.now() / 1000);
const body = JSON.stringify({
id: delivery.event_id,
type: delivery.event_type,
created_at: delivery.created_at,
data: delivery.payload,
});
const signature = await signWebhook(endpoint.secret_ref, `${timestamp}.${body}`);
const res = await fetch(endpoint.url, {
method: "POST",
headers: {
"Content-Type": "application/json",
"Webhook-Id": delivery.event_id,
"Webhook-Timestamp": String(timestamp),
"Webhook-Signature": signature,
},
body,
});
if (res.status >= 200 && res.status < 300) {
await db.markWebhookDelivered(delivery.id, res.status);
return;
}
throw new RetryableDeliveryError(res.status);
}
Retry like a transport, not like a request handler
Webhook delivery is at-least-once. A timeout might mean the receiver processed the event but your worker never saw the response. Retries are normal, so every webhook event needs a stable id and every receiver needs dedupe.
| Response | Action | Reason |
|---|---|---|
| 2xx | Mark delivered | Receiver accepted the event |
| 408, 429, 5xx, timeout | Retry with backoff | Likely transient |
| 400, 401, 403, 404, 410 | Pause or dead-letter after policy | Likely configuration or contract issue |
| Repeated failures | Dead-letter and alert | Support needs a visible repair path |
UPDATE webhook_deliveries
SET
status = CASE
WHEN attempts >= max_attempts THEN 'dead_letter'::webhook_delivery_status
ELSE 'pending'::webhook_delivery_status
END,
claimed_by = NULL,
lease_expires_at = NULL,
available_at = now() + (LEAST(POWER(2, attempts)::int * 30, 86400) * interval '1 second'),
last_status_code = $status_code,
last_error = $error
WHERE id = $delivery_id;
Inbound webhooks become inbox rows
The receiver side should be boring: verify the signature, insert an inbox row with a unique idempotency key, and return 2xx after the row is durable. Processing happens later in the claim loop.
async function receiveWebhook(req, res) {
const rawBody = await readRawBody(req);
await verifyWebhookSignature({
body: rawBody,
timestamp: req.headers["webhook-timestamp"],
signature: req.headers["webhook-signature"],
secret: WEBHOOK_SECRET,
});
const event = JSON.parse(rawBody);
await db.query(
`
INSERT INTO inbox (partition_key, partition_bucket, payload, idempotency_key)
VALUES ($1, $2, $3, $4)
ON CONFLICT (idempotency_key) WHERE idempotency_key IS NOT NULL DO NOTHING
`,
[event.data.account_id, bucketFor(event.data.account_id), event, event.id],
);
res.status(204).send();
}
Make delivery inspectable and replayable
A self-built webhook transport is only credible if support can answer what happened without reading logs from five services.
| Support question | Query surface |
|---|---|
Which endpoints receive OrderPaid? | webhook_subscriptions(event_type) |
Did endpoint 42 receive event evt_123? | webhook_deliveries(endpoint_id, event_id) |
| Why did delivery fail? | webhook_delivery_attempts plus the latest delivery status |
| Can we replay it? | Set status = 'pending', clear lease, choose available_at |
| Should we pause an endpoint? | Set endpoint status = 'paused' |
Where a webhook registry fits
Strong fit
- Customer or partner notifications over HTTP
- Moderate fan-out where each receiver owns an endpoint
- Events already exist in your outbox
- Receivers can dedupe by event id
- You want SQL-visible delivery state and replay
Use a broker or stream when
- You need many independent internal consumers on the same log
- Replay and retention are central platform requirements
- Consumers need pull-based backpressure and consumer groups
- Delivery volume is high enough that HTTP fan-out is the bottleneck
- Ordering, partitioning, and retention need shared infrastructure
Sharp edges before you ship
- Signing: sign timestamp plus raw body. Reject old timestamps to limit replay attacks.
- Idempotency: every event needs a stable id; receivers must dedupe.
- Secrets: support secret rotation with overlapping active secrets.
- Retries: timeouts are ambiguous. Retrying can duplicate delivery.
- Replay: replay should create a new delivery attempt, not mutate history beyond recognition.
Source
Use the article for explanation, then use these files when you want the complete SQL and TypeScript in one place.