Skip to content

Webhooks

Outbound HMAC-signed HTTP delivery on record events + custom dispatch from hooks. Retry budget, dead-letter trail, SSRF guard.

Vaultbase ships outbound webhooks — when records change (or your hook code says so), it POSTs JSON to URLs you configure. Each delivery is HMAC-SHA-256 signed, retried on failure, and logged for audit.

A webhook lists which events it cares about. Patterns:

PatternMatches
posts.createexact
posts.*every event under posts
*everything

Built-in events fired automatically:

  • <collection>.create — after a record is created
  • <collection>.update — after a record is updated
  • <collection>.delete — after a record is deleted

Records created by auth flows (sign-up, invite, guest, OAuth2) and relations cleared by a cascade delete don’t fire these events.

Custom events fired from hooks/routes/cron:

await ctx.helpers.webhooks.dispatch("billing.invoice_paid", {
customer: ctx.record.customer_id,
amount: ctx.record.amount,
});
{
"id": "<uuid>",
"event": "posts.create",
"timestamp": 1735000000,
"data": { "record": { "id": "...", "title": "...", ... } }
}

For <collection>.delete, data is { "id": "...", "record": { ... } } with the deleted record. For custom events, data is what you passed to dispatch.

Headers on every request:

HeaderNotes
X-Vaultbase-Eventevent label, e.g. posts.create
X-Vaultbase-Deliveryunique delivery id (also visible in admin)
X-Vaultbase-Timestampunix-seconds when the request was prepared
X-Vaultbase-Signaturesha256=<hex> of HMAC over <timestamp>.<body>

Plus any custom headers you configured (X-API-Key, …). Authorization, Cookie, Set-Cookie, Host, Content-Type, Content-Length, Transfer-Encoding, User-Agent and the X-Vaultbase-* headers are reserved: a custom header with one of these names is dropped.

const expected = "sha256=" + hmacSha256(secret, `${ts}.${rawBody}`);
if (!constantTimeEqual(expected, header)) reject();
if (Date.now() / 1000 - parseInt(ts) > 300) reject(); // replay window

Always compare in constant time, and reject deliveries with a stale timestamp (5-minute window is a sane default).

Each webhook configures:

  • Retry max — attempts before giving up (default 3)
  • Retry backoff — exponential (1s, 2s, 4s, …) or fixed
  • Retry delay (ms) — base delay
  • Timeout (ms) — per-attempt request timeout (default 30 s)

Failure progression: pending → pending (retry scheduled) → … → dead.

A 2xx response marks the delivery succeeded and the row is finalised. Anything else (network error, timeout, 3xx, 4xx, 5xx) increments the attempt counter and reschedules — until retry_max attempts have been made, after which the row goes to dead. Redirects are not followed: a receiver that redirects must be configured with its final URL.

The admin UI shows recent deliveries per webhook: status badge, attempt count, response code, timestamps. Click a row for the full payload and response body (truncated to 2 KB).

Outbound URLs pass through the same egress CIDR filter as helpers.http. Default-deny RFC1918 / loopback / link-local. Override via Hook egress settings.

If a webhook’s URL resolves into a denied range, the delivery goes directly to dead with error: "egress blocked: …" — no retry, no network call.

GET /api/v1/admin/webhooks — list
GET /api/v1/admin/webhooks/:id — read one
POST /api/v1/admin/webhooks — create (URL must be http(s)://)
PATCH /api/v1/admin/webhooks/:id — update
DELETE /api/v1/admin/webhooks/:id — delete
POST /api/v1/admin/webhooks/:id/test — fire a synthetic event
GET /api/v1/admin/webhooks/:id/deliveries — recent deliveries
POST /api/v1/admin/webhooks/:id/deliveries/:deliveryId/redeliver
— send a past delivery again

A test fire on a disabled webhook is a 409. A redelivery is a new delivery with the same payload, so its id (the event id receivers dedupe on) is the original’s; the X-Vaultbase-Delivery header is new.

All admin-auth gated. Mutations are picked up by the audit log under webhooks.* actions.

/_/webhooks — two-pane editor:

  • List — name, status dot, URL preview
  • Editor — name, URL, event chips, HMAC secret (regenerate), custom headers, retry/timeout settings
  • Recent deliveries — table with click-through to full payload + response. Fire test button enqueues a synthetic event scoped to this webhook only (won’t disturb other subscribers). Redeliver in a delivery’s drawer sends its payload again.
  • In-process dispatcher — fires every 2 seconds, claim limit 50 deliveries per tick. Sufficient for most loads; for very high event volumes, several servers on one Postgres database share the dispatcher load (each delivery is claimed by one of them). vaultbase cluster is deprecated.
  • No webhook receives more than retry_max total attempts. Each delivery is claimed before it’s sent, so overlapping ticks and cluster workers never send the same attempt twice. If the process dies mid-send, the claim expires after 3 minutes and the attempt is retried.
  • Per-attempt timeout is capped at 120 s, whatever timeout_ms says.
  • Response body is captured to the delivery log up to 2048 bytes.
  • Finished deliveries (succeeded / dead) are kept 30 days by default, then pruned (Settings → Data retention, maintenance.retention_days, 0 = forever).
  • The dispatcher does not preserve cross-event order — slow consumers
    • retries can shuffle the receive order.