# Webhook events

> See the five events a signed webhook POST can send, how to verify the signature, how retries work and the address rules your receiving endpoint must meet.

A Spatibase webhook sends a signed `POST` to your server when published content changes, so you do not have to poll the Content Delivery API. This page is the reference for developers who receive those messages. Setting a webhook up in the web app is described on [Webhooks](https://spatibase.com/docs/publish/webhooks/).

Each project has one webhook address. Owners and developers set it up in the project's **API**, on the **Webhooks** page, or through the [Content Management API](https://spatibase.com/docs/reference/webhook-events/#manage-the-webhook-with-the-content-management-api).

## Events

A webhook message names one of five events:

| Event | Sent when |
|---|---|
| `entries.published` | Entries went live or changed at a project publish, or came back when going back to an earlier version |
| `entries.unpublished` | Entries came off the live API at a publish (or by going back) |
| `entries.deleted` | Entries were removed at a publish and went to the trash. Also sent, without `version`, when an entry that was never live is deleted |
| `groups.changed` | The live groups changed at a publish (a group was created, renamed or deleted, or its members or order changed), or by going back |
| `ping` | Someone pressed **Send test message** |

An event is sent after the change is saved, and only if something actually changed on the live API. That is at a **project publish** or when going back to a [version](https://spatibase.com/docs/publish/versions/): one publish sends up to one message per kind, all with the same `version`. Saving a draft, marking an entry ready, staging an unpublish or delete and editing a group send nothing until the publish, because none of it is part of Delivery output. No event is sent for content model changes, file changes, restores from the trash, or entries an import adds to groups: these can still change Delivery output, so compare `contentVersion` (below) when you need to catch everything.

## The message

The message body is JSON with ids only. Read the content itself from the [Content Delivery API](https://spatibase.com/docs/reference/delivery-api/).

```json
{
  "id": "1042",
  "type": "entries.published",
  "projectId": "9b2f6c1e-…",
  "createdAt": "2026-10-15T09:30:00.000Z",
  "contentVersion": 57,
  "version": 12,
  "entryIds": ["5c1e…", "a8d0…"]
}
```

| Field | Meaning |
|---|---|
| `id` | The delivery id. Retries of the same message keep it, so use it to ignore duplicates |
| `type` | The event |
| `projectId` | The project's UUID |
| `createdAt` | When the event was created |
| `contentVersion` | The project's content version when the event was sent. Compare it with the value you last saw |
| `version` | The number of the project version that the publish (or going back) created. Absent when an entry that was never live was deleted. Not the same number as `contentVersion` |
| `entryIds`, `groupIds` | Present when they apply. Up to 200 ids per list; when more entries changed, the list is cut, so re-read what you need |

Both [OpenAPI files](https://spatibase.com/docs/reference/delivery-api/#machine-readable-spec) describe these bodies under `webhooks`, one schema per event (`WebhookEntriesPublished`, `WebhookGroupsChanged`, `WebhookPing` …), so you can generate types for your receiver.

Headers sent with every message:

| Header | Value |
|---|---|
| `Content-Type` | `application/json` |
| `User-Agent` | `Spatibase-Webhooks/1` |
| `Spatibase-Event` | the event, as in `type` |
| `Spatibase-Delivery` | the delivery id, as in `id` |
| `Spatibase-Signature` | `t=<unix seconds>,v1=<hex signature>` |

## Check the signature

`Spatibase-Signature` has the form `t=1760000000,v1=<hex>`. `v1` is the hex HMAC-SHA256 of the text `<t>.<raw body>`, keyed with your signing secret. Check it against the body exactly as received (before any JSON parsing), and refuse old timestamps so a captured message cannot be replayed.

```js
import crypto from 'node:crypto'

export function verify(rawBody, header, secret) {
  const parts = Object.fromEntries(header.split(',').map(p => p.split('=')))
  const t = Number(parts.t)
  if (!t || Math.abs(Date.now() / 1000 - t) > 300) return false
  const expected = crypto.createHmac('sha256', secret).update(`${t}.${rawBody}`).digest('hex')
  const given = Buffer.from(parts.v1 || '', 'hex')
  const wanted = Buffer.from(expected, 'hex')
  return given.length === wanted.length && crypto.timingSafeEqual(given, wanted)
}
```

With Express, read the body raw for this route, check it, then parse it:

```js
app.post('/spatibase-webhook', express.raw({ type: 'application/json' }), (req, res) => {
  const body = req.body.toString('utf8')
  if (!verify(body, req.get('Spatibase-Signature') || '', process.env.SPATIBASE_WEBHOOK_SECRET)) return res.sendStatus(401)
  const event = JSON.parse(body)
  res.sendStatus(204)            // answer fast, then do the work
  handle(event)
})
```

The 300-second window in the example is your choice; Spatibase signs each attempt with the time it is sent.

## Delivery and retries

Spatibase counts a delivery as successful when your server answers with a `2xx` status within 5 seconds.

- Any other status, a timeout, or a redirect (redirects are never followed) is a failure.
- A failed message is tried up to 3 times in total: again after 1 second, then after 10 seconds. The same message with the same `id` is sent each time, so make your handler safe to run twice.
- Retries are not guaranteed: a message that is still waiting for a retry can be lost. Do not rely on webhooks alone. Compare `contentVersion` with the last value you saw, and re-read when it is higher.
- A webhook that is turned off sends no events (a test message is still sent). Removing the webhook stops pending retries.
- The last 50 deliveries per project are kept and shown in the web app with their result (**Delivered**, **Failed** or **Sending**) and the number of tries.
- **Send test message** sends a `ping` with a single attempt.

## Address rules

The webhook address must be a public `https://` URL.

- Up to 2000 characters, with no user name or password in it. Use the signing secret to prove who sent a message.
- The host name is resolved, and addresses in private, loopback, link-local, shared (CGNAT), unique-local and multicast ranges are refused, also when written as IPv4-mapped IPv6.
- The check runs when you save the address and again before every attempt, and the connection goes to the address that was checked.

## Signing secret

The signing secret (`whsec_…`) is the key your server uses to check each message. It is shown in full only twice: when you create the webhook and when you create a new secret. Afterwards only its first 10 characters are shown.

Creating a new secret replaces the old one immediately, and messages signed with the old secret stop arriving. Update your server first, or turn the webhook off while you switch. Treat the secret like a password: anyone who has it can send messages that look like they come from Spatibase.

## Manage the webhook with the Content Management API

All webhook endpoints are under `https://api.spatibase.com/api/projects/$PROJECT_ID/webhook` (`$PROJECT_ID` is the UUID in the project's address) and need a signed-in session of a project owner or developer, as described on [Content Management API](https://spatibase.com/docs/reference/management-api/#authentication-for-scripts). AI assistants cannot use them.

| Request | Result |
|---|---|
| `GET` | `{"configured": false}`, or `{configured, url, enabled, secretPrefix, createdAt, updatedAt, deliveries}` |
| `PUT {url, enabled?}` | `201` with `secret` when created, `200` when updated. `422 {message, errors: {url}}` for a bad address |
| `POST /test` | Sends a `ping` and returns the delivery `{id, event, status, responseCode, error, attempts, createdAt, deliveredAt}` |
| `POST /rotate` | A new signing secret, as `secret` in the answer |
| `DELETE` | `204`. Removes the webhook and its list of deliveries |

`status` of a delivery is `pending`, `delivered` or `failed`. `POST /test`, `POST /rotate` and `DELETE` answer `404` when the project has no webhook. Adding, changing, removing the webhook and creating a new secret appear in the project's Activity with the address's host only.

```bash
curl -s -b jar.txt -X PUT "https://api.spatibase.com/api/projects/$PROJECT_ID/webhook" \
  -H 'Content-Type: application/json' -d '{"url":"https://example.com/spatibase-webhook"}'
```
```json
{ "configured": true, "url": "https://example.com/spatibase-webhook", "enabled": true, "secretPrefix": "whsec_AbCd",
  "createdAt": "2026-10-15T09:00:00.000Z", "updatedAt": "2026-10-15T09:00:00.000Z", "deliveries": [],
  "secret": "whsec_AbCd…" }
```

## Related

- [Webhooks](https://spatibase.com/docs/publish/webhooks/)
- [Content Delivery API](https://spatibase.com/docs/reference/delivery-api/)
- [Content Management API](https://spatibase.com/docs/reference/management-api/)
- [Limits](https://spatibase.com/docs/reference/limits/)
