Skip to content

Webhooks

A webhook is a URL we POST when something happens in your database. Two event families:

  • Row changesINSERT, UPDATE, DELETE, CREATE/ALTER/DROP. Same firing condition as LISTEN/NOTIFY but pushed as HTTP.
  • Approval eventsapproval_required when a write hits a require_approval rule, approval_resolved when 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.

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 cookie
POST /v1/db/:ns/:db/webhooks # admin-role bearer
Content-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.

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.

Every delivery carries:

HeaderNotes
User-AgentPerSQL-Webhooks/1
X-PerSQL-Eventrow.change, approval.required, approval.resolved, or test
X-PerSQL-Webhook-Idthe webhook’s id
X-PerSQL-Delivery-Idunique per attempt of this delivery
X-PerSQL-Timestampepoch ms of dispatch
X-PerSQL-Signaturev1=<hex> — see below

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);
}

We treat 2xx as success. Any other status (or network failure / 10s timeout) triggers a retry with this schedule:

AttemptWait before
10 s
230 s
32 min
410 min
51 h
66 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.

  • 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 typical INSERT INTO foo, UPDATE foo SET …, DELETE FROM foo shapes. Bulk operations across multiple tables fire one event per statement.
  • events — only fire when the kind matches. schema covers CREATE/ALTER/DROP. Statements where we can’t determine a kind (unknown) only fire when events is null. approval_required / approval_resolved fire regardless of the tables filter — approval events are not table-scoped.

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.

  • 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.