Skip to content

Webhooks

Webhooks turn events inside AnvilBase — row changes and auth events — into signed HTTP POSTs to your endpoints. A dedicated Rust delivery service handles signing, timeouts, retries with exponential backoff, a delivery log, and a dead-letter queue.

How delivery works

  1. An event occurs (a row is inserted, a user signs up…).
  2. The control plane writes a delivery row and emits a pg_notify.
  3. The standalone webhooks service is LISTENing; it picks up the delivery and POSTs your endpoint with a signed body.
  4. On failure it retries with exponential backoff (up to WEBHOOK_MAX_RETRIES, default 5). Exhausted deliveries move to the dead-letter queue.

Every attempt is recorded so you can inspect exactly what happened.

Create an endpoint

CLI

Terminal window
anvilbase webhooks create \
--project <id> \
--url https://app.example.com/hook \
--events "user.created,db.public.orders.INSERT" \
--description "order + signup notifications"
# prints the signing secret ONCE — save it

Management API

Terminal window
curl -X POST http://localhost:39001/api/v1/projects/<id>/webhooks \
-H "Authorization: Bearer $ANVILBASE_TOKEN" -H "Content-Type: application/json" \
-d '{
"url": "https://app.example.com/hook",
"events": ["user.created","db.public.orders.INSERT"],
"description": "order + signup notifications"
}'
# → { id, url, events, is_active, secret, created_at } (secret shown once)

The secret (a whsec_… value) is returned once — store it; you’ll use it to verify signatures.

SSRF guard (create-time and dispatch-time). Create/update/test reject loopback, RFC1918, CGNAT, and link-local targets (including the cloud-metadata IP 169.254.169.254), so a webhook can’t be pointed at internal infrastructure. Because a hostname that resolved to a public IP at create time could later be re-pointed at an internal IP (DNS rebinding), the delivery service also re-validates the resolved IP at dispatch time: it resolves the target host and refuses to connect if it lands in a blocked range (the request is recorded as a failed delivery, never silently dropped). See Network Security.

Event types

Subscribe to any mix of:

Auth events

  • user.created, user.updated, user.deleted
  • user.signed_in, user.signed_out

Database row-change events (from triggers on your project tables)

  • db.<schema>.<table>.<insert|update|delete> — e.g. db.public.orders.insert
  • Wildcards: db.<schema>.<table>.* (any op on a table), or * (everything)

Database (row-change) webhooks — attaching to a table

Database webhooks are Supabase-compatible: a row INSERT/UPDATE/DELETE on one of your tables fires a signed HTTP delivery. Two steps:

  1. Create an endpoint subscribed to the table’s events (above) — e.g. db.public.orders.* for any change on public.orders.
  2. Attach the row-change trigger to the table. Until you attach, the endpoint exists but no row changes fire. Attaching is idempotent (re-attaching replaces the trigger) and the events you pass decide which operations fire.

Attach

Terminal window
# CLI — events defaults to insert,update,delete when omitted
anvilbase webhooks attach \
--project <id> --webhook <wid> \
--table orders --events insert,update,delete # --schema public (default)
# Management API
curl -X POST http://localhost:39001/api/v1/projects/<id>/webhooks/<wid>/attach \
-H "Authorization: Bearer $ANVILBASE_TOKEN" -H "Content-Type: application/json" \
-d '{ "table": "orders", "events": ["insert","update","delete"], "schema": "public" }'
# → { schema, table, events } (events uppercased)

The control plane validates that the table exists (rejects typos/injection with a 400) and that every event is one of insert, update, delete. The attach route is deploy-scoped (trigger DDL on a user table), consistent with schema migrations — a CI deploy token can wire database webhooks.

Detach & list

Terminal window
# Stop firing on a table (idempotent)
anvilbase webhooks detach --project <id> --webhook <wid> --table orders
# What's attached for this project
anvilbase webhooks attachments --project <id> --webhook <wid>
# SCHEMA TABLE EVENTS
# public orders INSERT,UPDATE,DELETE

