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.
- Managed with the management API (scope
settings:read/settings:write), the MCP toolslist_webhooks,create_webhook,delete_webhook,test_webhook,list_webhook_deliveries, or the admin (Instellingen → Webhooks). - Signed per the Standard Webhooks 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
- Register an endpoint
- The message
- Verify the signature
- Responding, retries and the deliveries log
- Test, rotate, disable
- Write an idempotent consumer
- 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
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 |
id= thewebhook-idheader = the event id. The same event redelivered (retry, manual retry) keeps its id.dataholds ids and status fields only — never names, e-mail addresses or prices. Read the rest with the matching/api/v1call (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):
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
failedand not tried again automatically. - Every delivery is logged per endpoint, with its attempt count and the result of the last attempt:
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).
- Store every processed
webhook-id; when you see it again, answer 2xx and do nothing. - Treat the message as a hint: re-read the object from
/api/v1and act on its current state. Then order does not matter. - 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 |