nisebookings docs

Webhooks — get told when something changes

Register an HTTPS URL and the service POSTs 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.

Contents

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

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"
}'
{
  "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.

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

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=
{
  "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

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):

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

curl -s "${H[@]}" "$API/webhooks/$HOOK/deliveries?status=failed&limit=20"
{
  "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):

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

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

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