How it works under the hood, and why it’s safe across tenants. The trigger function is installed in your project database at provisioning time (and lazily re-ensured on first attach for older projects). It bridges to the platform database via dblink to enqueue a delivery and pg_notify the webhooks service. Two properties keep this isolated between projects:

  • Least-privilege transport. The dblink connection string is stored in a locked-down per-project config table (anvilbase_internal.webhook_bridge) that only the trigger (a SECURITY DEFINER function running as the database owner) and superusers can read — the anvilbase_internal schema is not granted to your anon/authenticated/service_role roles, so the connection string is not tenant-readable at all. It uses a dedicated anvilbase_webhook_bridge role whose only rights are INSERT into webhook_deliveries and SELECT of a few webhook_endpoints columns — nothing else. It cannot read API keys, the projects table, your secrets, or any other project’s data. So even if that connection string were extracted, the worst it permits is enqueuing deliveries to already-configured endpoints (which the trigger does anyway for legitimate writes). Its password is derived from the deployment’s CONTROL_PLANE_SECRET and never stored in plaintext. As additional defense-in-depth, EXECUTE on the connection-capable dblink functions (dblink, dblink_connect, dblink_exec) is revoked from PUBLIC and granted only to the anvilbase database-owner role that the SECURITY DEFINER trigger runs as — so your tenant roles cannot open arbitrary dblink connections at all, independent of pg_hba ordering. (dblink_connect_u, the unprivileged-auth variant, stays superuser-only and is never used by the bridge.)

    (Self-hosting note: earlier builds read this connection string from a custom app.platform_dblink setting applied with ALTER DATABASE … SET. On a privileged-extension-gated image such a default for a custom setting was never applied, so the trigger read an empty value and delivery silently did nothing. Delivery now reads the config table instead — no database-level setting is involved, and a missing/empty config row is logged as a WARNING rather than silently skipped.)

  • Database-bound project identity. The delivery is attributed to your project by deriving the project id from the connected database name (platform_<uuid>), which cannot be changed mid-session. It is not taken from a session setting, so a caller cannot point a row change at another project’s webhooks by overriding a variable.

Payload format

Database (row-change) events use the Supabase-compatible shape, so apps migrating from Supabase work unchanged:

{
"type": "INSERT",
"table": "orders",
"schema": "public",
"record": { "id": 1, "total": 4200, "status": "paid" },
"old_record": null,
"event": "db.public.orders.insert",
"project_id": "3f2b…",
"timestamp": "2026-06-04T10:00:00Z"
}

record is the new row (INSERT/UPDATE) and old_record the previous row (UPDATE/DELETE); the unused one is null. The extra event/project_id/timestamp fields are additive — Supabase clients ignore them.

Auth events use:

{
"id": "evt_abc123",
"type": "user.created",
"project_id": "3f2b…",
"created_at": "2026-06-04T10:00:00Z",
"data": { "user_id": "", "email": "alice@example.com" }
}

Verifying the signature

One signature scheme for every delivery. Test deliveries and production deliveries (auth + database webhooks) are signed identically, so a single verification routine works for all of them. Every delivery includes these headers:

X-Webhook-Signature: v1=<hex hmac>
X-Webhook-Timestamp: <unix seconds>
X-Webhook-ID: <uuid> # unique per attempt; use it to deduplicate

The signature is HMAC-SHA256, keyed with the endpoint’s signing secret (whsec_…, shown once at create), computed over the string "{X-Webhook-Timestamp}.{raw_body}" — i.e. the timestamp, a literal ., then the exact raw request body. Binding the timestamp into the signature lets you reject stale replays. The output is prefixed with v1=.

Replay protection. After the signature verifies, also reject the delivery if X-Webhook-Timestamp is too far from your clock. Use a 5-minute (300 s) tolerance window — large enough to absorb retries and clock skew, small enough that a captured delivery can’t be replayed indefinitely.

