# Webhooks

Source: https://dabadata.com/docs/webhooks/ (HTML) - https://dabadata.com/docs/webhooks/index.md (this page as markdown)

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](https://dabadata.com/docs/projects/index.md#restricted).

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

```ts
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](https://dabadata.com/docs/rules/index.md#row-conditions) 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](https://dabadata.com/docs/projects/index.md#secrets) 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:

```json
{
  "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
  }
}
```

- `type` is `<table>.<op>`, or `action.<name>` for an action hook, whose `data` is `{ action, params, caller: { id, role }, results: [{ row }], traceId }`.
- `row` is null for a delete, `before` null for an insert. Rows are sent in full: column read rules do not apply, because the receiver is your own server.
- `traceId` is 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](https://dabadata.com/docs/webhooks/index.md#from-your-backend) if you need it.

The headers follow the [Standard Webhooks](https://www.standardwebhooks.com/) 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:

```ts
// 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:

```ts
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.

```ts
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-After` on 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 compare `timestamp` (or the row's `updated_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 `localhost` or 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](https://dabadata.com/docs/billing/index.md), 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](https://dabadata.com/docs/trace/index.md) 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](https://dabadata.com/docs/projects/index.md#restricted) with a role that may do exactly that (best through an action), not the secret key. [Call dabadata from your backend](https://dabadata.com/docs/backend/index.md) has the full patterns for Vercel and Workers.
