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
- An event occurs (a row is inserted, a user signs up…).
- The control plane writes a delivery row and emits a
pg_notify. - The standalone webhooks service is
LISTENing; it picks up the delivery and POSTs your endpoint with a signed body. - 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
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 itManagement API
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.deleteduser.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:
- Create an endpoint subscribed to the table’s events (above) — e.g.
db.public.orders.*for any change onpublic.orders. - 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
eventsyou pass decide which operations fire.
Attach
# CLI — events defaults to insert,update,delete when omittedanvilbase webhooks attach \ --project <id> --webhook <wid> \ --table orders --events insert,update,delete # --schema public (default)
# Management APIcurl -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
# Stop firing on a table (idempotent)anvilbase webhooks detach --project <id> --webhook <wid> --table orders
# What's attached for this projectanvilbase webhooks attachments --project <id> --webhook <wid># SCHEMA TABLE EVENTS# public orders INSERT,UPDATE,DELETEHow 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
dblinkto enqueue a delivery andpg_notifythe webhooks service. Two properties keep this isolated between projects:
Least-privilege transport. The
dblinkconnection string is stored in a locked-down per-project config table (anvilbase_internal.webhook_bridge) that only the trigger (aSECURITY DEFINERfunction running as the database owner) and superusers can read — theanvilbase_internalschema is not granted to youranon/authenticated/service_roleroles, so the connection string is not tenant-readable at all. It uses a dedicatedanvilbase_webhook_bridgerole whose only rights areINSERTintowebhook_deliveriesandSELECTof a fewwebhook_endpointscolumns — nothing else. It cannot read API keys, theprojectstable, 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’sCONTROL_PLANE_SECRETand never stored in plaintext. As additional defense-in-depth,EXECUTEon the connection-capabledblinkfunctions (dblink,dblink_connect,dblink_exec) is revoked fromPUBLICand granted only to theanvilbasedatabase-owner role that theSECURITY DEFINERtrigger runs as — so your tenant roles cannot open arbitrarydblinkconnections at all, independent ofpg_hbaordering. (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_dblinksetting applied withALTER 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 aWARNINGrather 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 deduplicateThe 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 JSONapp.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
# List endpointsanvilbase webhooks list --project <id>
# Send a synchronous test event (30s timeout) — great for first setupanvilbase 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,deleteanvilbase webhooks detach --project <id> --webhook <wid> --table ordersanvilbase webhooks attachments --project <id> --webhook <wid>
# Deleteanvilbase 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
| Setting | Default | Meaning |
|---|---|---|
WEBHOOK_MAX_RETRIES | 5 | attempts before dead-lettering |
WEBHOOK_TIMEOUT | 30 | per-attempt HTTP timeout (seconds) |
WEBHOOK_RETRY_BASE_DELAY | 10 | base backoff seconds between attempts |
WEBHOOK_MAX_CONCURRENT | 10 | max 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.