Verify with a constant-time comparison, using the raw body (not re-serialized JSON):

import crypto from 'node:crypto'
function verify(rawBody: string, sigHeader: string, tsHeader: string, secret: string): boolean {
const signed = `${tsHeader}.${rawBody}`
const expected = 'v1=' + crypto.createHmac('sha256', secret).update(signed).digest('hex')
const a = Buffer.from(sigHeader), b = Buffer.from(expected)
return a.length === b.length && crypto.timingSafeEqual(a, b)
}
// Express handler — note: use the RAW body, not the parsed JSON
app.post('/hook', express.raw({ type: 'application/json' }), (req, res) => {
const sig = req.header('X-Webhook-Signature')!
const ts = req.header('X-Webhook-Timestamp')!
if (!verify(req.body.toString('utf8'), sig, ts, process.env.WHSEC!)) {
return res.status(400).send('bad signature')
}
// Reject replays: the timestamp is signed, so use a 300s tolerance window.
const skew = Math.abs(Date.now() / 1000 - Number(ts))
if (!Number.isFinite(skew) || skew > 300) {
return res.status(400).send('stale timestamp')
}
const event = JSON.parse(req.body.toString('utf8'))
// …handle event…
res.sendStatus(200)
})
import hmac, hashlib
def verify(raw_body: bytes, sig_header: str, ts_header: str, secret: str) -> bool:
signed = f"{ts_header}.".encode() + raw_body
expected = "v1=" + hmac.new(secret.encode(), signed, hashlib.sha256).hexdigest()
return hmac.compare_digest(expected, sig_header)

Respond with a 2xx to acknowledge. Any non-2xx (or a timeout) triggers a retry.

Manage, test, and inspect

Terminal window
# List endpoints
anvilbase webhooks list --project <id>
# Send a synchronous test event (30s timeout) — great for first setup
anvilbase webhooks test --project <id> --id <wid>
# Update (at least one of url/events/is-active)
anvilbase webhooks update --project <id> --id <wid> --is-active false
# View delivery attempts (status, response code, latency)
anvilbase webhooks deliveries --project <id> --id <wid>
# Attach / detach the row-change trigger on a table (database webhooks)
anvilbase webhooks attach --project <id> --webhook <wid> --table orders --events insert,update,delete
anvilbase webhooks detach --project <id> --webhook <wid> --table orders
anvilbase webhooks attachments --project <id> --webhook <wid>
# Delete
anvilbase webhooks delete --project <id> --id <wid>

Management API equivalents live under /api/v1/projects/{id}/webhooks/* — see Reference → Management API. The test call returns { success, status_code, response_body, latency_ms, error }, and is blocked by the SSRF guard if the target is internal.

Retries and timeouts

SettingDefaultMeaning
WEBHOOK_MAX_RETRIES5attempts before dead-lettering
WEBHOOK_TIMEOUT30per-attempt HTTP timeout (seconds)
WEBHOOK_RETRY_BASE_DELAY10base backoff seconds between attempts
WEBHOOK_MAX_CONCURRENT10max concurrent in-flight deliveries

Retries use exponential backoff. Make your handler idempotent — a delivery can arrive more than once (e.g. you 200’d but the ack was lost). The id field deduplicates.

Dead-letter queue

Deliveries that exhaust their retries become dead_letter. They’re retained (default 30 days) so you can investigate, then pruned automatically by a nightly pg_cron job. You can retry a dead-lettered delivery from the console or the delivery API, and operators can trigger the purge manually if pg_cron isn’t installed.

Database vs. function webhooks

  • Outbound events from AnvilBase to your URL → use this webhooks service (signing, retries, delivery log).
  • Inbound webhooks into AnvilBase from a third party (Stripe, GitHub) → use a public edge function.

Next: Secrets Vault.