# Webhooks — get told when something changes

Register an HTTPS URL and the service `POST`s a small signed JSON message to it whenever a booking,
ticket or payment changes — no matter where the change came from: the widget, the public API, the
admin, the management API, the MCP server, or a scheduled job (a payment that expires, an automatic
status change). Webhooks replace polling `GET payments/<token>` or the appointment list.

- Managed with the [management API](../api/README.md) (scope `settings:read` / `settings:write`), the
  MCP tools `list_webhooks`, `create_webhook`, `delete_webhook`, `test_webhook`,
  `list_webhook_deliveries`, or the admin (*Instellingen → Webhooks*).
- Signed per the [Standard Webhooks](https://www.standardwebhooks.com/) spec: any of its libraries
  verifies our messages, or use the 15 lines of plain Node below.
- Delivery is **at least once**, in no guaranteed order. Deduplicate on `webhook-id`.
- Messages are **thin**: ids and status fields only. Fetch the details you need from `/api/v1`.

## Contents

- [Event types](#event-types)
- [Register an endpoint](#register-an-endpoint)
- [The message](#the-message)
- [Verify the signature](#verify-the-signature)
- [Responding, retries and the deliveries log](#responding-retries-and-the-deliveries-log)
- [Test, rotate, disable](#test-rotate-disable)
- [Write an idempotent consumer](#write-an-idempotent-consumer)
- [Endpoint reference](#endpoint-reference)

## Event types

| `type` | Sent when |
|---|---|
| `booking.created` | A booking is made (widget, public API, admin, management API, MCP) |
| `booking.updated` | A booking's status or number of persons changes, or its appointment is moved (start time, staff member, service) |
| `booking.cancelled` | A booking becomes `cancelled` or `rejected` (customer link, admin, API, unpaid payment expiring) |
| `ticket.created` | An event ticket (attendee) is created |
| `ticket.approved` | A ticket is approved (free order, payment completed, or marked paid at the desk) |
| `ticket.cancelled` | A ticket is cancelled |
| `payment.completed` | A payment is completed (Mollie paid, a free order, or marked paid in the admin / API) |
| `payment.failed` | A payment is rejected: Mollie reports it failed, cancelled or expired, or it timed out unpaid |
| `payment.refunded` | A payment is refunded |
| `ping` | You pressed *Test* (or called `POST /webhooks/{id}/test`). Always sent, whatever the subscription |

`GET /api/v1/webhooks/events` returns the same list with descriptions. An endpoint subscribes to a
list of types; subscribe to what you handle and ignore unknown types (new ones may be added).

A booking that is cancelled sends `booking.cancelled`, not also `booking.updated`.

## Register an endpoint

```sh
export API=https://app.nisebookings.com/api/v1 KEY=nk_...
H=(-H "Authorization: Bearer $KEY" -H 'Content-Type: application/json')

curl -s "${H[@]}" -X POST $API/webhooks -d '{
  "url": "https://shop.example.com/hooks/nise",
  "events": ["booking.created", "booking.cancelled", "payment.completed"],
  "description": "shop sync"
}'
```

```json
{
  "id": "5b0d…", "url": "https://shop.example.com/hooks/nise",
  "events": ["booking.created", "booking.cancelled", "payment.completed"],
  "description": "shop sync", "active": true,
  "createdAt": "2026-10-12T09:00:00.000Z", "updatedAt": "2026-10-12T09:00:00.000Z",
  "secret": "whsec_MfKQ9r8GKYqrTwjUPD8ILPZIo2LaLaSw"
}
```

The **`secret` is shown once** — store it with your other credentials. No endpoint returns it again;
lost it? [Rotate](#test-rotate-disable).

URL rules: `https://` only, at most 2048 characters, no user name or password in it; the host must resolve to a public address (no
loopback, private, link-local or carrier-grade NAT ranges — checked at registration and again
before every send). Redirects are not followed: point the URL at the final address. An account can
have up to 10 endpoints.

## The message

```http
POST /hooks/nise HTTP/1.1
Content-Type: application/json
webhook-id: 0f8e3c1a-6d2b-4b7e-9a51-3c2d1e0f9a87
webhook-timestamp: 1791795600
webhook-signature: v1,K5oZfzN95Z9UVu1EsfQmfVNQhnkZ2pj9o9NDN/H/pI4=
```

```json
{
  "id": "0f8e3c1a-6d2b-4b7e-9a51-3c2d1e0f9a87",
  "type": "booking.cancelled",
  "createdAt": "2026-10-12T09:15:00Z",
  "tenantId": "6f1c…",
  "data": {
    "bookingId": "c41e…",
    "appointmentId": "8a07…",
    "customerId": "0d2b…",
    "serviceId": "82ef…",
    "staffId": "6a4c…",
    "persons": 1,
    "status": "cancelled",
    "previousStatus": "approved",
    "start": "2026-10-14T09:30:00Z"
  }
}
```

`data` per event family:

| Types | `data` fields |
|---|---|
| `booking.*` | `bookingId`, `appointmentId`, `customerId`, `serviceId`, `staffId`, `persons`, `status`, `previousStatus` (`null` on created), `start`; when the appointment was moved also `previousStart`, `previousStaffId`, `previousServiceId` |
| `ticket.*` | `ticketId`, `eventId`, `ticketTypeId`, `orderId`, `customerId`, `paymentId`, `status`, `previousStatus` |
| `payment.*` | `paymentId`, `customerId`, `type` (`local` / `mollie` / `free`), `status`, `previousStatus` |
| `ping` | `endpointId` |

- `id` = the `webhook-id` header = the event id. The same event redelivered (retry, manual retry)
  keeps its id.
- `data` holds ids and status fields only — never names, e-mail addresses or prices. Read the rest
  with the matching `/api/v1` call (`GET /appointments/{appointmentId}`, `GET /payments/{paymentId}`,
  `GET /events/{eventId}/attendees`), which also gives you the *current* state rather than the state at
  the time of the event.
- Field names are camelCase; times are ISO 8601 UTC.

## Verify the signature

The signature is `base64(HMAC-SHA256(key, webhook-id + "." + webhook-timestamp + "." + raw body))`,
where `key` is the base64-decoded part of the secret after `whsec_`. The header may carry several
space-separated `v1,<signature>` values; accept the message when any one matches. Reject timestamps
more than 5 minutes from your clock (replay protection). Always verify against the **raw** request
body — re-serialising parsed JSON changes the bytes.

Plain Node (no dependencies):

```js
import { createServer } from 'node:http';
import { createHmac, timingSafeEqual } from 'node:crypto';

const SECRET = process.env.NISE_WEBHOOK_SECRET; // whsec_…

export function verify(secret, headers, rawBody, toleranceSeconds = 300) {
  const id = headers['webhook-id'], ts = headers['webhook-timestamp'], sigs = headers['webhook-signature'];
  if (!id || !ts || !sigs) return false;
  if (Math.abs(Date.now() / 1000 - Number(ts)) > toleranceSeconds) return false;
  const key = Buffer.from(secret.replace(/^whsec_/, ''), 'base64');
  const expected = createHmac('sha256', key).update(`${id}.${ts}.${rawBody}`).digest();
  return sigs.split(' ').some((s) => {
    const [version, sig] = s.split(',');
    const got = Buffer.from(sig ?? '', 'base64');
    return version === 'v1' && got.length === expected.length && timingSafeEqual(got, expected);
  });
}

createServer((req, res) => {
  let raw = '';
  req.on('data', (c) => (raw += c));
  req.on('end', () => {
    if (!verify(SECRET, req.headers, raw)) return res.writeHead(401).end();
    const event = JSON.parse(raw);
    res.writeHead(204).end();              // acknowledge first …
    handle(event).catch(console.error);     // … then do the work (dedupe on event.id)
  });
}).listen(8080);
```

The official Standard Webhooks libraries (`standardwebhooks` for JavaScript, Python, Go, Ruby, PHP,
…) accept the same `whsec_` secret and headers.

## Responding, retries and the deliveries log

- Answer **2xx within 10 seconds**. Any other status, a timeout, a redirect or a connection error is a
  failed attempt. Do slow work after you answered (queue it).
- Failed attempts are retried after **1 min, 5 min, 30 min, 2 h, 12 h and 24 h** — 7 attempts over
  about 39 hours. After the seventh the delivery is marked `failed` and not tried again
  automatically.
- Every delivery is logged per endpoint, with its attempt count and the result of the last attempt:

```sh
curl -s "${H[@]}" "$API/webhooks/$HOOK/deliveries?status=failed&limit=20"
```

```json
{
  "data": [{
    "id": "e2a1…", "endpointId": "5b0d…", "eventId": "0f8e…", "type": "booking.cancelled",
    "status": "failed", "attempts": 7, "nextAttemptAt": "2026-10-12T00:15:00Z",
    "lastStatus": 500, "lastError": "HTTP 500: Internal Server Error",
    "createdAt": "2026-10-10T09:15:00Z", "deliveredAt": null, "payload": { "…": "…" }
  }],
  "total": 1
}
```

  Statuses: `pending` (waiting for `nextAttemptAt`), `sending`, `delivered`, `failed`. `lastStatus`
  is the HTTP status of the last attempt (`null` when the connection failed or timed out; `lastError`
  says why). Fixed your receiver? Send a delivery again — same event id, same payload, a fresh
  signature and a fresh retry schedule (`409` while that delivery is being sent):

```sh
curl -s "${H[@]}" -X POST "$API/webhooks/$HOOK/deliveries/$DELIVERY/retry"
```

The admin shows the last 20 deliveries per endpoint with the same information. Delivered and failed
deliveries are kept for 30 days, then deleted; pending ones stay until they finish.

## Test, rotate, disable

```sh
curl -s "${H[@]}" -X POST $API/webhooks/$HOOK/test             # 202: queues a `ping` and sends it right away
curl -s "${H[@]}" -X POST $API/webhooks/$HOOK/rotate-secret    # the endpoint + "secret":"whsec_…" — shown once
curl -s "${H[@]}" -X PATCH $API/webhooks/$HOOK -d '{"active":false}'   # pause (no new deliveries)
curl -s "${H[@]}" -X DELETE $API/webhooks/$HOOK                # remove it and its delivery log
```

**Rotating** takes effect immediately: every message sent after the call, including retries of older
events, is signed with the new secret only. To rotate without dropping messages, let your receiver
accept either secret for a few minutes (try the new one, then the old), deploy that, rotate, then
remove the old secret.

A paused endpoint (`active: false`) receives nothing: events that happen while it is paused are not
queued for it, and deliveries that were already waiting for a retry resume when you switch it back on.

## Write an idempotent consumer

Retries mean you will sometimes get the same event twice; ordering is not guaranteed (a
`booking.updated` can arrive before its `booking.created` when the first attempt of the latter
failed).

1. Store every processed `webhook-id`; when you see it again, answer 2xx and do nothing.
2. Treat the message as a *hint*: re-read the object from `/api/v1` and act on its current state.
   Then order does not matter.
3. Answer quickly; failures in your own processing should not turn into 5xx loops.

Sandboxes send webhooks like any other account — the quickest way to build a receiver is a
[sandbox](08-sandbox.md) plus a public tunnel to your laptop.

## Endpoint reference

All under `https://app.nisebookings.com/api/v1`, scope `settings:read` for `GET`, `settings:write`
for the rest. Full schemas: [`openapi.yaml`](../api/openapi.yaml).

| Call | Does |
|---|---|
| `GET /webhooks/events` | Event types with descriptions |
| `GET /webhooks` | List endpoints (`{data, total}`, never the secret) |
| `POST /webhooks` | `{url, events, description?, active?}` → `201` with `secret` (once) |
| `GET /webhooks/{id}` | One endpoint |
| `PATCH /webhooks/{id}` | Change `url`, `events`, `description`, `active` |
| `DELETE /webhooks/{id}` | Remove the endpoint and its deliveries → `204` |
| `POST /webhooks/{id}/rotate-secret` | New secret → the endpoint with `secret` (once) |
| `POST /webhooks/{id}/test` | Queue and send a `ping` → `202` with the delivery |
| `GET /webhooks/{id}/deliveries` | Delivery log, newest first; `status`, `limit` (≤ 100), `offset` |
| `POST /webhooks/{id}/deliveries/{deliveryId}/retry` | Send that delivery again → `202` with the delivery |
