---
title: Webhooks
description: Get an HTTP call when records or collections change, with signed payloads, automatic retries and a delivery log.
group: Integrate
order: 1
keywords: [webhooks, event notifications, hmac signature, retries, integrations]
---

# Webhooks

A webhook calls your server when something changes, so you can rebuild a site, post to
Slack, or sync another system — without polling.

## Set one up

**Settings → Webhooks → Create webhook.** You give it:

- the **URL** on your server to call;
- the **events** you care about;
- optionally the **collections** to watch, or all of them.

You get a signing secret starting `whsec_`, shown once. Keep it; you need it to check
that calls really came from us.

## Events

| Event                 | When                                           |
| --------------------- | ---------------------------------------------- |
| `record.created`      | A record was added                             |
| `record.updated`      | A record changed                               |
| `record.deleted`      | A record was moved to the trash                |
| `record.restored`     | A record came back from the trash              |
| `collection.created`  | A collection was added                         |
| `collection.updated`  | A collection's settings changed                |
| `collection.deleted`  | A collection was moved to the trash            |
| `collection.restored` | A collection came back                         |
| `schema.updated`      | A schema version was activated or switched off |

You can also subscribe to **all events**, which includes any added later. Imports and
bulk writes send one event per record.

## What arrives

```http
POST /hooks/baas HTTP/1.1
content-type: application/json
x-baas-event: record.created
x-baas-delivery: 9f3c1a7e-…
x-baas-timestamp: 1791406000
x-baas-signature: v1=5d41402abc4b2a76b9719d911017c592…
x-baas-webhook-id: 6b0e…

{
  "id": "0192f0b4-…",
  "type": "record.created",
  "createdAt": "2026-10-10T09:14:02.000Z",
  "workspaceId": "ac3385ca-…",
  "collectionId": "41a46615-…",
  "data": { "record": { "id": "01a12621-…", "data": { "title": "Ship it" } } }
}
```

Reply with any `2xx` as soon as you have the message. Do the slow part afterwards: we
give up on a call after 10 seconds and treat it as failed.

## Check the signature

Do this before you trust anything in the body. The signature is

```text
"v1=" + hex(HMAC_SHA256(secret, timestamp + "." + raw_body))
```

where `raw_body` is the **exact bytes** you received. Parsing the JSON first and
re-serialising it will change the bytes and the check will fail.

```js
// Node / Next.js route handler
import crypto from 'node:crypto';

export async function POST(request) {
  const raw = await request.text(); // raw bytes, before JSON.parse
  const timestamp = request.headers.get('x-baas-timestamp');
  const signature = request.headers.get('x-baas-signature');

  // Reject anything older than five minutes: stops a captured call being replayed.
  if (Math.abs(Date.now() / 1000 - Number(timestamp)) > 300)
    return new Response('stale', { status: 400 });

  const expected =
    'v1=' +
    crypto
      .createHmac('sha256', process.env.WEBHOOK_SECRET)
      .update(`${timestamp}.${raw}`)
      .digest('hex');

  const ok =
    signature?.length === expected.length &&
    crypto.timingSafeEqual(Buffer.from(signature), Buffer.from(expected));
  if (!ok) return new Response('bad signature', { status: 401 });

  const event = JSON.parse(raw);
  // … your work here …
  return new Response(null, { status: 204 });
}
```

```python
import hmac, hashlib, time

def verify(secret: str, body: bytes, timestamp: str, signature: str) -> bool:
    if abs(time.time() - int(timestamp)) > 300:
        return False
    mac = hmac.new(secret.encode(), f"{timestamp}.".encode() + body, hashlib.sha256)
    return hmac.compare_digest(f"v1={mac.hexdigest()}", signature)
```

Compare in constant time (`timingSafeEqual`, `compare_digest`) rather than with `==`.

## Delivery is at-least-once

The same event can arrive twice — for instance if your reply was lost on the way back.
Use `x-baas-delivery`, which is the same for every retry of one event, to ignore
duplicates:

```js
if (await alreadyHandled(deliveryId)) return new Response(null, { status: 204 });
```

## Retries and failures

A failed call is retried with increasing gaps — 15 seconds, 30 seconds, 1 minute, 2
minutes, and so on up to an hour — for up to 8 attempts. After that the delivery is
marked dead.

If 25 deliveries die in a row, the webhook is switched off automatically and the event
is recorded in your audit log. Re-enabling it clears the counter. This stops a dead
endpoint from being hammered forever.

Every attempt is logged with its status, duration and response, and kept for 30 days.
Open the webhook in the dashboard to see them, replay one, or send a test call
(`webhook.ping`) to check your endpoint before going live.

## URLs we refuse

Webhook URLs point outward from our servers, so they are restricted to protect both of
us: HTTPS in production, no credentials in the URL, no `localhost` or internal
hostnames, and no addresses that resolve to private networks. Redirects are not
followed. A URL that breaks these rules is rejected with `400 INVALID_WEBHOOK_URL` when
you save it.

To develop locally, use a tunnel such as ngrok or Cloudflare Tunnel and register the
public URL it gives you.

## Rotating the secret

Rotate from the webhook's menu. The new secret applies from the next delivery — there is
no overlap — so update your server first, or expect a few failed calls that will be
retried.

## Next steps

- [Recipes](/docs/recipes) — a webhook that rebuilds a static site.
- [Errors](/docs/errors) — the error codes.
