Build on Bookatu.
REST, webhooks & MCP
Every Bookatu business exposes a REST API, signed webhooks, and a Model Context Protocol (MCP) server. Use them to build integrations, booking automations, and AI-powered scheduling agents.
Curious how it’s built? Read the engineering blog.
Import into Postman/Insomnia or generate a typed SDK.
Authentication
The public endpoints (business, services, staff, availability, book) require no authentication and can be called from any origin. The key-protected endpoints (customers, appointments) and the MCP server need an API key issued from your admin dashboard at /{slug}/admin/api.
The customer booking-management endpoints take no API key: the appointment id IS the capability. Ids are apt_ followed by a 16-character nanoid — about 96 bits from a 64-character alphabet, the same order of unguessability as a magic link — and are only ever handed to the person who booked (their confirmation email and booking-success screen). They are also scoped to the business in the URL, so a valid id from one business can never be read or acted on through another's. Treat a booking id like a password: it lets the holder view, move or cancel that one booking, and nothing else. Every one of these endpoints is rate limited per IP, returns only what the person who booked already knows, and answers a 404 for anything that is not this business's booking.
Pass the key as a Bearer token in the Authorization header, or in the X-Api-Key header.
Authorization: Bearer bk_live_YOUR_KEY X-Api-Key: bk_live_YOUR_KEY
- Keys are issued from the admin dashboard and shown exactly once at creation. Store them in a secret manager or environment variable; they cannot be retrieved again.
- A key is a random token (bk_live_ followed by a 32-character id). Only its SHA-256 hash is stored, and verification uses a timing-safe comparison.
- Keys support an optional expiry, are checked against revocation on every request, and record a lastUsedAt timestamp so you can audit usage.
- Each key has a scope: read-write (full access) or read-only. A read-only key may call the GET endpoints and read-only MCP tools, but write endpoints — creating or cancelling a booking — return 403. Issue read-only keys for integrations that only need to read your data.
- Revoke a key immediately from the dashboard if it is ever exposed. Revoked and expired keys stop authenticating at once.
Keep keys server-side
The public v1 routes are CORS-open, so a browser can reach those URLs. Never embed a bk_live_ key in browser or mobile-client code — call key-protected endpoints from your own server.
Base URL & CORS
https://bookatu.com/api/v1/{slug}Replace {slug} with the business URL slug visible in your admin and public booking link. All responses are application/json. Errors always return { ok: false, error: "..." } (validation errors also include a fieldErrors map). Stack traces are never leaked.
The public endpoints (business, services, staff, availability, book) return Access-Control-Allow-Origin: * and answer the CORS preflight (OPTIONS) with 204, so any site or agent can call them directly from a browser. Key-protected endpoints deliberately do NOT send the wildcard — their allowed CORS origin is the platform's own origin, because business data has no legitimate cross-origin browser use. The customer booking-management endpoints (everything under /booking/{id}) do not send it either, and answer no preflight: they are authorised by the booking id itself, which has no business being replayed from a third-party page. Neither restriction affects server-to-server or native mobile callers, which are not subject to browser CORS at all. Never embed a bk_live_ key in browser or mobile-client code — call key-protected endpoints from your own server.
| Endpoint | Auth | Description |
|---|---|---|
| GET /business | None | Business profile + opening hours |
| GET /services | None | List active, online-bookable services |
| GET /staff | None | List bookable team members |
| GET /availability | None | Open slots for a service |
| POST /book | None | Create a booking (online source) |
| GET /booking/{id} | Booking id | Read one booking (the customer's own) |
| GET /booking/{id}/availability | Booking id | Open slots for MOVING this booking |
| POST /booking/{id}/reschedule | Booking id | Move a booking to a new time |
| POST /booking/{id}/cancel | Booking id | Cancel a booking (the customer's own) |
| GET /customers | API key | List customers |
| GET /appointments | API key | List appointments |
| GET /services/{id} | None | Get one service |
| GET /appointments/{id} | API key | Get one appointment |
| POST /appointments | API key | Create a booking (admin source) |
| POST /appointments/{id}/cancel | API key | Cancel a booking |
A note on the mobile API
A separate, first-party mobile API (under /api/mobile) powers the Bookatu mobile experience. It is actively evolving and is intentionally not documented here, as its endpoints are still changing. For integrations, build against the v1 REST API or the MCP server documented on this page.
Endpoint reference
Across responses, a booking status is one of: pending, confirmed, completed, cancelled, no_show.
GET /business
PublicGET /api/v1/{slug}/businessBusiness profile + opening hours
The business profile: name, industry, country, currency, timezone, locale, address, phone, the public booking link, and the full week of opening hours. Lets an agent introduce the business and know when it is open before checking availability. No authentication required.
curl https://bookatu.com/api/v1/your-salon/business
Response example
{
"name": "Parnell Nails",
"slug": "parnell-nails",
"industry": "nails",
"country": "NZ",
"currency": "NZD",
"timezone": "Pacific/Auckland",
"locale": "en-NZ",
"address": "12 Queen St, Auckland",
"phone": "09 555 0100",
"bookingUrl": "https://bookatu.com/parnell-nails/book",
"hours": [
{ "weekday": 0, "open": false, "openMin": null, "closeMin": null, "breakStartMin": null, "breakEndMin": null },
{ "weekday": 1, "open": true, "openMin": 540, "closeMin": 1080, "breakStartMin": 780, "breakEndMin": 840 },
{ "weekday": 2, "open": true, "openMin": 540, "closeMin": 1080, "breakStartMin": null, "breakEndMin": null },
{ "weekday": 3, "open": true, "openMin": 540, "closeMin": 1080, "breakStartMin": null, "breakEndMin": null },
{ "weekday": 4, "open": true, "openMin": 540, "closeMin": 1080, "breakStartMin": null, "breakEndMin": null },
{ "weekday": 5, "open": true, "openMin": 540, "closeMin": 1140, "breakStartMin": null, "breakEndMin": null },
{ "weekday": 6, "open": true, "openMin": 600, "closeMin": 960, "breakStartMin": null, "breakEndMin": null }
]
}
// hours always has all 7 days. weekday 0 = Sunday .. 6 = Saturday.
// openMin / closeMin are minutes from local midnight, and are null when closed.
// breakStartMin / breakEndMin are a mid-day closure (Monday above shuts 1pm to
// 2pm) and are null when the day runs straight through. A day with a break
// trades openMin-breakStartMin and breakEndMin-closeMin, and NOTHING between:
// treat the outer pair alone as open all day and you will send someone to a
// locked door.Errors
| Status | When |
|---|---|
| 404 | No business exists for the {slug}. |
| 500 | Unexpected server error (details are never leaked). |
GET /services
PublicGET /api/v1/{slug}/servicesList active, online-bookable services
The org's public profile plus every service that is active and bookable online, ordered by sort order then name. Use a service id and durationMin when checking availability and booking. No authentication required.
curl https://bookatu.com/api/v1/your-salon/services
Response example
{
"org": {
"name": "Parnell Nails",
"slug": "parnell-nails",
"currency": "NZD",
"timezone": "Pacific/Auckland"
},
"services": [
{
"id": "svc_abc123",
"name": "Gel Manicure",
"description": "Long-lasting gel polish with cuticle care.",
"category": "Nails",
"durationMin": 60,
"priceCents": 7500,
"currency": "NZD"
}
]
}
// description is the service description, or null when none is set.Errors
| Status | When |
|---|---|
| 404 | No business exists for the {slug}. |
| 500 | Unexpected server error. |
GET /staff
PublicGET /api/v1/{slug}/staffList bookable team members
The bookable team members (id, name, title, bio) so an agent can offer "book with X". Public-facing fields only — never staff email or phone. No authentication required.
curl https://bookatu.com/api/v1/your-salon/staff
Response example
{
"org": { "name": "Parnell Nails", "slug": "parnell-nails", "timezone": "Pacific/Auckland" },
"staff": [
{ "id": "stf_abc", "name": "Anna", "title": "Senior Nail Tech", "bio": null }
]
}
// title and bio are null when not set.Errors
| Status | When |
|---|---|
| 404 | No business exists for the {slug}. |
| 500 | Unexpected server error. |
GET /availability
PublicGET /api/v1/{slug}/availabilityOpen slots for a service
Bookable slots for a service across a date window. The engine accounts for opening hours, existing appointments, buffer times, resources and minimum booking notice. Each slot's startAt is a UTC ISO 8601 string ready to pass straight to a booking call. No authentication required. This is the read for a NEW booking: to move an existing one, use GET /booking/{id}/availability instead, because this read counts that booking's own slot as busy.
curl "https://bookatu.com/api/v1/your-salon/availability?serviceId=svc_abc123&from=2026-06-10&to=2026-06-14"
Response example
{
"org": { "name": "Parnell Nails", "slug": "parnell-nails", "timezone": "Pacific/Auckland" },
"days": [
{
"date": "2026-06-10",
"open": true,
"slots": [
{ "startMin": 540, "startAt": "2026-06-09T21:00:00.000Z" }
]
},
{ "date": "2026-06-11", "open": false, "slots": [] }
]
}
// startMin is minutes from local midnight; startAt is the UTC instant to book.Parameters
| Field | Type | In | Required | Notes |
|---|---|---|---|---|
| serviceId | string | query | Yes | An active, online-bookable service id. |
| from | string | query | Yes | Start date, YYYY-MM-DD. |
| to | string | query | Yes | End date, YYYY-MM-DD. Must be on or after from, and at most 60 days later. |
| staffId | string | query | No | Restrict slots to one team member. |
| partySize | number | query | No | How many people are coming, 1–50. Only times with a table that seats them come back, and each slot then names the tables that fit (resourceIds), smallest first — pass one straight back as resourceId when booking. Omit to apply no capacity filter. |
Errors
| Status | When |
|---|---|
| 400 | serviceId is missing, from/to are not YYYY-MM-DD, from is later than to, or the range exceeds 60 days. |
| 404 | The {slug} is unknown, or the serviceId is not an active, online-bookable service in this org. |
| 500 | Unexpected server error. |
POST /book
PublicPOST /api/v1/{slug}/bookCreate a booking (online source)
The customer-facing booking call. The slot is validated server-side (race-condition safe), a customer record is matched by email then phone or created, and a booking reference is returned. As an online-source booking it fully respects availability and minimum notice. When a deposit is required the booking is pending and the response includes a payLink to surface to the customer. No authentication required.
curl -X POST https://bookatu.com/api/v1/your-salon/book \
-H "Content-Type: application/json" \
-d '{
"serviceId": "svc_abc123",
"startAt": "2026-06-10T09:00:00Z",
"name": "Alex Smith",
"email": "alex@example.com",
"phone": "+64 21 555 0100"
}'Response example
{
"ok": true,
"ref": "VRD-7QK2M9",
"appointmentId": "apt_abc123",
"status": "confirmed",
"needsPayment": false
}
// When a deposit is required, status is "pending" and a payLink is returned:
{
"ok": true,
"ref": "VRD-7QK2M9",
"appointmentId": "apt_abc123",
"status": "pending",
"needsPayment": true,
"payLink": "https://bookatu.com/your-salon/book/success?apt=apt_abc123"
}Request fields
| Field | Type | In | Required | Notes |
|---|---|---|---|---|
| serviceId | string | body | Yes | An active, online-bookable service id from GET /services. |
| startAt | string | body | Yes | ISO 8601 date-time, e.g. a startAt value from GET /availability ("2026-06-10T09:00:00Z"). |
| name | string | body | Yes | Customer name, 2–80 characters (trimmed). |
| string | body | Yes | A valid email address. | |
| phone | string | body | Yes | 6–30 characters; digits, spaces and + ( ) - only. |
| staffId | string | body | No | Specific staff id. Omit to let the system assign a free team member. |
| notes | string | body | No | Free text, max 500 characters. |
| partySize | number | body | No | How many people are coming, 1–50. Restaurants: only a table that seats the party is used, and the booking records the covers. Omit for a booking for one seat, which is every appointment-style booking. |
| resourceId | string | body | No | A specific table (or room) of this business to prefer. Honoured only while it is genuinely free for that time; otherwise the smallest one that fits the party is assigned. An id that is not this business's is ignored. |
Errors
| Status | When |
|---|---|
| 400 | The request body is not valid JSON. |
| 422 | Validation failed — the response includes a fieldErrors map naming each invalid field (e.g. email, phone). Also returned when startAt is not a parseable ISO 8601 date-time. |
| 409 | The slot was just taken, or the service is unavailable / inactive. |
| 404 | No business exists for the {slug}. |
| 500 | Unexpected server error. |
GET /booking/{id}
Authorised by the booking idNo API key. Authorised by the appointment id in the path.
GET /api/v1/{slug}/booking/{id}Read one booking (the customer's own)
The JSON twin of the manage-booking page a customer reaches from their confirmation email: their booking, its service, its team member, its price and deposit, plus the business's cancellation policy. Authorised by the appointment id alone — no API key, no account — and deliberately narrow: only what the person who booked already knows. No customer record, no notes, no other bookings. Rate limited to 30 requests per IP every 5 minutes.
curl https://bookatu.com/api/v1/your-salon/booking/apt_abc123
Response example
{
"org": {
"slug": "parnell-nails",
"name": "Parnell Nails",
"currency": "NZD",
"timezone": "Pacific/Auckland"
},
"booking": {
"id": "apt_abc123",
"ref": "VRD-7QK2M9",
"status": "confirmed",
"startAt": "2026-06-10T21:00:00.000Z",
"endAt": "2026-06-10T22:00:00.000Z",
"durationMin": 60,
"service": "Gel Manicure",
"staff": "Anna",
"priceCents": 7500,
"depositCents": 2000,
"addOns": [{ "name": "Nail art", "priceCents": 1500 }]
},
"policy": {
"cancellationWindowHours": 24,
"forfeitDepositOnLateCancel": true
}
}
// policy is the business's cancellation policy: cancelling or moving more than
// cancellationWindowHours before the start is free, and inside that window a
// paid deposit is kept when forfeitDepositOnLateCancel is true. ref and staff
// are null when unset; addOns is [] when there are none.Parameters
| Field | Type | In | Required | Notes |
|---|---|---|---|---|
| id | string | path | Yes | The appointment id, as returned by POST /book (apt_…). |
Errors
| Status | When |
|---|---|
| 404 | No business exists for the {slug}, or no booking with that id in this business (the same 404 either way — a distinct response would confirm an id exists somewhere). |
| 429 | More than 30 requests from one IP in 5 minutes. The response carries Retry-After. |
| 500 | Unexpected server error (details are never leaked). |
GET /booking/{id}/availability
Authorised by the booking idNo API key. Authorised by the appointment id in the path.
GET /api/v1/{slug}/booking/{id}/availabilityOpen slots for MOVING this booking
Bookable slots for moving an existing booking, across a date window. This differs from GET /availability in two ways that matter: the booking's own slot is EXCLUDED from the busy set (otherwise an appointment's own hold hides the very time its owner is trying to shift around), and the fit uses the booking's own service and its own length — which is not always the service's default, because an add-on or a duration override can make the booking longer. Pass a returned startAt straight to POST /booking/{id}/reschedule. Authorised by the appointment id alone. Rate limited to 30 requests per IP every 5 minutes.
curl "https://bookatu.com/api/v1/your-salon/booking/apt_abc123/availability?from=2026-06-10&to=2026-06-14"
Response example
{
"org": { "slug": "parnell-nails", "name": "Parnell Nails", "timezone": "Pacific/Auckland" },
"booking": { "id": "apt_abc123", "durationMin": 60 },
"days": [
{
"date": "2026-06-10",
"open": true,
"slots": [
{ "startMin": 540, "startAt": "2026-06-09T21:00:00.000Z" }
]
},
{ "date": "2026-06-11", "open": false, "slots": [] }
]
}
// booking.durationMin is the length being fitted (the booking's, not the
// service's default). startMin is minutes from local midnight; startAt is the
// UTC instant to send back when rescheduling.Parameters
| Field | Type | In | Required | Notes |
|---|---|---|---|---|
| id | string | path | Yes | The appointment id being moved. |
| from | string | query | Yes | Start date, YYYY-MM-DD. |
| to | string | query | Yes | End date, YYYY-MM-DD. Must be on or after from, and at most 60 days later. |
Errors
| Status | When |
|---|---|
| 400 | from/to are not YYYY-MM-DD, from is later than to, or the range exceeds 60 days. |
| 404 | No business exists for the {slug}, or no booking with that id in this business. |
| 429 | More than 30 requests from one IP in 5 minutes. |
| 500 | Unexpected server error (details are never leaked). |
POST /booking/{id}/reschedule
Authorised by the booking idNo API key. Authorised by the appointment id in the path.
POST /api/v1/{slug}/booking/{id}/rescheduleMove a booking to a new time
The customer-facing reschedule — the same call the manage-booking web page makes, with the same strict rules. The new time is validated as a real, open slot: opening hours, existing bookings, buffers, resources and the business's minimum booking notice all apply, and the booking's own hold is excluded so it can be moved within its own slot. A time that is not bookable returns 409 with the reason, and nothing changes. The booking keeps its team member unless the availability engine has to reassign it, and its length is unchanged. Moving a booking fires the booking.rescheduled webhook and updates any connected calendar. Only an upcoming pending or confirmed booking can be moved: a cancelled, completed, no-show or past booking returns 409 — the same rule as the web page, so a cancelled booking can never be quietly reinstated. Rate limited to 10 requests per IP every 10 minutes.
curl -X POST https://bookatu.com/api/v1/your-salon/booking/apt_abc123/reschedule \
-H "Content-Type: application/json" \
-d '{ "startAt": "2026-06-12T21:00:00.000Z" }'Response example
{ "ok": true, "startAt": "2026-06-12T21:00:00.000Z" }
// startAt echoes the time that was taken, so the new booking can be rendered
// without a second read.Request fields
| Field | Type | In | Required | Notes |
|---|---|---|---|---|
| id | string | path | Yes | The appointment id to move. |
| startAt | string | body | Yes | The new start time as an ISO 8601 date-time, e.g. a startAt from GET /booking/{id}/availability. Must be in the future. |
Errors
| Status | When |
|---|---|
| 400 | The request body is not valid JSON. |
| 422 | startAt is missing, is not a parseable ISO 8601 date-time, or is in the past. |
| 409 | The time is not bookable (already taken, outside opening hours, or inside the minimum booking notice) — the response carries the reason. Also returned when the booking is cancelled, completed, a no-show, or has already started. |
| 404 | No business exists for the {slug}, or no booking with that id in this business. |
| 429 | More than 10 requests from one IP in 10 minutes. The response carries Retry-After. |
| 500 | Unexpected server error (details are never leaked). |
POST /booking/{id}/cancel
Authorised by the booking idNo API key. Authorised by the appointment id in the path.
POST /api/v1/{slug}/booking/{id}/cancelCancel a booking (the customer's own)
The customer-facing cancellation — the same call the manage-booking web page makes, so the cancellation policy, any deposit refund or forfeiture, the booking.cancelled webhook and the waitlist re-offer are identical however it is triggered. The forfeited flag says whether a paid deposit was kept under the policy, which an app needs in order to tell the person the truth on the confirmation screen. Cancelling an already-cancelled booking is idempotent (alreadyCancelled: true), so a retry after a dropped connection is safe. Rate limited to 10 requests per IP every 10 minutes.
curl -X POST https://bookatu.com/api/v1/your-salon/booking/apt_abc123/cancel
Response example
{ "ok": true, "alreadyCancelled": false, "forfeited": false }
// forfeited is true when a paid deposit was kept under the cancellation policy
// (see policy on GET /booking/{id}).Parameters
| Field | Type | In | Required | Notes |
|---|---|---|---|---|
| id | string | path | Yes | The appointment id to cancel. |
Errors
| Status | When |
|---|---|
| 409 | The booking could not be cancelled — the response carries the reason. |
| 404 | No business exists for the {slug}, or no booking with that id in this business. |
| 429 | More than 10 requests from one IP in 10 minutes. The response carries Retry-After. |
| 500 | Unexpected server error (details are never leaked). |
GET /customers
Requires API keyGET /api/v1/{slug}/customersList customers
Up to 1000 customers in this org (id, name, email, phone), ordered by name. Requires a valid API key whose org matches the {slug}.
curl https://bookatu.com/api/v1/your-salon/customers \ -H "Authorization: Bearer bk_live_YOUR_KEY"
Response example
{
"customers": [
{ "id": "cus_xyz", "name": "Alex Smith", "email": "alex@example.com", "phone": "+64211234567" }
]
}
// Capped at 1000 customers, ordered by name.Errors
| Status | When |
|---|---|
| 401 | The API key is missing or invalid. |
| 403 | The key belongs to a different organisation. |
| 404 | No business exists for the {slug}. |
| 500 | Unexpected server error. |
GET /appointments
Requires API keyGET /api/v1/{slug}/appointmentsList appointments
By default, upcoming appointments with status pending or confirmed whose startAt is now or later, ordered by start time, capped at 200. Optional filters let you pull a specific window or statuses, and limit/offset page through results — when more rows may exist the response includes nextOffset. Requires a valid API key scoped to this org.
curl "https://bookatu.com/api/v1/your-salon/appointments?status=completed&from=2026-06-01&limit=50" \ -H "Authorization: Bearer bk_live_YOUR_KEY"
Response example
{
"appointments": [
{
"id": "apt_abc123",
"ref": "VRD-7QK2M9",
"serviceName": "Gel Manicure",
"startAt": "2026-06-10T21:00:00.000Z",
"endAt": "2026-06-10T22:00:00.000Z",
"durationMin": 60,
"priceCents": 7500,
"status": "confirmed",
"customerId": "cus_xyz",
"staffId": "stf_abc",
"notes": null,
"createdAt": "2026-06-01T03:12:00.000Z"
}
]
}
// Only pending + confirmed, startAt >= now, oldest first, max 200 rows.Parameters
| Field | Type | In | Required | Notes |
|---|---|---|---|---|
| from | string | query | No | Lower time bound, ISO 8601. Overrides the default "now" — use it to list a past window. |
| to | string | query | No | Upper time bound, ISO 8601 (inclusive). |
| status | string | query | No | Comma-separated statuses to include: pending, confirmed, completed, cancelled, no_show. Defaults to pending + confirmed. |
| limit | number | query | No | Page size, 1–200 (default 200). |
| offset | number | query | No | Rows to skip, for pagination. The response carries nextOffset when more may exist. |
Errors
| Status | When |
|---|---|
| 401 | The API key is missing or invalid. |
| 403 | The key belongs to a different organisation. |
| 404 | No business exists for the {slug}. |
| 500 | Unexpected server error. |
GET /services/{id}
PublicGET /api/v1/{slug}/services/{id}Get one service
A single active, online-bookable service by id — the same shape as a row of GET /services. No authentication required.
curl https://bookatu.com/api/v1/your-salon/services/svc_abc123
Response example
{
"id": "svc_abc123",
"name": "Gel Manicure",
"description": "Long-lasting gel polish with cuticle care.",
"category": "Nails",
"durationMin": 60,
"priceCents": 7500,
"currency": "NZD"
}Parameters
| Field | Type | In | Required | Notes |
|---|---|---|---|---|
| id | string | path | Yes | The service id (from GET /services). |
Errors
| Status | When |
|---|---|
| 404 | No business exists for the {slug}, or no active, online-bookable service with that id. |
| 500 | Unexpected server error. |
GET /appointments/{id}
Requires API keyGET /api/v1/{slug}/appointments/{id}Get one appointment
A single appointment by id, scoped to the key's org (a key for one business can never read another's). Same lean shape as a row of GET /appointments. A read-only key may call this. Requires a valid API key scoped to this org.
curl https://bookatu.com/api/v1/your-salon/appointments/apt_abc123 \ -H "Authorization: Bearer bk_live_YOUR_KEY"
Response example
{
"id": "apt_abc123",
"ref": "VRD-7QK2M9",
"serviceName": "Gel Manicure",
"startAt": "2026-06-10T21:00:00.000Z",
"endAt": "2026-06-10T22:00:00.000Z",
"durationMin": 60,
"priceCents": 7500,
"status": "confirmed",
"customerId": "cus_xyz",
"staffId": "stf_abc",
"notes": null,
"createdAt": "2026-06-01T03:12:00.000Z"
}Parameters
| Field | Type | In | Required | Notes |
|---|---|---|---|---|
| id | string | path | Yes | The appointment id. |
Errors
| Status | When |
|---|---|
| 401 | The API key is missing or invalid. |
| 403 | The key belongs to a different organisation. |
| 404 | No business exists for the {slug}, or no appointment with that id in this org. |
| 500 | Unexpected server error. |
POST /appointments
Requires API keyPOST /api/v1/{slug}/appointmentsCreate a booking (admin source)
Create a booking with source: admin and the same body as POST /book. An admin-source booking deliberately bypasses online restrictions: minimum booking notice is not enforced, and — unlike POST /book — a slot conflict does NOT reject the request. The availability engine is used only to resolve a free staff member and resource; it does not block the booking. The one guard that still applies is the database uniqueness constraint on (staff, start time), which returns 409 if that exact staff member is already booked at that instant. Use this for trusted staff-side tools and agents acting on behalf of the business; use POST /book for customer-facing flows that must respect availability. Requires a valid API key scoped to this org.
curl -X POST https://bookatu.com/api/v1/your-salon/appointments \
-H "Authorization: Bearer bk_live_YOUR_KEY" \
-H "Content-Type: application/json" \
-d '{
"serviceId": "svc_abc123",
"startAt": "2026-06-10T09:00:00Z",
"name": "Alex Smith",
"email": "alex@example.com",
"phone": "+64 21 555 0100"
}'Response example
{
"ok": true,
"ref": "VRD-7QK2M9",
"appointmentId": "apt_abc123",
"status": "confirmed",
"needsPayment": false
}
// When a deposit is required, status is "pending" and a payLink is returned:
{
"ok": true,
"ref": "VRD-7QK2M9",
"appointmentId": "apt_abc123",
"status": "pending",
"needsPayment": true,
"payLink": "https://bookatu.com/your-salon/book/success?apt=apt_abc123"
}Request fields
| Field | Type | In | Required | Notes |
|---|---|---|---|---|
| serviceId | string | body | Yes | An active, online-bookable service id from GET /services. |
| startAt | string | body | Yes | ISO 8601 date-time, e.g. a startAt value from GET /availability ("2026-06-10T09:00:00Z"). |
| name | string | body | Yes | Customer name, 2–80 characters (trimmed). |
| string | body | Yes | A valid email address. | |
| phone | string | body | Yes | 6–30 characters; digits, spaces and + ( ) - only. |
| staffId | string | body | No | Specific staff id. Omit to let the system assign a free team member. |
| notes | string | body | No | Free text, max 500 characters. |
| partySize | number | body | No | How many people are coming, 1–50. Restaurants: only a table that seats the party is used, and the booking records the covers. Omit for a booking for one seat, which is every appointment-style booking. |
| resourceId | string | body | No | A specific table (or room) of this business to prefer. Honoured only while it is genuinely free for that time; otherwise the smallest one that fits the party is assigned. An id that is not this business's is ignored. |
Errors
| Status | When |
|---|---|
| 400 | The request body is not valid JSON. |
| 422 | Validation failed — the response includes a fieldErrors map naming each invalid field (e.g. email, phone). Also returned when startAt is not a parseable ISO 8601 date-time. |
| 409 | The service is unavailable / inactive, or that exact staff member is already booked at that instant (database uniqueness guard). |
| 401 | The API key is missing or invalid. |
| 403 | The key belongs to a different organisation, or the key is read-only (creating a booking needs a read-write key). |
| 404 | No business exists for the {slug}. |
| 500 | Unexpected server error. |
POST /appointments/{id}/cancel
Requires API keyPOST /api/v1/{slug}/appointments/{id}/cancelCancel a booking
Cancel a booking. Scoped to the key's org, so a key for one business can never touch another's data. Fires the booking.cancelled webhook and re-offers the freed slot to the waitlist. The forfeited flag is true when a paid deposit was forfeited under the cancellation policy. Cancelling an already-cancelled booking is idempotent. Requires a valid API key scoped to this org.
curl -X POST https://bookatu.com/api/v1/your-salon/appointments/apt_abc123/cancel \
-H "Authorization: Bearer bk_live_YOUR_KEY" \
-H "Content-Type: application/json" \
-d '{ "reason": "Customer requested" }'Response example
{ "ok": true, "id": "apt_abc123", "forfeited": false }Request fields
| Field | Type | In | Required | Notes |
|---|---|---|---|---|
| id | string | path | Yes | The appointment id to cancel. |
| reason | string | body | No | Optional cancellation reason, truncated to 200 characters and stored on the booking. |
Errors
| Status | When |
|---|---|
| 401 | The API key is missing or invalid. |
| 403 | The key belongs to a different organisation, or the key is read-only (cancelling needs a read-write key). |
| 404 | No business exists for the {slug}. |
| 400 | The booking could not be cancelled (e.g. the appointment id is unknown in this org). |
| 500 | Unexpected server error. |
Webhooks
SignedReceive a POST to your own https endpoint when bookings change, an order is paid, a form is submitted or a campaign finishes sending. Add an endpoint in your admin dashboard at /{slug}/admin/api (admins only), or let your agent register one itself with the webhooks_create MCP tool. Payloads carry no customer contact details. Fetch the full record from GET /appointments with your API key when you need more.
| Event | When it fires |
|---|---|
| booking.created | A booking is created (online, admin or via the API). |
| booking.cancelled | A booking is cancelled (by staff, the customer, or the API). |
| booking.completed | A booking is marked complete after the visit. |
| booking.rescheduled | A booking is moved to a new time (startAt is the new time). |
| order.paid | A shop order is paid (fires once per order; the buyer's contact details are never in the payload). |
| form.submitted | A client completes an intake / consent form (fires once per response; answers and signature never leave the admin dashboard). |
| campaign.sent | An email campaign finishes sending (from the dashboard or the scheduler; recipient addresses are never in the payload). |
booking.created payload
{
"event": "booking.created",
"sentAt": "2026-06-01T03:12:00.000Z",
"data": {
"id": "apt_abc123",
"ref": "VRD-7QK2M9",
"service": "Gel Manicure",
"startAt": "2026-06-10T21:00:00.000Z",
"durationMin": 60,
"priceCents": 7500,
"status": "pending"
}
}booking.cancelled payload
{
"event": "booking.cancelled",
"sentAt": "2026-06-02T09:30:00.000Z",
"data": {
"id": "apt_abc123",
"ref": "VRD-7QK2M9",
"service": "Gel Manicure",
"startAt": "2026-06-10T21:00:00.000Z",
"status": "cancelled"
}
}booking.completed payload
{
"event": "booking.completed",
"sentAt": "2026-06-10T23:05:00.000Z",
"data": {
"id": "apt_abc123",
"ref": "VRD-7QK2M9",
"service": "Gel Manicure",
"startAt": "2026-06-10T21:00:00.000Z",
"status": "completed"
}
}booking.rescheduled payload
{
"event": "booking.rescheduled",
"sentAt": "2026-06-03T11:00:00.000Z",
"data": {
"id": "apt_abc123",
"ref": "VRD-7QK2M9",
"service": "Gel Manicure",
"startAt": "2026-06-12T21:00:00.000Z",
"status": "confirmed"
}
}order.paid payload
{
"event": "order.paid",
"sentAt": "2026-06-04T02:20:00.000Z",
"data": {
"id": "ord_abc123",
"description": "2 items",
"amountCents": 5400,
"currency": "nzd",
"items": [
{ "productId": "prd_a1", "product": "Repair Shampoo", "quantity": 1 },
{ "productId": "prd_b2", "product": "Leave-in Conditioner", "quantity": 1 }
]
}
}form.submitted payload
{
"event": "form.submitted",
"sentAt": "2026-06-05T20:41:00.000Z",
"data": {
"formId": "frm_abc123",
"formName": "New client intake",
"responseId": "frs_def456",
"customerId": "cus_ghi789",
"appointmentId": "apt_jkl012"
}
}campaign.sent payload
{
"event": "campaign.sent",
"sentAt": "2026-06-06T09:00:00.000Z",
"data": {
"campaignId": "cmp_abc123",
"subject": "Winter offers are live",
"audience": "recent",
"sent": 182,
"recipients": 187
}
}Delivery & signing
Each delivery is signed with HMAC-SHA256 over the string "{timestamp}.{rawBody}", hex-encoded. Verify it before trusting the body. The endpoint secret is shown once when you create it and starts with whsec_.
X-Bookatu-EventThe event name, e.g. booking.created.X-Bookatu-Signaturet={unixSeconds},v1={hmacSha256Hex}. Use t when recomputing the signature.X-Bookatu-AttemptDelivery attempt number, 1 to 3.User-AgentBookatu-Webhooks/1Content-Typeapplication/json
- Up to 3 attempts. Backoff between attempts is 500ms, then 1000ms, then 2000ms (the formula is capped at 4000ms). A delivery succeeds on any 2xx response; non-2xx responses, timeouts and network errors are retried.
- Each attempt times out after 4 seconds.
- Redirects are not followed (redirect: error). Endpoints must be public https URLs: localhost, .local / .internal / .localhost hosts and private, reserved, loopback and cloud-metadata IP ranges are rejected (SSRF protection), both when the endpoint is saved and again at send time.
Verify every delivery. Recompute the signature over {timestamp}.{rawBody} and compare it, in a timing-safe way, to the v1 value in X-Bookatu-Signature.
import crypto from "node:crypto";
// rawBody is the exact bytes you received (do not re-serialize).
// secret is your endpoint secret (whsec_...).
// t comes from the X-Bookatu-Signature header: "t=<seconds>,v1=<hmac>".
function verify(rawBody, t, v1, secret) {
const expected = crypto
.createHmac("sha256", secret)
.update(`${t}.${rawBody}`)
.digest("hex");
return crypto.timingSafeEqual(Buffer.from(expected), Buffer.from(v1));
}Rate limits
There is currently no per-key rate limit enforced on the v1 REST API. That is not a promise of unlimited throughput. Build clients defensively: treat HTTP 429 and 5xx responses as retryable, back off (ideally exponentially, with jitter), and cache rarely-changing responses such as business, services and staff. Limits may be introduced in future, designed not to break well-behaved clients. The exception is the customer booking-management family under /booking/{id}, which IS limited per IP today — they carry no key, so the limit is what stops someone hammering them; each endpoint states its own budget, and a 429 always carries Retry-After.
MCP server
AI agentsBookatu exposes a Model Context Protocol (MCP) server so any AI assistant (Claude, GPT-4o, Gemini, or a custom agent) can list services, check real-time availability, create bookings, look up customer and appointment data, manage the product and service catalog, write and publish posts on the business's blog, build intake and consent forms (and require them before a service), and draft email campaigns — all in a single authenticated session. Writes require a read-write key, and there are no delete tools by design. Two deliberate limits: form-response listings are summaries only (clients' actual answers never leave the admin dashboard), and campaigns can only be drafted — there is no send or schedule tool, so mass email always stays an owner-confirmed action in the app.
Endpoint
https://bookatu.com/api/mcp
Protocol
Streamable HTTP transport, JSON-RPC 2.0. Authenticate with the same bk_live_ key as the REST API (Authorization: Bearer bk_live_YOUR_KEY). The org is resolved from the key automatically — no org slug is needed in the URL.
# Discover tools
curl -X POST https://bookatu.com/api/mcp \
-H "Authorization: Bearer bk_live_YOUR_KEY" \
-H "Content-Type: application/json" \
-d '{"jsonrpc":"2.0","id":1,"method":"tools/list","params":{}}'| Tool | Description |
|---|---|
| list_services | List all active bookable services. |
| get_availability | Get open slots for a service over a date range (max 60 days). |
| create_booking | Book an appointment for a customer; returns a ref and, when a deposit is due, status pending. Requires a read-write key. |
| list_customers | List all customers in the org. |
| list_appointments | List upcoming pending and confirmed appointments. Returns each booking's id for reschedule_booking and cancel_booking. |
| list_staff | List the bookable team members: id, name, title, active and bookable. |
| reschedule_booking | Move a booking to a new time, and optionally to a different team member. The new time is re-checked exactly as a client's own reschedule is, so a time that is not free is refused rather than double-booked. Requires a read-write key. |
| cancel_booking | Cancel a booking. The client is told and the slot goes back on offer; the reply says whether a paid deposit was kept under the business's notice policy. Requires a read-write key. |
| list_products | List every retail product in the catalog, active and inactive. |
| create_product | Add a retail product (prices in dollars; optional tracked stock with an opening-stock ledger entry). Requires a read-write key. |
| update_product | Update only the provided fields of a product; a stock change records a ledger adjustment. Requires a read-write key. |
| receive_stock | Record a delivery into a stock-tracked product and log the restock movement. Requires a read-write key. |
| create_service | Add a standard bookable service (duration in minutes, prices in dollars). Requires a read-write key. |
| update_service | Update only the provided fields of a service. Requires a read-write key. |
| blog_list_posts | List the business's blog posts (drafts and published), newest first — lean rows without bodies; optional status filter. |
| blog_get_post | Get one blog post in full, markdown body included, by id or slug (drafts visible to the owner). |
| blog_create_post | Write a blog post (markdown body; slug auto-derived and unique per business). Saves a draft unless publish is true. Requires a read-write key. |
| blog_update_post | Update only the provided fields of a post — the slug never changes — and optionally flip its status via an explicit draft/published field. Requires a read-write key. |
| blog_publish_post | Publish a draft post; the first publish stamps publishedAt and a re-publish keeps the original date. Requires a read-write key. |
| blog_unpublish_post | Take a post off the public blog (back to draft), keeping its content and original publish date. Requires a read-write key. |
| forms_list | List the business's intake / consent forms — lean rows with field, attachment and response counts, never full field schemas; optional active/inactive filter. |
| forms_get | Get one form in full, including its complete field schema and the services it is required before. |
| forms_create | Create an intake / consent form from a hard-validated field schema (the real builder field types; choice fields need 2+ options — bad fields are rejected, never silently dropped). Requires a read-write key. |
| forms_update | Update only the provided parts of a form (a fields array replaces the stored one; active shows/hides it). Service attachment changes go through the attach/detach tools. Requires a read-write key. |
| forms_attach_to_service | Require a form before a service (clients complete it when booking). Both ids must belong to the business; idempotent. Requires a read-write key. |
| forms_detach_from_service | Stop requiring a form before a service — removes the requirement only, the form and responses stay. Idempotent. Requires a read-write key. |
| forms_list_responses | Summary rows for one form's responses: response id, customer display name and submission time only. Clients' actual answers never leave the admin dashboard (privacy posture). |
| campaigns_list | List email campaigns (drafts through sent) — lean rows with status, audience segment and counts, never bodies. |
| campaigns_get | Get one campaign in full, body markup included. Audiences are segment names; recipient email addresses are never returned. |
| campaigns_create_draft | Draft an email campaign (subject, composer-markup body, audience segment). Always saved as a draft: there is no send or schedule tool — sending stays an owner action in the dashboard. Requires a read-write key. |
| get_report | Business performance for a period vs the period before: revenue, bookings, chair time used, revenue per open hour, rebook and no-show rates, with service/staff/category/channel breakdowns. Read-only. |
| get_findings | Ranked, data-computed findings about the business (severity, headline, detail, suggested action) — what the numbers mean, not prose. Read-only. |
| get_capacity | Chair time by day of week and hour in the business's own timezone — where the week is full and where it is empty. Read-only. |
| webhooks_list | List the business's webhook endpoints: url, subscribed events, active flag and last delivery status. Signing secrets are never returned. |
| webhooks_create | Register a public https endpoint to receive signed event POSTs; returns the HMAC signing secret once. Unknown event names are rejected. Requires a read-write key. |
| webhooks_disable | Switch an endpoint off — deliveries stop, the endpoint and its history are kept. No delete tool exists; resuming or removing is an owner action in the dashboard. Requires a read-write key. |
# Call a tool
curl -X POST https://bookatu.com/api/mcp \
-H "Authorization: Bearer bk_live_YOUR_KEY" \
-H "Content-Type: application/json" \
-d '{
"jsonrpc": "2.0",
"id": 2,
"method": "tools/call",
"params": {
"name": "get_availability",
"arguments": {
"serviceId": "svc_abc123",
"from": "2026-06-10",
"to": "2026-06-14"
}
}
}'Configure your AI assistant by pointing it at https://bookatu.com/api/mcpwith your API key. All tools are automatically scoped to the key's organisation.
Connect from Claude
OAuth 2.1Claude's custom connectors use OAuth rather than a pasted key, and the MCP server supports that flow natively. You sign in with your normal Bookatu account, pick which business to connect and whether Claude may write or only read, and Claude receives its own revocable token for that one business. Your password and your other businesses are never shared.
- In Claude, open Settings → Connectors → Add custom connector.
- Enter
https://bookatu.com/api/mcpas the server URL and continue. - Claude sends you to Bookatu to sign in. You must be an owner or admin of the business you connect.
- Choose the business and the access level (read and write, or read only), then approve.
The same rules apply as with API keys: every tool is scoped to the connected business, read-only connections are refused on any write tool, and there are no delete tools at all. Disconnect any time from Settings → Developer API → Connected apps in your dashboard, or from Claude's connector settings. The flow is the standard MCP authorization stack for any other OAuth-capable MCP client too: discovery at /.well-known/oauth-authorization-server and /.well-known/oauth-protected-resource, dynamic client registration, authorization code with PKCE, and refresh token rotation.
Build an agent on Bookatu
The closed loopAn AI agent can run a Bookatu business end to end with two primitives: MCP tools to act (read data, create bookings, manage the catalog, draft campaigns) and signed webhooks to react (a POST arrives at the agent when something happens). Tools in, events out. That closes the loop: the agent no longer needs a human to tell it that a booking was cancelled or an order was paid.
1. Get a read-write API key
Create one in the admin dashboard under Settings, then Developer API. The key scopes every tool call to that business; a read-only key can call every read tool but no write tool.
2. Subscribe to events (the agent can do this itself)
Call the webhooks_create MCP tool with a public https URL your agent listens on. The response contains the HMAC signing secret exactly once. webhooks_list shows what is wired up, webhooks_disable switches an endpoint off, and the owner sees every endpoint and delivery in the dashboard.
3. Verify and react
Each delivery is signed (X-Bookatu-Signature) and named (X-Bookatu-Event). Verify the signature, then decide: a booking.cancelled can trigger a get_availability call and a waitlist offer; an order.paid can trigger receive_stock planning; a form.submitted can trigger the pre-visit checklist.
4. Act through tools, within the guardrails
Every write is org-scoped and velocity-capped, there are no delete tools, campaigns can only be drafted (sending stays owner-confirmed in the dashboard), and clients' form answers never leave the admin UI. An agent can run the business without being able to wreck it.
Worked example: an agent that refills cancelled slots
# The agent registers where it wants events delivered (once, at setup)
curl -X POST https://bookatu.com/api/mcp \
-H "Authorization: Bearer bk_live_YOUR_KEY" \
-H "Content-Type: application/json" \
-d '{
"jsonrpc": "2.0", "id": 1, "method": "tools/call",
"params": {
"name": "webhooks_create",
"arguments": {
"url": "https://agent.example.com/hooks/bookatu",
"events": ["booking.cancelled"]
}
}
}'
# → { "webhook": { "id": "whk_...", ... }, "secret": "whsec_..." } (secret shown once)# Later, a client cancels. Bookatu POSTs to the agent's endpoint:
POST /hooks/bookatu HTTP/1.1
X-Bookatu-Event: booking.cancelled
X-Bookatu-Signature: t=1780000000,v1=3f1c...9ab2
Content-Type: application/json
{
"event": "booking.cancelled",
"sentAt": "2026-07-30T02:14:00.000Z",
"data": { "id": "apt_abc123", "ref": "VRD-7QK2M9",
"service": "Gel Manicure",
"startAt": "2026-08-02T21:00:00.000Z", "status": "cancelled" }
}# The agent verifies the signature, then acts through MCP again:
# confirm the freed slot is really open...
{ "name": "get_availability",
"arguments": { "serviceId": "svc_...", "from": "2026-08-02", "to": "2026-08-02" } }
# ...and rebook it for the next customer who asked for that time.
{ "name": "create_booking",
"arguments": { "serviceId": "svc_...", "startAt": "2026-08-02T21:00:00.000Z",
"name": "Mia Chen", "email": "mia@example.com", "phone": "+64211234567" } }What the loop deliberately cannot do
What the loop deliberately cannot do: delete anything (no delete tools exist on the MCP surface), send mass email (campaigns are drafts until the owner presses send), read clients' form answers, or touch another business (the org is resolved from the key, and endpoint URLs must be public https, so events can never be routed to internal infrastructure). Webhook payloads carry ids and names, not customer contact details; fetch full records with your key when you need them.
Your first booking is a curl away.
Create a free account and generate your first API key from the admin dashboard.