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.

NeedUseWhy
Notify a customer app that OrderPaid happenedWebhook deliverySimple HTTP contract, no shared broker account
Publish to many internal services with replayBroker or event logFan-out, retention, consumer groups
Guarantee publish intent after your DB commitTransactional outboxIntent commits with the aggregate change
Process partner callbacks you receiveInbound inboxAcknowledge 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.

Webhook registry and delivery tables
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.

ResponseActionReason
2xxMark deliveredReceiver accepted the event
408, 429, 5xx, timeoutRetry with backoffLikely transient
400, 401, 403, 404, 410Pause or dead-letter after policyLikely configuration or contract issue
Repeated failuresDead-letter and alertSupport 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 questionQuery 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.