Webhooks
A webhook is a URL we POST when something happens in your database. Two event families:
- Row changes —
INSERT,UPDATE,DELETE,CREATE/ALTER/DROP. Same firing condition asLISTEN/NOTIFYbut pushed as HTTP. - Approval events —
approval_requiredwhen a write hits arequire_approvalrule,approval_resolvedwhen a reviewer decides. Use these to drive Slack alerts, page a reviewer, or kick a downstream workflow once the write actually runs.
Use webhooks when you want to trigger something outside PerSQL: kick off a job, sync to another system, fan out to a queue, send a notification.
Configure
Section titled “Configure”In the console: Database → Webhooks → New webhook. Two parallel HTTP surfaces — cookie-authed for the console, bearer-authed for agents and SDK callers:
POST /api/namespaces/:ns/databases/:db/webhooks # console cookiePOST /v1/db/:ns/:db/webhooks # admin-role bearerContent-Type: application/json
{ "name": "Sync to warehouse", "url": "https://hooks.acme.com/persql", "tables": ["orders", "customers"], "events": ["insert", "update"]}MCP agents call create_webhook with the same shape (admin-role token
required). list_webhooks and delete_webhook round it out.
Required: name, url (must be public https). Optional:
tables (array; null = all — ignored for approval events),
events (subset of
insert | update | delete | schema | approval_required | approval_resolved;
null = all), enabled (defaults to true).
The response includes a secret field — a 64-character
whsec_… string used to sign every delivery. It’s also returned
on subsequent GETs, so you can rotate it from the console with
POST /webhooks/:id/rotate-secret.
Payloads
Section titled “Payloads”Row change — fired for insert | update | delete | schema:
{ "id": "d7c1e2…", "type": "row.change", "ts": "2026-05-04T12:34:56.789Z", "database": "acme:orders", "table": "customers", "kind": "insert"}kind is one of insert | update | delete | schema | unknown.
We don’t currently include the row body — you fetch it back from
the database if you need it. Why: full-row payloads explode in
size on bulk updates and force schema-aware encoding decisions
(blobs, JSON columns) we’d rather not freeze early.
Approval required / resolved — fired when a write hits a
require_approval rule (required) or a reviewer decides (resolved):
{ "id": "8f0…", "type": "approval.required", "ts": "2026-05-17T12:30:00.000Z", "database": "acme:orders", "approvalToken": "appr_…", "status": "pending", "hits": [ { "ruleId": "apprule_…", "tableGlob": "production_*", "action": "require_approval", "matchedTable": "production_users", "note": null } ]}The resolved payload is identical except type is approval.resolved
and status is approved or denied. The reviewer is not included
in the payload — fetch via the console API if you need actor identity.
Headers
Section titled “Headers”Every delivery carries:
| Header | Notes |
|---|---|
User-Agent | PerSQL-Webhooks/1 |
X-PerSQL-Event | row.change, approval.required, approval.resolved, or test |
X-PerSQL-Webhook-Id | the webhook’s id |
X-PerSQL-Delivery-Id | unique per attempt of this delivery |
X-PerSQL-Timestamp | epoch ms of dispatch |
X-PerSQL-Signature | v1=<hex> — see below |
Verifying signatures
Section titled “Verifying signatures”Compute HMAC-SHA256(secret, "<timestamp>.<rawBody>") and
compare hex-encoded against the v1=… value of the
X-PerSQL-Signature header. Reject deliveries older than
~5 minutes — that’s the easiest way to defang replay.
Node:
import { createHmac, timingSafeEqual } from "node:crypto";
function verify(req: { headers: Record<string, string>; rawBody: string }, secret: string) { const ts = req.headers["x-persql-timestamp"]; const sig = req.headers["x-persql-signature"]; if (!ts || !sig) return false; if (Math.abs(Date.now() - Number(ts)) > 5 * 60_000) return false; const expected = "v1=" + createHmac("sha256", secret) .update(`${ts}.${req.rawBody}`) .digest("hex"); const a = Buffer.from(sig); const b = Buffer.from(expected); return a.length === b.length && timingSafeEqual(a, b);}Retries & backoff
Section titled “Retries & backoff”We treat 2xx as success. Any other status (or network failure / 10s timeout) triggers a retry with this schedule:
| Attempt | Wait before |
|---|---|
| 1 | 0 s |
| 2 | 30 s |
| 3 | 2 min |
| 4 | 10 min |
| 5 | 1 h |
| 6 | 6 h |
After 6 failures we drop the delivery. We do not queue deliveries that pile up while your endpoint is down indefinitely — this isn’t a durable queue, it’s a fan-out. If you need at-least-once durability, send to a queue (Cloudflare Queues, SQS) and process from there.
Idempotency: deliveries don’t carry an Idempotency-Key, but
X-PerSQL-Delivery-Id is unique per attempt, so retries arrive
with the same id. You can dedupe on it.
Filtering
Section titled “Filtering”tables— only fire when the SQL touches one of these tables. We sniff the table name from the SQL text (the same way realtime broadcasts do) — it works for typicalINSERT INTO foo,UPDATE foo SET …,DELETE FROM fooshapes. Bulk operations across multiple tables fire one event per statement.events— only fire when the kind matches.schemacoversCREATE/ALTER/DROP. Statements where we can’t determine a kind (unknown) only fire wheneventsis null.approval_required/approval_resolvedfire regardless of thetablesfilter — approval events are not table-scoped.
Test from the console
Section titled “Test from the console”The webhook detail page has a Send test button — it POSTs a
synthetic { "type": "test" } event and shows you the status
code and round-trip time. Use it to confirm your endpoint is
reachable + your signature verification is right before any real
events flow.
Caveats
Section titled “Caveats”- Public URLs only. We refuse
localhost,127.0.0.1,::1, and*.internal. Use a tunnel (ngrok, Cloudflare Tunnel) for local dev. - Order is best-effort. Two changes a millisecond apart on the same table may arrive at your endpoint out of order, especially after retries. Don’t rely on event order for state machines — re-read the row from the database when you need authoritative state.
- No row payload yet. Events carry the table name and operation but not the row contents. Re-fetch the row from the database when the handler runs.