Concepts
Webhooks
A webhook tells your own server that something happened in your project: a row was written, an action ran. dabadata sends a signed HTTPS request to your endpoint after the write commits, retries it when your endpoint is down, and shows every attempt in the trace and on the console's Webhooks page. Your logic lives wherever you like: a Vercel route, a Cloudflare Worker, your own box.
Use a webhook when your backend has to react to a change: mail a receipt when an order is paid, call a payment provider, update a search index. Serverless functions cannot hold a live socket, and the secret key cannot open one; a webhook is the way in. To write from your backend, see restricted keys.
Declaring a hook
Hooks live in the definition, next to tables and actions, under webhooks. Each environment has its own definition, so live and test can point at different endpoints.
webhooks: {
orderPaid: {
on: { table: "orders", ops: ["update"], where: "status = 'paid'", changed: ["status"] },
url: "https://example.com/api/dabadata-hook",
},
rsvpMade: {
on: { action: "rsvp" }, // after the action's steps commit
urlSecret: "RSVP_HOOK_URL", // a project secret holding the URL
},
},| Key | What it does |
|---|---|
on.table | A declared table. Every committed insert, update or delete of a row fires the hook (subject to the keys below). |
on.ops | Which of insert, update, delete. Default all three. |
on.where | A condition in the row condition language, read on the row after the write (before it, for a delete). $user is not available: a hook has no caller. |
on.changed | Columns; an update fires only when one of them changed. where plus changed is "the moment the order became paid", not every later edit of a paid order. |
on.action | A declared action. Fires once per successful call. |
url | Your endpoint. https:// on hosted projects. |
urlSecret | Instead of url: the name of a project secret that holds the URL. Handy when the definition file is shared between environments. |
enabled | false keeps the hook declared but sends nothing and records nothing. |
Hook names are letters, digits and _, starting with a letter. The Free plan allows 5 hooks per environment, paid plans 50; a definition with more is refused with webhooks: at most 5 hooks per environment on the free plan.
What fires a table hook: every write through the data API - /v1/mutate, /v1/transact, action steps, MCP tool writes, and rows changed by a ref's onDelete. SQL console writes, migrations and the nightly job do not. The event is stored in the same database transaction as the write, so a committed write always has its events and a rolled-back one never does.
What is sent
A POST with a JSON body:
{
"type": "orders.update",
"timestamp": "2026-10-19T09:30:00.000Z",
"hook": "orderPaid",
"projectId": "prj_example12345",
"env": "live",
"data": {
"table": "orders",
"op": "update",
"id": "ord_123",
"row": { "id": "ord_123", "status": "paid", "total": 4200 },
"before": { "id": "ord_123", "status": "pending", "total": 4200 },
"traceId": 1234
}
}typeis<table>.<op>, oraction.<name>for an action hook, whosedatais{ action, params, caller: { id, role }, results: [{ row }], traceId }.rowis null for a delete,beforenull for an insert. Rows are sent in full: column read rules do not apply, because the receiver is your own server.traceIdis the trace row of the write that caused the event: open it in the console to see who wrote what and why it was allowed.- A body above 256 KB sends the rows as
{ "id" }only, with"truncated": true; read the row back with a restricted key if you need it.
The headers follow the Standard Webhooks spec:
| Header | Value |
|---|---|
webhook-id | The delivery id, msg_.... The same on every retry and on "Send again": store it and skip ids you have seen. |
webhook-timestamp | Unix seconds of this attempt. |
webhook-signature | v1,<base64 HMAC-SHA256> of <id>.<timestamp>.<body>, with the hook's signing secret. Two signatures, space separated, while a secret is being rotated. |
Content-Type | application/json |
User-Agent | dabadata-webhooks/1 |
Verifying a delivery
Each hook has its own signing secret, whsec_.... Copy it from the console (Webhooks, Signing secret, developers and up) or read it with the secret key, and put it in your endpoint's environment. Verify with the Standard Webhooks library for your language (standardwebhooks on npm and PyPI; Go, Ruby, Java, Rust, PHP and C# too): it checks the signature and that the timestamp is within 5 minutes. Always verify the raw body, before parsing it.
A Next.js route on Vercel:
// app/api/dabadata-hook/route.ts
import { Webhook } from "standardwebhooks";
const wh = new Webhook(process.env.DABADATA_WEBHOOK_SECRET!); // whsec_...
export async function POST(req: Request) {
const body = await req.text(); // the raw body, exactly as sent
let event: any;
try {
event = wh.verify(body, Object.fromEntries(req.headers));
} catch {
return new Response("bad signature", { status: 400 });
}
if (event.type === "orders.update") {
await sendReceipt(event.data.row); // your code
}
return new Response(null, { status: 204 });
}A Cloudflare Worker:
import { Webhook } from "standardwebhooks";
export default {
async fetch(req: Request, env: { DABADATA_WEBHOOK_SECRET: string }) {
const body = await req.text();
try {
const event = new Webhook(env.DABADATA_WEBHOOK_SECRET).verify(body, Object.fromEntries(req.headers));
// ... do the work, or queue it and answer at once
return new Response(null, { status: 204 });
} catch {
return new Response("bad signature", { status: 400 });
}
},
};Without a library: the key is the base64 after whsec_, decoded to bytes.
import { createHmac, timingSafeEqual } from "node:crypto";
function verify(secret: string, headers: Headers, body: string): boolean {
const id = headers.get("webhook-id")!, ts = headers.get("webhook-timestamp")!;
if (Math.abs(Date.now() / 1000 - Number(ts)) > 300) return false; // replayed or late
const key = Buffer.from(secret.slice("whsec_".length), "base64");
const want = createHmac("sha256", key).update(`${id}.${ts}.${body}`).digest();
return headers.get("webhook-signature")!.split(" ").some((s) => {
const got = Buffer.from(s.split(",")[1] ?? "", "base64");
return got.length === want.length && timingSafeEqual(got, want);
});
}Rotate secret on the console (or POST /v1/project/webhooks/<name>/rotate-secret) makes a new secret; for 24 hours every delivery carries signatures with both, so you can update your endpoint without dropping anything.
Delivery and retries
- A delivery succeeds on any 2xx within 15 seconds. Answer quickly and do slow work afterwards.
- Otherwise it is tried again after 1 minute, 5 minutes, 30 minutes, 2 hours and 12 hours (each give or take 10 %). After the 6th failed attempt it is
failed. - A
Retry-Afteron a 429 or 503 is honoured (up to 12 hours). - Redirects are not followed: a 3xx is a failure. Update the URL instead.
- A 410 Gone fails the delivery at once and pauses the hook.
- Delivery is at least once, in no guaranteed order: dedupe on
webhook-id, and comparetimestamp(or the row'supdated_at) if order matters. - At most 4 requests are in flight to one hook at a time, so a bulk update does not flood your endpoint.
- On hosted projects the URL must be https and resolve to public addresses only. A URL naming
localhostor a private IP is refused when you save the definition, and each delivery goes to the address that was checked, so a name that changes its answer between the check and the request cannot reach a private address.
Paused hooks. A hook whose attempts have all failed for 3 days, or that got a 410, is paused and the workspace owner is mailed. While paused, new events are listed as skipped (hook paused). Fix the endpoint and press Resume: the events the pause stopped are sent again.
Monthly allowance
Deliveries are counted per event, workspace-wide, per calendar month (UTC) - retries and "Send again" of a delivered or failed event are free.
| Free | Starter | Pro | Scale | |
|---|---|---|---|---|
| Deliveries / month | 10,000 | 100,000 | 1,000,000 | 10,000,000 |
| Hooks per environment | 5 | 50 | 50 | 50 |
- Free stops at the allowance: further events of the month are
skipped(over the monthly limit). After an upgrade, Send skipped again sends them (they count then). - Paid plans use the same overage cap as connections and egress (Usage and plan, owner only): at the allowance by default. Raise it and deliveries go on; the part above the allowance is billed per block of 100,000. At the cap events are
held, not dropped: they go out as soon as the cap is raised or the month changes, and an event held for 7 days is skipped. - The workspace owner is mailed at 80 % and at 100 % of the allowance, and when events start being held.
On the console
The Webhooks page of a project shows each hook (what it fires on, where it sends, paused or not, the last delivery), the month's meter, and every delivery with its status: pending, delivered, failed, held or skipped. Open one to see the exact body sent, the response (its first 1 KB) and every attempt. Developers and up can Send again (same webhook-id), Send test event (a webhook.test event, not counted), reveal or rotate the signing secret, and resume a paused hook.
In the trace
Every attempt is a trace row: op: "webhook", source: "webhook", the event's table, the delivery id as rowId, result ok or error with the reason (HTTP 500, timeout after 15 s, refused: ..., over the monthly limit, held at the cap, hook paused), the duration, and values { hook, type, attempt, status, httpStatus, cause } - cause being the trace id of the write. So the console's Trace page answers "this row changed, and here is what we told your server about it".
Routes
With the environment's secret key (or a console session: viewers read, developers and up change):
| Route | What |
|---|---|
GET /v1/project/webhooks | The hooks with their state, and usage: { month, used, allowance, cap }. |
GET /v1/project/webhooks/deliveries | ?hook=&status=&limit=50&before=<id>, newest first. |
GET /v1/project/webhooks/deliveries/<id> | One delivery with its body, last response and attempts. |
POST /v1/project/webhooks/deliveries/<id>/resend | Send it again now, same id. A skipped one counts now (403 plan_limit when the month is used up). |
POST /v1/project/webhooks/deliveries/resend | { status: "skipped" | "failed", hook? }: send them all again (at most 10,000) -> { resent, limited }. |
GET /v1/project/webhooks/<name>/secret | { secret, previous }. Developers and up. |
POST /v1/project/webhooks/<name>/rotate-secret | A new secret; the old one keeps signing for 24 hours. |
POST /v1/project/webhooks/<name>/resume | Unpause and send again what the pause stopped. |
POST /v1/project/webhooks/<name>/test | Send a webhook.test event now and answer the outcome. |
Deliveries are kept as long as the plan keeps the trace.
Reading and writing back
A webhook handler often needs to write something back - mark the order as mailed, store the payment id. Give it a restricted key with a role that may do exactly that (best through an action), not the secret key. Call dabadata from your backend has the full patterns for Vercel and Workers.