Webhooks
Real-time notifications about booking events, HMAC-SHA256 signature verification.
Overview#
The Bokko webhook system sends real-time HTTP notifications when the status of a booking changes. This allows your system to respond immediately to events without having to constantly poll the API.
- Real-time: Notifications are sent at the moment a status change occurs.
- HTTPS: Webhooks can only be sent to secure endpoints.
- HMAC-SHA256 Signature: Every payload is cryptographically signed; you can verify its authenticity.
Configuration#
You can register the webhook URL at the PUT /v1/webhooks/config endpoint. This requires the webhook.manage capability.
curl -X PUT https://api.bokko.io/v1/webhooks/config \
-H "Authorization: Bearer {API_KEY}" \
-H "Content-Type: application/json" \
-d '{ "url": "https://example.com/webhooks/bokko" }'URL Requirements:
- HTTPS protocol is mandatory.
- Maximum 500 characters long.
- Must not contain authentication credentials (user:pass@).
Restricted Address Ranges:
| Range | Examples |
|---|---|
| Loopback | localhost, 127.0.0.1, ::1, 0.0.0.0 |
| Private network | 10.x.x.x, 172.16-31.x.x, 192.168.x.x |
| Link-local | 169.254.x.x |
PUT call, the system automatically generates a HMAC secret (32 bytes = 64 hex characters). This secret is returned once in the response — save it in a secure location! Subsequent PUT calls will not return it again.Events#
| Esemény | Leírás |
|---|---|
booking.requested | New appointment request received (always the request state, sequence 1 — an automatically accepted request is followed by booking.confirmed) |
booking.confirmed | Booking confirmed — by the business (acceptanceMode manual) or by the service's automatic acceptance rule (acceptanceMode automatic) |
booking.declined | Booking declined |
booking.cancelled | Booking cancelled |
booking.reschedule_proposed | Appointment modification proposed |
booking.reschedule_confirmed | >- |
booking.completed | Booking completed |
booking.no_show | Guest did not show up |
| Event | Description |
|---|---|
booking.requested | New appointment request received (always the request state) |
booking.confirmed | Booking accepted — by the business or by the service’s automatic acceptance rule |
booking.declined | Booking declined |
booking.cancelled | Booking cancelled |
booking.reschedule_proposed | Appointment modification proposed |
booking.reschedule_confirmed | Modified appointment confirmed |
booking.completed | Booking completed |
booking.no_show | Guest did not show up |
Payload Format#
Every webhook delivery is a JSON object with the following structure:
deliveryId identifies the business event (event × booking × transition), not the attempt: a deterministic 32-character hex string. Every attempt and every re-delivery of the same event carries the same value — use it as your deduplication key.{
"event": "booking.confirmed",
"deliveryId": "3f2a9c0d8b7e4f1a2c3d4e5f6a7b8c9d",
"timestamp": "2026-04-01T09:00:00.000Z",
"salonSlug": "precision-cuts",
"transition": {
"id": "tr_8Kq2mZ4pX7",
"sequence": 2,
"occurredAt": "2026-04-01T09:15:00Z",
"from": "requested",
"to": "confirmed"
},
"booking": {
"bookingId": "abc123",
"publicReference": "BK-A2B3C4D5",
"status": "confirmed",
"locationId": "loc_downtown_01",
"locationSnapshot": {
"id": "loc_downtown_01",
"locationSlug": "downtown",
"publicLocationName": "Bokko Downtown",
"address": {
"city": "Budapest",
"street": "Deak Ferenc utca 1.",
"zip": "1052"
},
"timezone": "Europe/Budapest"
},
"serviceId": "svc_haircut_01",
"serviceName": "Haircut",
"staffId": "staff_anna_01",
"staffName": "Anna",
"guestName": "Peter Smith",
"requestedSlot": {
"date": "2026-04-05",
"startTime": "10:00",
"timezone": "Europe/Budapest"
},
"confirmedSlot": {
"date": "2026-04-05",
"startTime": "10:00",
"endTime": "10:45",
"timezone": "Europe/Budapest"
},
"note": null,
"createdAt": "2026-04-01T09:00:00Z",
"updatedAt": "2026-04-01T09:15:00Z",
"stateVersion": 2,
"acceptanceMode": "manual"
}
}The confirmedSlot contains the finalized appointment time for active confirmed-style lifecycle states such as confirmed, completed, and noShow. For cancelled or declined bookings, confirmedSlot is always null in the API, even if the booking had been confirmed earlier. The status field determines whether the time slot is currently occupied.
Ordering, Identity and Deduplication#
Three separate keys — do not substitute one for another:
| Concern | Key | Rule |
|---|---|---|
| State ordering | transition.sequence (= booking.stateVersion) | Delivery order is not guaranteed. Per booking, apply an event only if its sequence is greater than the last one you applied; otherwise it is stale. |
| Business-mutation identity | transition.id | A new immutable id for every state change (status transition or change of the confirmed time); null for a creation. Not sortable. |
| Delivery deduplication | deliveryId | Identical on every attempt and re-delivery of the same event. |
timestamp and transition.occurredAt are informative only — two events of one transaction can share the same time. Every booking webhook represents a state change, so every event carries a sequence. Two successive time changes of a confirmed booking produce two booking.reschedule_confirmed events with sequence N and N+1 and two different deliveryIds.
Automatic acceptance. When a service's acceptance rule accepts a request in the same call, two events are sent: booking.requested (sequence: 1, status: requested, empty confirmedSlot) and booking.confirmed (sequence: 2, acceptanceMode: automatic). Each payload describes the state of ITS event, not the current state of the booking.
Status Enum ↔ Event Name Mapping#
The booking status field uses a camelCase enum (Bokko Public API convention), while the webhook event names use a snake_case namespaced format (REST / event sourcing convention). They differ intentionally — clients should apply the following mapping:
| BookingStatus Enum | Webhook Event |
|---|---|
requested | booking.requested (booking created) |
confirmed | booking.confirmed, booking.reschedule_confirmed (if confirmed after rescheduling) |
declined | booking.declined |
cancelled | booking.cancelled |
rescheduleProposed | booking.reschedule_proposed |
completed | booking.completed |
noShow | booking.no_show |
booking.no_show event is in snake_case (no_show), not camelCase (noShow). However, the status field of the Booking schema uses camelCase (noShow, rescheduleProposed). This discrepancy is an intentional design choice.HTTP Headers#
Every webhook request contains the following custom headers:
| Header | Description |
|---|---|
X-Bokko-Signature | sha256=<hex> — HMAC-SHA256 signature over the body |
X-Bokko-Delivery-Id | Business event ID — deduplication key, identical on every attempt |
X-Bokko-Event | Name of the event (e.g., booking.confirmed) |
X-Bokko-Timestamp | ISO 8601 timestamp of the first dispatch, identical on every attempt |
X-Bokko-Attempt | Attempt number (1–3) |
Signature Verification#
Node.js#
const crypto = require('crypto');
function verifySignature(rawBody, signature, secret) {
const expected = crypto
.createHmac('sha256', secret)
.update(rawBody, 'utf8')
.digest('hex');
const sig = signature.replace('sha256=', '');
return crypto.timingSafeEqual(
Buffer.from(sig, 'hex'),
Buffer.from(expected, 'hex')
);
}Python#
import hmac
import hashlib
def verify_signature(body: bytes, signature: str, secret: str) -> bool:
expected = hmac.new(
secret.encode(), body, hashlib.sha256
).hexdigest()
sig = signature.replace('sha256=', '')
return hmac.compare_digest(sig, expected)Delivery Rules#
| Rule | Value |
|---|---|
| Number of attempts | at most 3 per event |
| Timeout | 8 seconds per attempt |
| Follow redirects | No |
| Delivery guarantee | At-least-once — the same event can arrive more than once |
| Retried responses | timeout, network error, 408, 429, 5xx |
| Not retried | every other status, including 3xx |
| Schedule | 2nd attempt ~15 s – 2 minutes after the 1st, 3rd attempt ~2–6 minutes after the 2nd |
Retry-After | honoured on 429 / 503 (seconds or HTTP date, max 600 s); it only extends the wait |
Every attempt sends the same body bytes with the same deliveryId and timestamp. The signature is computed on each attempt with the current secret, and retries go to the currently configured URL — after a secret rotation, a retried delivery is signed with the new secret.
Possible outcomes:
success— 2xx response code receivedtimeout— endpoint did not respond within 8 secondsnetwork_error— endpoint was not reachablehttp_error— non-2xx response code (e.g., 500)
exhausted (retryable failure on the last attempt) or failed (non-retryable response, or the webhook configuration was deactivated) is not attempted again. Monitor these in the delivery log and reconcile with the GET /v1/bookings endpoint when needed.Delivery Log#
The GET /v1/webhooks/deliveries endpoint lists one record per business event, with every attempt in attempts and the delivery state in status (processing, retry_scheduled, succeeded, failed, exhausted). Records are kept for 12 months; a single query can cover at most 90 days.
Filtering Options:
| Parameter | Description |
|---|---|
event | Event type filter (e.g., booking.confirmed) |
outcome | Outcome of the most recent attempt (success, timeout, network_error, http_error) |
from / to | Date range (ISO 8601) |
Pagination is cursor-based: provide the meta.cursor field from the response in the cursor parameter of the next request.
Secret Rotation#
If your secret is compromised, you can generate a new secret:
curl -X POST https://api.bokko.io/v1/webhooks/config/rotate-secret \
-H "Authorization: Bearer {API_KEY}"Best Practices#
- Order by
transition.sequence, deduplicate bydeliveryId— see Ordering, Identity and Deduplication. - Process idempotently, keyed by
deliveryId— delivery is at-least-once, so the same event can arrive again (for example when your 200 response was lost after a timeout). Store processeddeliveryIdvalues and acknowledge repeats with200without re-processing. - Respond quickly with 200, perform actual processing asynchronously — the 8-second timeout can be tight for complex logic.
- Periodically reconcile with the
GET /v1/bookingsendpoint to ensure you don't miss any events. - For local development, use webhook.site or ngrok to receive webhooks.