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 their checkout, booking requests, class sessions, reports and the audit log) 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 scopes, picked when it is made: bookings:read, bookings:write, availability:read, catalog:read, catalog:write, staff:read, customers:read, reports:read, content:read, content:write, webhooks:read, webhooks:write, checkout:read, checkout:write, audit:read. Every key-protected endpoint and every MCP tool needs exactly one, named on it below (GET /customers needs customers:read, reading bookings bookings:read, making, moving, cancelling or answering them and changing a class roster bookings:write, the money reports reports:read), and tools/list shows only the tools the key can call. A call outside the key's scopes returns 403 naming the scope it needed (missingScope in the body; error.data.missingScope over MCP).
- checkout:read, checkout:write, audit:read are carried only by a key made with them ticked: the checkout scopes are for another point of sale (such as Moneris MGR) checking bookings out, and audit:read reads the audit log. A key made before scopes never has them, whatever its access level, and neither does a connected app. Anything that names a client (their name, email and phone) needs customers:read as well.
- A key can never do more than the person who made it may do in the business: they can only give it scopes their role holds (customers:read needs Export data, reports:read needs See analytics & earnings), and if their role later loses a right, or they leave the team, their keys lose it too. A connected app is held to the person who approved it the same way.
- Keys made before scopes keep the access level they were made with: 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. The checkout and audit scopes are the exception: such a key never has them. The dashboard marks them as legacy; replace them with scoped keys.
- 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 |
| GET /appointments/{id}/checkout | API key | What a booking comes to at checkout (MGR hand-off) |
| POST /appointments/{id}/checkout | API key | Post an outside sale back: the booking is checked out (MGR hand-off) |
| POST /appointments/{id}/deposit-request | API key | Send the deposit pay link again |
| POST /appointments/{id}/deposit-request/cancel | API key | Stop asking for the deposit |
| GET /booking-requests | API key | List booking requests waiting for an answer |
| POST /booking-requests/{id}/accept | API key | Accept a booking request |
| POST /booking-requests/{id}/decline | API key | Decline a booking request |
| GET /class-sessions | API key | List class sessions |
| GET /class-sessions/{id}/roster | API key | Who holds a place on a class session |
| POST /class-sessions/{id}/roster | API key | Put a phone or walk-in client on a class session |
| POST /class-sessions/{id}/roster/{enrolmentId}/cancel | API key | Cancel one person's place on a class session |
| POST /class-sessions/{id}/roster/{enrolmentId}/attendance | API key | Mark who came to a class session |
| GET /reports/earnings | API key | Staff earnings and commission |
| GET /reports/tips | API key | Tips |
| GET /reports/deposits | API key | Deposit reconciliation |
| GET /audit-log | API key | The audit log |
| GET /visits/availability | None | Open starts for a visit of several services |
| POST /visits/book | None | Book a visit of several services (online source) |
| POST /visits | API key | Book a visit of several services (the team's) |
| GET /visits/{id} | API key | Get one visit |
| POST /visits/{id}/reschedule | API key | Move a whole visit |
| POST /visits/{id}/cancel | API key | Cancel a whole visit |
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. At a business that lets clients book several services in one visit, visits says so, with the most services one visit takes: { "maxServices": 4 } (absent everywhere else). 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. At a business with extras, each service also has addOns: the extras offered with it under the business's combination rules ({ id, name, description, priceCents, durationMin, addsTime }; addsTime true means its durationMin is added to the booking). Pass their ids as addOnIds when booking. A business with no extras returns no addOns. A service with combination rules lists them as combinations: onlyWith, the only other services it can share a visit with (absent: any), and neverWith, the services and extras it is never booked with, whichever of the two set it (a 4 braids and a 6 braids, say). A service two or more of the team do at once says how many as peopleNeeded. Each only when there is something to say, so every other row is exactly as it was. A service that needs two or more kinds of resource at once (a treatment room and a laser machine, say) lists them as resourceKinds, in order: a time is offered only when a person and one free resource of each kind are free together, and a booking of it holds one of each for its whole length, the first kind's as the booking's own room. Absent on a service that needs one kind or none, so its row is exactly as it was. 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. Each time also comes as when: { utc, primary, business: { timeZone, local, utcOffset, abbr, display }, client } with the offset in force on that day (a slot after the clocks change carries the new one), on the business's clock (the branch's at a branch) and, when the client's zone is known, on theirs (client is null otherwise; dayDiffers says their date is another day). Quote display rather than converting a time yourself. timeZone names the clock the slots and the from/to dates are on. 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",
"when": {
"utc": "2026-06-09T21:00:00Z",
"primary": "business",
"business": {
"timeZone": "Pacific/Auckland",
"local": "2026-06-10T09:00:00+12:00",
"utcOffset": "+12:00",
"abbr": "NZST",
"display": "Wednesday, 10 June at 9:00 am NZST"
},
"client": null
}
}
]
},
{ "date": "2026-06-11", "open": false, "slots": [] }
],
"timeZone": "Pacific/Auckland",
"datesAreOn": "business"
}
// 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 (for a service that takes a group: a room that seats them all and a person free), 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. |
| addOnIds | string | query | No | The extras the booking will have, comma-separated ids from the service's addOns. Extras with addsTime make the booking longer, so only times with room for the longer booking come back. Omit for the service on its own. |
| clientTimeZone | string | query | No | The client's own time zone, the IANA zone of a place (America/Toronto): each slot's when then gives the time on their clock too. Abbreviations (EST, which would be read as Panama), offsets (+13:00) and Etc/ zones are refused with code UNKNOWN_TIME_ZONE (400): ask the person which city they are in. Never guessed from the request. |
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; or clientTimeZone is not a place's zone (code UNKNOWN_TIME_ZONE). |
| 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, with no key and no account, so nobody calling it has proved who they are. The slot is validated server-side (race-condition safe) and a booking reference is returned. As an online-source booking it fully respects availability and minimum notice. A customer record is matched by email then phone or created, but an email match changes nothing: every customer is treated as new (a business that approves new clients' bookings gets a request), no deposit is waived, nothing on an existing record is filled in, and the answer is the same for a known email and an unknown one. A service that takes a deposit is refused with 409 and code DEPOSIT_NEEDS_BOOKING_PAGE, with bookingPageUrl, the business's own booking page where the deposit can be paid; nothing is booked. The appointmentId in the answer is the person's private key to their booking: never show it, store it or put it in a chat; the person manages the booking from the confirmation email they are sent. The answer's when is the booked time on the business's clock and, with clientTimeZone, the client's. No authentication required; at most 5 bookings per address per business in 10 minutes and 15 a day, and 40 a day per business.
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
}
// status is "pending" for a request the business still has to accept.
// appointmentId is the person's private key to their booking: never show it,
// store it or put it in a chat; they manage it from the email they are sent.
// A service that takes a deposit is refused, and nothing is booked:
// HTTP 409
{
"ok": false,
"error": "This service takes a deposit, which can only be paid on the business's booking page. Nothing was booked: send the person to bookingPageUrl.",
"code": "DEPOSIT_NEEDS_BOOKING_PAGE",
"bookingPageUrl": "https://bookatu.com/your-salon/book?service=svc_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 | RFC 3339 with its offset, e.g. a startAt value from GET /availability ("2026-06-10T09:00:00.000Z") or "2026-06-10T21:00:00+12:00". Read strictly: no offset is a 422 with code TIME_NEEDS_OFFSET and an example in the business's zone; a date that does not exist, seconds other than :00 or anything else is a 422 with code TIME_INVALID. Nothing is booked either way. |
| 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. A service that takes a group: no more than its most, in a room that seats them all, still with a person, and priced per head when the service is. 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. |
| repeat | object | body | No | Book this visit and repeats of it as one series: { "everyDays": 14, "visits": 6 } or { "monthly": true, "visits": 6 }, where visits counts every visit, this one included. POST /appointments (a key with bookings:write): every 1–365 days or monthly, 2–26 visits, each repeat checked against the calendar. POST /book: only what the business lets clients repeat online (its services, every 1–4 weeks or monthly, up to its most visits), else 422 with nothing booked. A repeat that is not free is left out and listed with why, never forced in; only the first visit can need a deposit. The answer then has a series: { id, every, bookedCount, skippedCount, visits: [{ index, startAt, status: booked | skipped, appointmentId?, ref?, code?, reason? }] }. Omit to book one visit. |
| marketingConsent | object | body | No | Only when the customer agreed to get marketing email from this business: { "source": "website_form", "wording": "the exact words they agreed to" } (source up to 60 characters, wording 10 to 2000). Through this call the yes is never taken on its own: a new customer is emailed by the business to confirm it, and is subscribed only once they do. Omit it when they did not agree: nobody is opted in by default, and a customer already on file keeps the choice they made. Malformed is a 422 with nothing booked. |
| addOnIds | string[] | body | No | Extras to add, up to 20: ids from the service's addOns in GET /services (only the extras the business offers with that service are listed there). One the service does not take under the business's combination rules (an extra it does not offer, or two that are never together) is a 409 with the reason, e.g. "Gloss can't be added to 4 braids.", and nothing is booked. Each extra is added to the price; one with addsTime also makes the booking longer by its durationMin, so ask GET /availability with the same addOnIds first. Omit to book no extras. |
| clientTimeZone | string | body | No | The client's own time zone, the IANA zone of a place (America/Toronto), kept on the booking so their emails and pages show their clock too; the answer's when gives the time on both clocks. Abbreviations (EST, which would be read as Panama), offsets (+13:00) and Etc/ zones are refused with code UNKNOWN_TIME_ZONE (422, nothing booked): ask the person which city they are in. Never guessed from the request. |
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). A startAt with no offset has code TIME_NEEDS_OFFSET and an example in the business's zone; one that is not a real RFC 3339 date-time on a whole minute has code TIME_INVALID; a clientTimeZone that is not a place's zone has code UNKNOWN_TIME_ZONE. |
| 409 | Nothing was booked: the slot was just taken, the service is unavailable, inactive or not offered online, the service takes a deposit (code DEPOSIT_NEEDS_BOOKING_PAGE, with bookingPageUrl), or the business is not taking online bookings right now (code BOOKING_PAUSED). |
| 429 | Too many bookings: 5 per address per business in 10 minutes, 15 a day, or 40 a day at the business through this call. The response carries Retry-After. |
| 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 and what the customer may still change online (selfService), worked out by the same rules the web page and the change endpoints apply: whether a cancel or a move would be accepted now and why not, until when online changes stay open, how many online moves are left, and, when the business lets customers choose someone else when they move, who they may choose. 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. When the booking is one service of a visit of several, visit lists the visit's services still on ({ id, services: [{ appointmentId, service, staff, startAt, endAt, durationMin, priceCents, status }] }), and selfService is the visit's, under the rules of its first service. A booking two or more of the team do at once also has booking.additionalStaff: the others' names, besides staff. At a business that switched Quick swap on, quickSwap says what the manage-booking page would offer about swapping out of a late cancellation: status (null, requested, claimed, withdrawn or expired), requestedAt, cutoffAt (when offers stop), canAsk and, when not, block (off, approval, closed, not_confirmed, checked_in, request_open, not_offered, asked, past_cutoff or not_late), and canWithdraw (the client's ask stands and the time is still on offer: false once the business has stopped offering it, for example by switching Quick swap off). Read only: the customer asks and withdraws on the web page. when is the booking's start on the business's clock and, when the client's zone was kept with the booking, on theirs (source "booking"). 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
},
"selfService": {
"canCancel": true,
"canReschedule": true,
"cancelBlocked": null,
"rescheduleBlocked": null,
"changesCloseAt": "2026-06-09T21:00:00.000Z",
"movesLeft": 2,
"canChangeStaff": true,
"staffOptions": [
{ "id": "stf_anna", "name": "Anna", "current": true },
{ "id": "stf_mia", "name": "Mia", "current": false }
],
"message": null,
"contact": null
}
}
// 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.
//
// selfService is what the customer may change online right now. canCancel and
// canReschedule say whether POST /cancel and POST /reschedule would be accepted;
// when not, cancelBlocked / rescheduleBlocked carry the same code those
// endpoints' 409 does: cancel_off or reschedule_off (the business takes that
// change by phone or email only), past_cutoff, reschedule_limit, cancel_closed
// or move_closed (the booking is cancelled, completed, a no-show or has
// started), or deposit_unpaid (rescheduleBlocked only: the booking is held
// until its deposit is paid, and cannot be moved before). changesCloseAt is when online changes stop (null: never); movesLeft
// is null when there is no limit. staffOptions lists who the booking can be
// moved to (the booked person first, current: true) and is [] unless
// canChangeStaff. message and contact are what the web page shows instead of
// the changes the business keeps for itself, in its language: null when
// nothing is kept back, and for a booking that is over.
//
// At a business that switched Quick swap on there is also quickSwap, e.g.
// { "status": null, "requestedAt": null, "cutoffAt": "2026-06-10T19:00:00.000Z",
// "canAsk": true, "block": null, "canWithdraw": false }: whether the customer
// may ask to swap out of a late cancellation on the web page now, and when
// offers stop.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. For one service of a visit of several, the slots are the starts the whole visit fits, each service keeping its person and length, and visit gives its id and its whole length ({ id, durationMin }). 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. |
| staffId | string | query | No | Someone else to move the booking to: an id from selfService.staffOptions on GET /booking/{id}. Omitted, the slots are the booked team member's. Anyone the business does not let the customer choose returns no days. |
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. For one service of a visit of several, the whole visit moves: every service keeps its person and length, startAt is the first service's new start, it counts once against the business's limit on moves, and nobody else can be picked (staffId). 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. |
| staffId | string | body | No | Move the booking to someone else: an id from selfService.staffOptions on GET /booking/{id}, only when the business lets customers choose (canChangeStaff). A change of person counts as a move, at the same time or not. Omitted, the booking keeps its team member. |
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; or staffId is not a string. |
| 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, and when the business's online-change rules refuse the move; then the response also carries blocked (reschedule_off, past_cutoff, reschedule_limit, staff_not_offered for a person the customer may not choose, or deposit_unpaid for a booking held until its deposit is paid) and the business's contact { phone, email }. |
| 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, the waitlist re-offer and the cancellation emails the business may have switched on 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. Only an upcoming pending or confirmed booking can be cancelled: a completed, no-show or started booking returns 409. For one service of a visit of several, the whole visit is cancelled, under the rules of its first service, with one email each way. 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. When the business's online-change rules refuse it, or the booking is over, it also carries blocked (cancel_off, past_cutoff or cancel_closed) and the business's contact { phone, email }, as selfService on GET /booking/{id} predicts. |
| 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 keyNeeds the customers:read scope.
GET /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, or it lacks the customers:read scope (the body names it as missingScope). |
| 404 | No business exists for the {slug}. |
| 500 | Unexpected server error. |
GET /appointments
Requires API keyNeeds the bookings:read scope.
GET /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. Each row says where the booking came from: source (online: the client; phone, walk_in or admin: the team, the API or the phone app; magic_fill: a waitlist claim), leadSource (the tag it came in with, as stored, e.g. a booking link's ?via=) and channel (google, instagram, facebook, apple_maps or bing for a client who came through one of the business's booking links, else null); seriesId names the repeat series a booking is a visit of, and visitId the visit of several services it is one of (each null when it is neither; GET /visits/{id} reads a visit). A booking that two or more of the team do at once also has additionalStaffIds: the others on it besides staffId, in their order (absent on every other booking). A booking of a service that needs two or more kinds of resource at once, or one holding more than one resource, also has resources: every room, chair or machine it holds, { id, name, kind }, its own room first and then the others in the order of the service's resourceKinds. A booking saved without one of its kinds lists only what it holds ([] for nothing). Absent on every other booking. Quick swap: a booking whose client asked to swap out of a late cancellation (the business offers the time to its waitlist while the client stays booked) also has swap ({ status, requestedAt, cutoffAt, closedAt, claimedAppointmentId }). status is requested (offered to the waitlist until cutoffAt), claimed (a waiting client took it over: claimedAppointmentId is their booking, and this one is cancelled with no fee), withdrawn or expired (nobody took it by the cutoff, or the booking changed since). The ask is on the first service of a visit. Absent on every other booking, so a business that never switched Quick swap on reads exactly as before. 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",
"source": "online",
"leadSource": "google",
"channel": "google",
"seriesId": null,
"visitId": null
}
]
}
// 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, or it lacks the bookings:read scope (the body names it as missingScope). |
| 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, addOns included at a business with extras. A service with combination rules lists them as combinations: onlyWith, the only other services it can share a visit with (absent: any), and neverWith, the services and extras it is never booked with, whichever of the two set it (a 4 braids and a 6 braids, say). A service two or more of the team do at once says how many as peopleNeeded. Each only when there is something to say, so every other row is exactly as it was. A service that needs two or more kinds of resource at once (a treatment room and a laser machine, say) lists them as resourceKinds, in order: a time is offered only when a person and one free resource of each kind are free together, and a booking of it holds one of each for its whole length, the first kind's as the booking's own room. Absent on a service that needs one kind or none, so its row is exactly as it was. 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 keyNeeds the bookings:read scope.
GET /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, source, leadSource, channel, seriesId and visitId included. A booking that two or more of the team do at once also has additionalStaffIds: the others on it besides staffId, in their order (absent on every other booking). A booking of a service that needs two or more kinds of resource at once, or one holding more than one resource, also has resources: every room, chair or machine it holds, { id, name, kind }, its own room first and then the others in the order of the service's resourceKinds. A booking saved without one of its kinds lists only what it holds ([] for nothing). Absent on every other booking. Quick swap: a booking whose client asked to swap out of a late cancellation (the business offers the time to its waitlist while the client stays booked) also has swap ({ status, requestedAt, cutoffAt, closedAt, claimedAppointmentId }). status is requested (offered to the waitlist until cutoffAt), claimed (a waiting client took it over: claimedAppointmentId is their booking, and this one is cancelled with no fee), withdrawn or expired (nobody took it by the cutoff, or the booking changed since). The ask is on the first service of a visit. Absent on every other booking, so a business that never switched Quick swap on reads exactly as before. 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",
"source": "phone",
"leadSource": null,
"channel": null,
"seriesId": "ser_abc123",
"visitId": null
}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, or it lacks the bookings:read scope (the body names it as missingScope). |
| 404 | No business exists for the {slug}, or no appointment with that id in this org. |
| 500 | Unexpected server error. |
POST /appointments
Requires API keyNeeds the bookings:write scope.
POST /api/v1/{slug}/appointmentsCreate a booking (admin source)
Create a booking with source: admin and the same body as POST /book, plus requestDeposit and additionalStaffIds (the others on a booking two or more of the team do at once, then named in the answer too). For a service that needs two or more kinds of resource at once (resourceKinds on GET /services), the engine holds one free resource of each kind at a time it offers, resourceId prefers the first kind's one (the room), and the answer lists everything the booking holds as resources ({ id, name, kind }); at a time the engine does not offer it may hold fewer, exactly as a room is left off, and resources says what it holds. The other resources are always the engine's choice and cannot be named. To book a visit of several services, use POST /visits. 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 | No | An instant with its offset (RFC 3339), such as a startAt from GET /availability ("2026-06-10T09:00:00Z"), in whole minutes. A time with no offset is a 422 TIME_NEEDS_OFFSET with an example on the business's clock; a date that does not exist or seconds other than :00 are TIME_INVALID. Send startAt or localTime, exactly one. |
| 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. A service that takes a group: no more than its most, in a room that seats them all, still with a person, and priced per head when the service is. 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. |
| repeat | object | body | No | Book this visit and repeats of it as one series: { "everyDays": 14, "visits": 6 } or { "monthly": true, "visits": 6 }, where visits counts every visit, this one included. POST /appointments (a key with bookings:write): every 1–365 days or monthly, 2–26 visits, each repeat checked against the calendar. POST /book: only what the business lets clients repeat online (its services, every 1–4 weeks or monthly, up to its most visits), else 422 with nothing booked. A repeat that is not free is left out and listed with why, never forced in; only the first visit can need a deposit. The answer then has a series: { id, every, bookedCount, skippedCount, visits: [{ index, startAt, status: booked | skipped, appointmentId?, ref?, code?, reason? }] }. Omit to book one visit. |
| marketingConsent | object | body | No | Only when the customer agreed to get marketing email from this business: { "source": "website_form", "wording": "the exact words they agreed to" } (source up to 60 characters, wording 10 to 2000). Recorded as their consent, with the time and those words (Canada's anti-spam law asks for that proof). Omit it when they did not agree: nobody is opted in by default, and a customer already on file keeps the choice they made. Malformed is a 422 with nothing booked. |
| addOnIds | string[] | body | No | Extras to add, up to 20: ids from the service's addOns in GET /services (only the extras the business offers with that service are listed there). One the service does not take under the business's combination rules (an extra it does not offer, or two that are never together) is a 409 with the reason, e.g. "Gloss can't be added to 4 braids.", and nothing is booked. Each extra is added to the price; one with addsTime also makes the booking longer by its durationMin, so ask GET /availability with the same addOnIds first. Omit to book no extras. |
| requestDeposit | boolean | body | No | POST /appointments only. true asks the client for a deposit, as the staff booking form can: the booking is confirmed and waits on its deposit (the service's, by the business's deposit policy), and the client is sent a secure pay link to the business's own Stripe Checkout by email (and by text when they agreed to texts). Refused before anything is booked when the business cannot take card payments (409, reason no_payments), the service takes no deposit (422, reason no_deposit) or there is no email to send the link to (422, reason no_email). The answer then has depositRequest: { sent: true, amountCents, emailedTo, texted, expiresAt } or { sent: false, reason, message } (the booking is still made; send the link with POST /appointments/{id}/deposit-request). Omit for a booking with no deposit asked for, exactly as before. |
| additionalStaffIds | string[] | body | No | For a service two or more of the team do at once (peopleNeeded on GET /services): the others on the booking besides staffId, as the New booking form's Also with names them, ids from GET /staff, up to peopleNeeded minus one. Omit to let the engine pick whoever else does it and is free. The answer then has additionalStaffIds. A 422 with nothing booked for a service one person does, an id that is not one of this business's active team, or staffId itself. |
| localTime | string | body | No | Instead of startAt: a wall-clock time with no offset ("2026-11-04T09:00"), read on the business's clock (the branch's, at a branch) or on timeZone. A time the clocks skip that day is a 422 TIME_DOES_NOT_EXIST with nextValid, one they show twice a 422 TIME_AMBIGUOUS with both candidates (as When objects): resend startAt with the offset meant. |
| timeZone | string | body | No | With localTime only: the IANA zone of a place it is on (America/Toronto). Abbreviations, offsets and Etc/ zones are a 422 UNKNOWN_TIME_ZONE. |
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). With requestDeposit, also when the business cannot take card payments (reason no_payments), with nothing booked. |
| 422 | With requestDeposit: it is not true or false, the service takes no deposit (reason no_deposit), or there is no email to send the pay link to (reason no_email). With additionalStaffIds: the service is done by one person, an id is not one of this business's active team or is staffId, or there are more than it needs (fieldErrors.additionalStaffIds). Nothing is booked. |
| 401 | The API key is missing or invalid. |
| 403 | The key belongs to a different organisation, or it lacks the bookings:write scope (the body names it as missingScope), or it is a key made before scopes that 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 keyNeeds the bookings:write scope.
POST /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 it lacks the bookings:write scope (the body names it as missingScope), or it is a key made before scopes that 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. |
GET /appointments/{id}/checkout
Requires API keyNeeds the checkout:read scope, on a key made with it ticked.
GET /api/v1/{slug}/appointments/{id}/checkoutWhat a booking comes to at checkout (MGR hand-off)
For a point of sale that is not Bookatu's (Moneris MGR, or any till): start checkout from a booking and carry it into your sale rather than ringing it up again by hand. The answer is the sale Bookatu's own till would open with: the booking and its ref, the client, who it is with, each line (the service, then each extra; every service of a visit of several services) at its booked price and discount with the person credited for it (staffId, staffName), each line's tax and the tax lines as the till would charge them (tax.mode: included in the prices, added on top at checkout, or none), the deposit already paid (depositPaidCents) and dueCents: what is left to take, tax added on top included and the deposit taken off. Charge exactly dueCents, then POST the sale back to the same address. checkout.allowed says whether it can be checked out now; when not, checkout.blocked says why (not_live: finished, cancelled or a no-show; deposit_pending: the client is still paying an online deposit; already_checked_out: then sale is the sale that closed it, whichever till took it). Each line's lineId is stable while the booking is open; name it to credit a line to someone else when you post the sale. The client comes by id (customer.id); their name, email and phone come too only when the key also has customers:read. A visit of several services is one sale under its first service, whichever service's id you ask with. Requires an API key made with the checkout:read scope ticked (the scope needs See everyone's calendar). Changes nothing.
curl https://bookatu.com/api/v1/your-salon/appointments/apt_abc123/checkout \ -H "Authorization: Bearer bk_live_YOUR_KEY"
Response example
{
"version": 1,
"appointment": {
"id": "apt_abc123",
"ref": "VRD-7QK2M9",
"status": "confirmed",
"startAt": "2026-06-10T21:00:00.000Z",
"endAt": "2026-06-10T22:10:00.000Z",
"serviceName": "Cut",
"customerId": "cus_xyz",
"staffId": "stf_sam",
"source": "online",
"visitId": null
},
"customer": { "id": "cus_xyz", "name": "Jane Doe", "email": "jane@example.com", "phone": "+16045550100" },
"staff": { "id": "stf_sam", "name": "Sam" },
"currency": "CAD",
"lines": [
{
"lineId": "apt_abc123:service",
"kind": "service",
"appointmentId": "apt_abc123",
"label": "Cut",
"qty": 1,
"unitCents": 4000,
"discountCents": 400,
"totalCents": 3600,
"taxCents": 432,
"serviceId": "svc_cut",
"staffId": "stf_sam",
"staffName": "Sam"
},
{
"lineId": "apt_abc123:add_on:1",
"kind": "add_on",
"appointmentId": "apt_abc123",
"label": "Head massage",
"qty": 1,
"unitCents": 1500,
"discountCents": 0,
"totalCents": 1500,
"taxCents": 180,
"serviceId": "svc_massage",
"staffId": "stf_sam",
"staffName": "Sam"
}
],
"subtotalCents": 5500,
"discountCents": 400,
"totalCents": 5100,
"tax": {
"mode": "added",
"taxCents": 612,
"lines": [
{ "rateId": "gst", "label": "GST", "rateMilli": 5000, "taxCents": 255 },
{ "rateId": "pst", "label": "PST", "rateMilli": 7000, "taxCents": 357 }
]
},
"depositPaidCents": 2000,
"dueCents": 3712,
"checkout": { "allowed": true, "blocked": null },
"sale": null
}
// A booking already checked out: dueCents 0, checkout.blocked
// "already_checked_out", and sale is the sale that closed it:
// "sale": {
"id": "sale_x1y2z3",
"receipt": "X1Y2Z3",
"at": "2026-06-10T22:12:00.000Z",
"source": "moneris_mgr",
"externalSaleId": "MGR-000123",
"method": "Card",
"tender": "card",
"collectedCents": 3712,
"saleTotalCents": 3712,
"tipCents": 500,
"products": []
}
//
// sale.source is "bookatu" for Bookatu's own till (externalSaleId null), else
// the name the outside till posted. tax.lines[].rateId is null for a business
// with one tax inside its prices. customer has only id without customers:read.Parameters
| Field | Type | In | Required | Notes |
|---|---|---|---|---|
| id | string | path | Yes | The appointment id (from GET /appointments or GET /appointments/{id}). |
Errors
| Status | When |
|---|---|
| 401 | The API key is missing or invalid. |
| 403 | The key belongs to a different organisation, or it does not carry the checkout:read scope (the body names it as missingScope). Only a key made with checkout:read ticked carries it: a key made before scopes never does, whatever its access level. |
| 404 | No business exists for the {slug}, or no appointment with that id in this business (the same 404 either way). |
| 500 | Unexpected server error (details are never leaked). |
POST /appointments/{id}/checkout
Requires API keyNeeds the checkout:write scope, on a key made with it ticked.
POST /api/v1/{slug}/appointments/{id}/checkoutPost an outside sale back: the booking is checked out (MGR hand-off)
The sale your point of sale took for the booking. The booking is checked out exactly as Bookatu's own till checks it out: one sale linked to the booking (its receipt number on Bookatu's side), a line for the service and each extra credited to who did it, the booking's payment on the ledger (so it drops off Payments owed), the tip and who got it, and the booking is completed, with the thank-you, loyalty points and booking.completed webhook every completed booking gets. It is idempotent on externalSaleId: the same sale posted again (a retry after a dropped connection) finds the sale it made, answers replayed: true and writes nothing more, so it is always safe to retry. amountCents must be the checkout's dueCents: a sale for a different amount is refused with the amount it comes to (amount_mismatch), because two tills that disagree on the price cannot both be right in the books; change the booking's price in Bookatu first. A tip is recorded only when the business has switched on Accept tips at checkout, as at its own till (sale.tipCents says what was). Products you sell with it stay in your own till. Fires booking.checked_out to the endpoints subscribed to it. The answer is the checkout payload as GET now returns it, with ok and replayed. Requires an API key made with the checkout:write scope ticked (the scope needs Edit the calendar).
curl -X POST https://bookatu.com/api/v1/your-salon/appointments/apt_abc123/checkout \
-H "Authorization: Bearer bk_live_YOUR_KEY" \
-H "Content-Type: application/json" \
-d '{
"externalSaleId": "MGR-000123",
"source": "moneris_mgr",
"amountCents": 3712,
"currency": "CAD",
"tender": "card",
"tipCents": 500,
"lines": [{ "lineId": "apt_abc123:add_on:1", "staffId": "stf_mia" }]
}'Response example
{
"ok": true,
"replayed": false,
"version": 1,
"appointment": { "id": "apt_abc123", "ref": "VRD-7QK2M9", "status": "completed", ... },
"lines": [ ...each line with the person it is now credited to... ],
"depositPaidCents": 2000,
"dueCents": 0,
"checkout": { "allowed": false, "blocked": "already_checked_out" },
"sale": {
"id": "sale_x1y2z3",
"receipt": "X1Y2Z3",
"at": "2026-06-10T22:12:00.000Z",
"source": "moneris_mgr",
"externalSaleId": "MGR-000123",
"method": "Card",
"tender": "card",
"collectedCents": 3712,
"saleTotalCents": 3712,
"tipCents": 500,
"products": []
}
}
// The same externalSaleId posted again: the same answer with "replayed": true,
// and nothing written. A refusal is { ok: false, error, reason, ... }.Request fields
| Field | Type | In | Required | Notes |
|---|---|---|---|---|
| id | string | path | Yes | The appointment id. For a visit of several services, any of its services: the whole visit is checked out. |
| externalSaleId | string | body | Yes | Your till's own id for the sale, 1 to 100 printable characters without spaces. The idempotency key: unique per business and till, so it can close one booking only. |
| source | string | body | No | Which till took it, e.g. "moneris_mgr": lower-case letters, digits, dot, dash or underscore, up to 40. Defaults to "external". "bookatu" is Bookatu's own till and is refused. |
| amountCents | number | body | Yes | What you took for the booking, in whole cents, tip excluded: the checkout's dueCents (0 when the deposit covered it all). |
| currency | string | body | No | ISO 4217 code; when given it must be the business's currency. |
| tender | string | body | Yes | How it was paid: card, cash or other. Recorded as the till records its own (Card, Cash, Other). |
| tipCents | number | body | No | The tip, in whole cents, beside the amount and never inside it. Omit or 0 for none. |
| tipSplit | object[] | body | No | Who got the tip: [{ staffId, amountCents }], adding up to tipCents, each person once and on the business's team. Omit to split it by the business's own rule. |
| lines | object[] | body | No | Who did a line, when it was not the booking's person: [{ lineId, staffId }], lineId from the checkout payload, staffId one of the business's active team (GET /staff). Lines not named stay credited as booked. |
Errors
| Status | When |
|---|---|
| 400 | The request body is not valid JSON. |
| 401 | The API key is missing or invalid. |
| 403 | The key belongs to a different organisation, or it does not carry the checkout:write scope (the body names it as missingScope). Only a key made with checkout:write ticked carries it: a key made before scopes never does, whatever its access level. |
| 404 | No business exists for the {slug}, or no appointment with that id in this business. |
| 409 | Nothing was recorded, and reason says why: amount_mismatch (amountCents is not what the booking comes to; expectedCents and currency say what it does), already_checked_out (another sale closed it; saleId names it), external_sale_used (that externalSaleId from this till is already another booking's sale), lines_changed (a lineId is not one of the booking's lines: it changed since you read it), not_live (finished, cancelled or a no-show) or deposit_pending (the client is still paying an online deposit). |
| 422 | Nothing was recorded: a field is malformed (fieldErrors names each; a staffId in lines that is not on the team is one), the currency is not the business's (reason currency), or the tip or its split does not add up (reason tip or tip_split). |
| 503 | Checking bookings out from another till is still being switched on for this business (its database is being updated). Nothing was recorded; retry shortly with the same externalSaleId. |
| 500 | Unexpected server error (details are never leaked). |
POST /appointments/{id}/deposit-request
Requires API keyNeeds the bookings:write scope.
POST /api/v1/{slug}/appointments/{id}/deposit-requestSend the deposit pay link again
For a booking the business made (by phone or walk-in, or POST /appointments with requestDeposit) that is waiting on the deposit it asked the client for: send the client the pay link again. The Stripe Checkout before is closed first, so a client holding two links can never pay twice, and the new link is open for a day. Paying is marked by Stripe's webhook, exactly as an online deposit, and the deposit then comes off the bill at checkout. One audit row names the key. Requires an API key with bookings:write.
curl -X POST https://bookatu.com/api/v1/your-salon/appointments/apt_abc123/deposit-request \ -H "Authorization: Bearer bk_live_YOUR_KEY"
Response example
{
"ok": true,
"appointmentId": "apt_abc123",
"sent": true,
"amountCents": 2000,
"emailedTo": "jane@example.com",
"texted": false,
"expiresAt": "2026-06-02T03:12:00.000Z"
}Parameters
| Field | Type | In | Required | Notes |
|---|---|---|---|---|
| id | string | path | Yes | The booking waiting on its deposit. |
Errors
| Status | When |
|---|---|
| 401 | The API key is missing or invalid. |
| 403 | The key belongs to a different organisation, or it lacks the bookings:write scope (the body names it as missingScope), or its maker can no longer do what the scope needs. |
| 404 | No business exists for the {slug}, or no booking with that id in this business. |
| 409 | The booking is not waiting on a deposit the business asked for (reason not_waiting), the client already paid it (paid), the client has no email (no_email), or the business cannot take card payments now (unavailable). |
| 502 | Stripe or the email did not answer (reason stripe or not_sent). Try again in a moment. |
| 500 | Unexpected server error (details are never leaked). |
POST /appointments/{id}/deposit-request/cancel
Requires API keyNeeds the bookings:write scope.
POST /api/v1/{slug}/appointments/{id}/deposit-request/cancelStop asking for the deposit
The client will pay in person after all: the pay link stops working and the booking no longer waits on a deposit, so it can be edited and checked out. Refused once the client has paid it. The booking itself is never cancelled. One audit row names the key. Requires an API key with bookings:write.
curl -X POST https://bookatu.com/api/v1/your-salon/appointments/apt_abc123/deposit-request/cancel \ -H "Authorization: Bearer bk_live_YOUR_KEY"
Response example
{ "ok": true, "appointmentId": "apt_abc123" }Parameters
| Field | Type | In | Required | Notes |
|---|---|---|---|---|
| id | string | path | Yes | The booking waiting on its deposit. |
Errors
| Status | When |
|---|---|
| 401 | The API key is missing or invalid. |
| 403 | The key belongs to a different organisation, or it lacks the bookings:write scope (the body names it as missingScope), or its maker can no longer do what the scope needs. |
| 404 | No business exists for the {slug}, or no booking with that id in this business. |
| 409 | The client already paid it (reason paid), or the booking is not waiting on a deposit (not_waiting). |
| 502 | Stripe did not answer, so the link is still open (reason stripe). Try again in a moment. |
| 500 | Unexpected server error (details are never leaked). |
GET /booking-requests
Requires API keyNeeds the bookings:read scope.
GET /api/v1/{slug}/booking-requestsList booking requests waiting for an answer
At a business that approves online bookings before they are confirmed: the requests that can be answered now, oldest first. kind booking is a client's new booking, pending and holding its time; kind move is a client's move of a confirmed booking, which keeps its own time until the move is accepted (requestedStartAt, requestedStaffName). deadline is when it is declined for the business if nobody answers. A request whose deposit is still being paid is not listed until it is. A visit of several services is one request, under its first service. The client comes by id; customer (their name, email and phone) only for a key that also has customers:read. A request for a service that needs two or more kinds of resource at once also has resources: every room, chair or machine it holds, { id, name, kind }, its own room first, as on GET /appointments. Requires an API key with bookings:read.
curl https://bookatu.com/api/v1/your-salon/booking-requests \ -H "Authorization: Bearer bk_live_YOUR_KEY"
Response example
{
"requests": [
{
"id": "apt_abc123",
"ref": "VRD-7QK2M9",
"kind": "booking",
"customerId": "cus_xyz",
"serviceName": "Gel Manicure",
"staffId": "stf_abc",
"staffName": "Anna",
"startAt": "2026-06-10T21:00:00.000Z",
"durationMin": 60,
"requestedStartAt": null,
"requestedStaffName": null,
"requestedAt": "2026-06-08T02:00:00.000Z",
"deadline": "2026-06-10T02:00:00.000Z",
"depositPaidCents": 0,
"priceCents": 7500,
"notes": null
}
]
}Errors
| Status | When |
|---|---|
| 401 | The API key is missing or invalid. |
| 403 | The key belongs to a different organisation, or it lacks the bookings:read scope (the body names it as missingScope), or its maker can no longer do what the scope needs. |
| 404 | No business exists for the {slug}. |
| 500 | Unexpected server error (details are never leaked). |
POST /booking-requests/{id}/accept
Requires API keyNeeds the bookings:write scope.
POST /api/v1/{slug}/booking-requests/{id}/acceptAccept a booking request
The booking is confirmed, with everything a confirmed booking gets (the client's confirmation, the calendar entry), or the client's move is carried out after its time is checked again, exactly as Accept on the Requests list. A request is answered once: a second answer, from anyone, is 409. One audit row names the key. Requires an API key with bookings:write.
curl -X POST https://bookatu.com/api/v1/your-salon/booking-requests/apt_abc123/accept \ -H "Authorization: Bearer bk_live_YOUR_KEY"
Response example
{ "ok": true, "id": "apt_abc123", "kind": "booking", "status": "confirmed" }Parameters
| Field | Type | In | Required | Notes |
|---|---|---|---|---|
| id | string | path | Yes | The request's id (from GET /booking-requests). |
Errors
| Status | When |
|---|---|
| 401 | The API key is missing or invalid. |
| 403 | The key belongs to a different organisation, or it lacks the bookings:write scope (the body names it as missingScope), or its maker can no longer do what the scope needs. |
| 404 | No business exists for the {slug}, or no request with that id in this business. |
| 409 | It has already been answered, its deposit is still being paid, its time has passed (it can only be declined), or the time a move asked for is no longer free. |
| 500 | Unexpected server error (details are never leaked). |
POST /booking-requests/{id}/decline
Requires API keyNeeds the bookings:write scope.
POST /api/v1/{slug}/booking-requests/{id}/declineDecline a booking request
A booking is cancelled as the business (any deposit refunded, the time freed for the waitlist); a move is dropped and the booking keeps its time. Either way the client is emailed, with the note when one is given. Answered once. One audit row names the key. Requires an API key with bookings:write.
curl -X POST https://bookatu.com/api/v1/your-salon/booking-requests/apt_abc123/decline \
-H "Authorization: Bearer bk_live_YOUR_KEY" \
-H "Content-Type: application/json" \
-d '{ "reason": "We are closed that day, sorry." }'Response example
{ "ok": true, "id": "apt_abc123", "kind": "booking", "status": "cancelled" }Request fields
| Field | Type | In | Required | Notes |
|---|---|---|---|---|
| id | string | path | Yes | The request's id (from GET /booking-requests). |
| reason | string | body | No | A note for the client, up to 500 characters. |
Errors
| Status | When |
|---|---|
| 401 | The API key is missing or invalid. |
| 403 | The key belongs to a different organisation, or it lacks the bookings:write scope (the body names it as missingScope), or its maker can no longer do what the scope needs. |
| 404 | No business exists for the {slug}, or no request with that id in this business. |
| 409 | It has already been answered, or its deposit is still being paid. |
| 422 | reason is longer than 500 characters (fieldErrors). |
| 500 | Unexpected server error (details are never leaked). |
GET /class-sessions
Requires API keyNeeds the bookings:read scope.
GET /api/v1/{slug}/class-sessionsList class sessions
The business's class sessions, soonest first, with how full each is. By default the scheduled sessions still to start, up to 100. A business that does not run classes lists none. Requires an API key with bookings:read.
curl "https://bookatu.com/api/v1/your-salon/class-sessions?from=2026-06-01&to=2026-07-01" \ -H "Authorization: Bearer bk_live_YOUR_KEY"
Response example
{
"sessions": [
{
"id": "cls_abc123",
"serviceId": "svc_yoga",
"className": "Sunrise yoga",
"staffId": "stf_abc",
"resourceId": null,
"startAt": "2026-06-10T18:00:00.000Z",
"endAt": "2026-06-10T19:00:00.000Z",
"capacity": 12,
"enrolledCount": 9,
"spotsLeft": 3,
"priceCents": 2000,
"status": "scheduled",
"seriesId": null
}
]
}Parameters
| Field | Type | In | Required | Notes |
|---|---|---|---|---|
| from | string | query | No | Start of the window: YYYY-MM-DD (the start of that day in the business's timezone) or ISO 8601. Defaults to now when to is not given. |
| to | string | query | No | End of the window, exclusive: YYYY-MM-DD (the start of that day in the business's timezone) or ISO 8601. |
| serviceId | string | query | No | One class (a group service of this business); another id is 404. |
| status | string | query | No | scheduled (the default), cancelled or all. |
| limit | number | query | No | 1 to 200, default 100. |
Errors
| Status | When |
|---|---|
| 401 | The API key is missing or invalid. |
| 403 | The key belongs to a different organisation, or it lacks the bookings:read scope (the body names it as missingScope), or its maker can no longer do what the scope needs. |
| 404 | No business exists for the {slug}, or serviceId is not one of its classes. |
| 422 | from, to, status or limit is malformed (fieldErrors). |
| 500 | Unexpected server error (details are never leaked). |
GET /class-sessions/{id}/roster
Requires API keyNeeds the bookings:read scope.
GET /api/v1/{slug}/class-sessions/{id}/rosterWho holds a place on a class session
The session, and everyone holding a live place on it, in sign-up order: each place's enrolmentId, the client's id, its status (booked, attended, no_show) and how it was paid (payment: paid_online, checkout_open, pay_at_door, free, refunded or forfeited). A cancelled place is off the roster. The client comes by id; customer (name, email, phone) only for a key that also has customers:read. Requires an API key with bookings:read.
curl https://bookatu.com/api/v1/your-salon/class-sessions/cls_abc123/roster \ -H "Authorization: Bearer bk_live_YOUR_KEY"
Response example
{
"session": {
"id": "cls_abc123",
"serviceId": "svc_yoga",
"className": "Sunrise yoga",
"staffId": "stf_abc",
"resourceId": null,
"startAt": "2026-06-10T18:00:00.000Z",
"endAt": "2026-06-10T19:00:00.000Z",
"capacity": 12,
"enrolledCount": 9,
"spotsLeft": 3,
"priceCents": 2000,
"status": "scheduled",
"seriesId": null
},
"roster": [
{
"enrolmentId": "enr_abc123",
"customerId": "cus_xyz",
"status": "booked",
"payment": "paid_online",
"depositStatus": "paid",
"depositPaidCents": 2000,
"priceCents": 2000,
"source": "online",
"createdAt": "2026-06-01T03:12:00.000Z"
}
]
}Parameters
| Field | Type | In | Required | Notes |
|---|---|---|---|---|
| id | string | path | Yes | The session's id (from GET /class-sessions). |
Errors
| Status | When |
|---|---|
| 401 | The API key is missing or invalid. |
| 403 | The key belongs to a different organisation, or it lacks the bookings:read scope (the body names it as missingScope), or its maker can no longer do what the scope needs. |
| 404 | No business exists for the {slug}, or no class session with that id in this business. |
| 500 | Unexpected server error (details are never leaked). |
POST /class-sessions/{id}/roster
Requires API keyNeeds the bookings:write scope.
POST /api/v1/{slug}/class-sessions/{id}/rosterPut a phone or walk-in client on a class session
Through the roster's own code, as Add on the session page: the client already on file is matched by email (then phone), or added; they are never opted in to marketing through the API (record their choice on their profile); nothing is taken, so the place is paid at the door; their ticket is emailed when they have an email and the session is still to come. A full session stays full: 409 with the spots left, and nothing is written. One audit row names the key. Requires an API key with bookings:write.
curl -X POST https://bookatu.com/api/v1/your-salon/class-sessions/cls_abc123/roster \
-H "Authorization: Bearer bk_live_YOUR_KEY" \
-H "Content-Type: application/json" \
-d '{ "name": "Pat Visitor", "email": "pat@example.com", "source": "phone" }'Response example
{
"ok": true,
"sessionId": "cls_abc123",
"enrolmentId": "enr_def456",
"customerId": "cus_pat",
"name": "Pat Visitor",
"matched": false,
"emailed": true
}
// matched is true when an existing client was found by email or phone (their
// name on file is kept); emailed says whether their ticket went out.Request fields
| Field | Type | In | Required | Notes |
|---|---|---|---|---|
| id | string | path | Yes | The session's id. |
| name | string | body | Yes | The client's name, 2 to 80 characters. |
| string | body | No | Their email, for their ticket and to match them. | |
| phone | string | body | No | Their phone number, to match them. |
| source | string | body | No | phone (the default) or walk_in. |
Errors
| Status | When |
|---|---|
| 401 | The API key is missing or invalid. |
| 403 | The key belongs to a different organisation, or it lacks the bookings:write scope (the body names it as missingScope), or its maker can no longer do what the scope needs. |
| 400 | The request body is not valid JSON. |
| 404 | No business exists for the {slug}, or no class session with that id in this business. |
| 409 | The session is full or cancelled, or they already hold a place on it. Nothing was written. |
| 422 | The details are malformed (fieldErrors names each: name, email, phone, source). |
| 500 | Unexpected server error (details are never leaked). |
POST /class-sessions/{id}/roster/{enrolmentId}/cancel
Requires API keyNeeds the bookings:write scope.
POST /api/v1/{slug}/class-sessions/{id}/roster/{enrolmentId}/cancelCancel one person's place on a class session
Their spot is freed, what they paid comes back as account credit (creditedCents), and they are emailed, exactly as Remove on the roster and as cancelling the whole session does for each person. A place marked attended or a no-show is refused until the mark is undone. One audit row names the key. Requires an API key with bookings:write.
curl -X POST https://bookatu.com/api/v1/your-salon/class-sessions/cls_abc123/roster/enr_abc123/cancel \ -H "Authorization: Bearer bk_live_YOUR_KEY"
Response example
{ "ok": true, "sessionId": "cls_abc123", "enrolmentId": "enr_abc123", "creditedCents": 2000 }Parameters
| Field | Type | In | Required | Notes |
|---|---|---|---|---|
| id | string | path | Yes | The session's id. |
| enrolmentId | string | path | Yes | The place (from the roster). |
Errors
| Status | When |
|---|---|
| 401 | The API key is missing or invalid. |
| 403 | The key belongs to a different organisation, or it lacks the bookings:write scope (the body names it as missingScope), or its maker can no longer do what the scope needs. |
| 404 | No business exists for the {slug}, no class session with that id, or that place is not on this session. |
| 409 | The place is marked attended or a no-show (undo the mark first), or was already taken off. |
| 500 | Unexpected server error (details are never leaked). |
POST /class-sessions/{id}/roster/{enrolmentId}/attendance
Requires API keyNeeds the bookings:write scope.
POST /api/v1/{slug}/class-sessions/{id}/roster/{enrolmentId}/attendanceMark who came to a class session
attended is the very state the ticket scan at the door sets; no_show records that they did not come; booked undoes a mark. The headcount, what they paid and their client record are untouched. The same mark again changes nothing (changed: false). A change is one audit row naming the key. Requires an API key with bookings:write.
curl -X POST https://bookatu.com/api/v1/your-salon/class-sessions/cls_abc123/roster/enr_abc123/attendance \
-H "Authorization: Bearer bk_live_YOUR_KEY" \
-H "Content-Type: application/json" \
-d '{ "mark": "attended" }'Response example
{ "ok": true, "sessionId": "cls_abc123", "enrolmentId": "enr_abc123", "status": "attended", "changed": true }Request fields
| Field | Type | In | Required | Notes |
|---|---|---|---|---|
| id | string | path | Yes | The session's id. |
| enrolmentId | string | path | Yes | The place (from the roster). |
| mark | string | body | Yes | attended, no_show or booked. |
Errors
| Status | When |
|---|---|
| 401 | The API key is missing or invalid. |
| 403 | The key belongs to a different organisation, or it lacks the bookings:write scope (the body names it as missingScope), or its maker can no longer do what the scope needs. |
| 400 | The request body is not valid JSON. |
| 404 | No business exists for the {slug}, no class session with that id, or that place is not on this session. |
| 409 | The place was cancelled, so it cannot be marked. |
| 422 | mark is not attended, no_show or booked (fieldErrors). |
| 500 | Unexpected server error (details are never leaked). |
GET /reports/earnings
Requires API keyNeeds the reports:read scope.
GET /api/v1/{slug}/reports/earningsStaff earnings and commission
What Reports > Staff earnings shows, as JSON: each person's completed visits and the products they sold, their commission under the business's rules on the day of each (tiered rules counted from the start of the pay period), refunds and voids taking commission back, and tips; in total, by period and line by line (up to 500 lines; linesTruncated says when there are more, and the totals always cover them all). Booth renters earn no commission and are listed apart (boothRenter), with employeeTotals and renterTotals beside the totals. Clients are never named. Reads only. Requires an API key with reports:read (the scope needs See analytics & earnings).
curl "https://bookatu.com/api/v1/your-salon/reports/earnings?range=last_month&by=week" \ -H "Authorization: Bearer bk_live_YOUR_KEY"
Response example
{
"query": { "range": "last_month", "from": "2026-05-01", "to": "2026-05-31", "groupBy": "week", "staffId": null, "label": "Last month" },
"currency": "CAD",
"totals": { "visits": 41, "serviceSalesCents": 312000, "serviceCommissionCents": 124800, "productUnits": 9, "productSalesCents": 27000, "productCommissionCents": 2700, "refundsSalesCents": -4000, "refundsCommissionCents": -1600, "commissionCents": 125900, "tipsCents": 18500 },
"hasBoothRenters": false,
"employeeTotals": { ... },
"renterTotals": { ... },
"hasRules": true,
"people": [{ "staffId": "stf_abc", "name": "Anna", "boothRenter": false, "visits": 23, "commissionCents": 70100, ... }],
"periods": [{ "key": "2026-04-27", "label": "27 Apr", "from": "2026-04-27", "to": "2026-05-03", "totals": { ... }, "people": [ ... ] }],
"lines": [{ "id": "visit:apt_abc123", "source": "visit", "at": "2026-05-02T21:00:00.000Z", "staffId": "stf_abc", "appliesTo": "service", "label": "Cut + Head massage", "ref": "VRD-7QK2M9", "receipt": "X1Y2Z3", "appointmentId": "apt_abc123", "saleId": "sale_x1y2z3", "category": "Hair", "units": 1, "salesCents": 5100, "commissionCents": 2040, "tipCents": 0, "ruleId": "crl_abc", "boothRenter": false }],
"linesTruncated": false
}Parameters
| Field | Type | In | Required | Notes |
|---|---|---|---|---|
| range | string | query | No | this_week, last_week, this_month, last_month, 30d, 90d, year; custom with from and to. Defaults to this_month. Days are the business's own (its timezone). |
| from | string | query | No | First day, YYYY-MM-DD, for range=custom. |
| to | string | query | No | Last day, YYYY-MM-DD, for range=custom (included). |
| by | string | query | No | day, week (from Monday) or month: the periods to total by. Defaults to week. |
| staff | string | query | No | One person's id (GET /staff, or anyone ever on the team). Someone not on this business's team is 404. |
Errors
| Status | When |
|---|---|
| 401 | The API key is missing or invalid. |
| 403 | The key belongs to a different organisation, or it lacks the reports:read scope (the body names it as missingScope), or its maker can no longer do what the scope needs. |
| 404 | No business exists for the {slug}, or staff is not one of its team. |
| 500 | Unexpected server error (details are never leaked). |
GET /reports/tips
Requires API keyNeeds the reports:read scope.
GET /api/v1/{slug}/reports/tipsTips
What Reports > Tips shows, as JSON: the tips taken at checkout by person and by period, each row of the record (who got what, by which rule, and any adjustment and its reason), and each tipped sale with how it is split now. config says whether the business takes tips and its rule. Up to 500 entries and sales (entriesTruncated). Reads only. Requires an API key with reports:read.
curl "https://bookatu.com/api/v1/your-salon/reports/tips?range=this_month" \ -H "Authorization: Bearer bk_live_YOUR_KEY"
Response example
{
"query": { "range": "this_month", "from": "2026-06-01", "to": "2026-06-30", "groupBy": "week", "staffId": null, "label": "This month" },
"currency": "CAD",
"config": { "enabled": true, "rule": "service_share", "presets": [1000, 1500, 2000] },
"totalCents": 18500,
"people": [{ "staffId": "stf_abc", "name": "Anna", "tipsCents": 11000 }],
"periods": [{ "key": "2026-06-01", "label": "1 Jun", "from": "2026-06-01", "to": "2026-06-07", "totalCents": 6000, "people": [ ... ] }],
"entries": [{ "id": "tal_abc", "saleId": "sale_x1y2z3", "receipt": "X1Y2Z3", "staffId": "stf_abc", "name": "Anna", "amountCents": 500, "rule": "service_share", "adjustmentId": null, "reason": null, "at": "2026-06-02T22:12:00.000Z", "method": "Card", "bookingRef": "VRD-7QK2M9", "appointmentId": "apt_abc123" }],
"entriesTruncated": false,
"sales": [{ "saleId": "sale_x1y2z3", "receipt": "X1Y2Z3", "at": "2026-06-02T22:12:00.000Z", "method": "Card", "tipCents": 500, "appointmentId": "apt_abc123", "bookingRef": "VRD-7QK2M9", "current": [{ "staffId": "stf_abc", "name": "Anna", "amountCents": 500 }], "rule": "service_share", "adjusted": false }]
}Parameters
| Field | Type | In | Required | Notes |
|---|---|---|---|---|
| range | string | query | No | this_week, last_week, this_month, last_month, 30d, 90d, year; custom with from and to. Defaults to this_month. Days are the business's own (its timezone). |
| from | string | query | No | First day, YYYY-MM-DD, for range=custom. |
| to | string | query | No | Last day, YYYY-MM-DD, for range=custom (included). |
| by | string | query | No | day, week (from Monday) or month: the periods to total by. Defaults to week. |
| staff | string | query | No | One person's id. Someone not on this business's team is 404. |
Errors
| Status | When |
|---|---|
| 401 | The API key is missing or invalid. |
| 403 | The key belongs to a different organisation, or it lacks the reports:read scope (the body names it as missingScope), or its maker can no longer do what the scope needs. |
| 404 | No business exists for the {slug}, or staff is not one of its team. |
| 500 | Unexpected server error (details are never leaked). |
GET /reports/deposits
Requires API keyNeeds the reports:read scope.
GET /api/v1/{slug}/reports/depositsDeposit reconciliation
What Reports > Deposits shows, as JSON: each deposit taken (online when the client booked, by a pay link the business sent, or from account credit) and what became of it: taken off the bill at checkout (applied), kept for a no-show or late cancellation (kept), refunded to the card or as credit, still held for a visit to come (held), or still held on a booking that is over (unresolved, worth a look); with Stripe's own record beside each (stripeCheck) and in total. Every total balances because every row does. Up to 500 rows (rowsTruncated). The client is not named. Reads only. Requires an API key with reports:read.
curl "https://bookatu.com/api/v1/your-salon/reports/deposits?range=month&basis=taken" \ -H "Authorization: Bearer bk_live_YOUR_KEY"
Response example
{
"query": { "range": "month", "basis": "taken", "groupBy": "month", "from": "2026-06-01", "to": "2026-06-30", "label": "This month" },
"currency": "CAD",
"totals": { "bookings": 12, "takenCents": 24000, "takenOnlineCents": 20000, "takenPayLinkCents": 2000, "takenCreditCents": 2000, "appliedCents": 16000, "keptCents": 2000, "refundedCents": 2000, "heldCents": 4000, "unresolvedCents": 0, "refundUnconfirmedCents": 0 },
"periods": [{ "key": "2026-06", "label": "Jun 2026", "fromKey": "2026-06-01", "toKey": "2026-06-30", "bookings": 12, ... }],
"rows": [{ "appointmentId": "apt_abc123", "ref": "VRD-7QK2M9", "serviceName": "Cut", "staffName": "Anna", "visitAt": "2026-06-10T21:00:00.000Z", "status": "completed", "depositStatus": "paid", "method": "online", "takenCents": 2000, "takenAt": "2026-06-01T03:12:00.000Z", "bookedCents": 2000, "stripeCents": 2000, "stripeRefundCents": 0, "stripeCheck": "matches", "appliedCents": 2000, "appliedAt": "2026-06-10T22:12:00.000Z", "receipt": "X1Y2Z3", "keptCents": 0, "keptReason": null, "keptAt": null, "refundedCents": 0, "refundedAt": null, "refundedTo": null, "refundUnconfirmedCents": 0, "heldCents": 0, "unresolvedCents": 0, "notes": [] }],
"rowsTruncated": false,
"stripe": { "bookedCardCents": 22000, "stripeCardCents": 22000, "stripeRefundCents": 2000, "checks": { "matches": 11, "not_card": 1, "missing": 0, "unrecorded": 0, "more": 0, "less": 0 }, "byDate": { ... } }
}Parameters
| Field | Type | In | Required | Notes |
|---|---|---|---|---|
| range | string | query | No | 30d, 90d, month, last_month, year, all; custom with from and to. Defaults to 90d. Days are the business's own (its timezone). |
| from | string | query | No | First day, YYYY-MM-DD, for range=custom. |
| to | string | query | No | Last day, YYYY-MM-DD, for range=custom (included). |
| by | string | query | No | day, week (from Monday) or month: the periods to total by. Defaults to month. |
| basis | string | query | No | taken (the default: by the day the deposit was taken) or visit (by the visit's day). |
Errors
| Status | When |
|---|---|
| 401 | The API key is missing or invalid. |
| 403 | The key belongs to a different organisation, or it lacks the reports:read scope (the body names it as missingScope), or its maker can no longer do what the scope needs. |
| 404 | No business exists for the {slug}. |
| 500 | Unexpected server error (details are never leaked). |
GET /audit-log
Requires API keyNeeds the audit:read scope, on a key made with it ticked.
GET /api/v1/{slug}/audit-logThe audit log
Who did what in the business, and when, newest first, as the Audit log page shows it: bookings, clients, money, loyalty and gift cards, classes, sign-ins, team access, keys, webhooks, settings and downloads. Each row names who acted (a team member with their email and role, an API key, a connected app, a client online, or Bookatu itself), what was done to which record, a summary, what changed (notes, secrets and bank details by name only), and where it came from for a team member, key or app. The client a row is about is named by id (clientId). Filters and pages as the page takes them. Reading it writes nothing. Requires an API key made with the audit:read scope ticked, by someone the business lets manage it (owners and admins on the standard grid); it stops working if they lose that right or leave.
curl "https://bookatu.com/api/v1/your-salon/audit-log?action=booking.*&from=2026-06-01" \ -H "Authorization: Bearer bk_live_YOUR_KEY"
Response example
{
"events": [
{
"id": "aud_abc123",
"at": "2026-06-02T09:30:00.000Z",
"action": "booking.cancelled",
"actionLabel": "Booking cancelled",
"actor": { "type": "user", "id": "usr_abc", "name": "Alex Owner", "email": "alex@example.com", "role": "owner", "roleLabel": "Owner" },
"target": { "type": "booking", "id": "apt_abc123" },
"clientId": "cus_xyz",
"summary": "VRD-7QK2M9 cancelled (Gel Manicure, Wed 10 Jun 2026, 9:00 am)",
"changes": [{ "field": "status", "to": "cancelled" }],
"ip": "203.0.113.7",
"userAgent": "Mozilla/5.0 ..."
}
],
"page": 1,
"pageSize": 50,
"hasMore": false,
"filters": { "action": "booking.*", "from": "2026-06-01" }
}Parameters
| Field | Type | In | Required | Notes |
|---|---|---|---|---|
| actor | string | query | No | user:<id>, api_key:<id>, app:<id>, client or system. |
| action | string | query | No | One action (booking.cancelled) or a whole area (booking.*). |
| target | string | query | No | What it was done to: booking, client, invoice, api_key, settings, class, ... |
| targetId | string | query | No | That record's id. |
| from | string | query | No | First day, YYYY-MM-DD, in the business's timezone. |
| to | string | query | No | Last day, YYYY-MM-DD, included. |
| page | number | query | No | Page number, from 1. |
| pageSize | number | query | No | Rows a page, 1 to 200 (default 50). hasMore says whether there is another page. |
Errors
| Status | When |
|---|---|
| 401 | The API key is missing or invalid. |
| 403 | The key belongs to a different organisation, or it does not carry the audit:read scope (the body names it as missingScope). Only a key made with audit:read ticked carries it: a key made before scopes never does, whatever its access level. |
| 404 | No business exists for the {slug}. |
| 500 | Unexpected server error (details are never leaked). |
GET /visits/availability
PublicGET /api/v1/{slug}/visits/availabilityOpen starts for a visit of several services
The starts a visit of several services fits (a colour, then a cut) across a date window: the booking page's own question, answered by the same code. The services run back to back: the first on the business's booking grid, each next one the moment the one before ends, each checked as a booking of it on its own would be (someone who does it and is free, its room, the hours and breaks, the business's buffer). Each slot lists every service with who does it and when; the same person carries on from one service to the next where they can. Only at a business that lets clients book several services in one visit (GET /business has visits); anywhere else it is a 409 with code not_offered. Book a returned startAt with POST /visits/book and the same serviceIds, staffId and addOnIds: a time offered here is a time it takes. Each time also comes as when: { utc, primary, business: { timeZone, local, utcOffset, abbr, display }, client } with the offset in force on that day (a slot after the clocks change carries the new one), on the business's clock (the branch's at a branch) and, when the client's zone is known, on theirs (client is null otherwise; dayDiffers says their date is another day). Quote display rather than converting a time yourself. No authentication required.
curl "https://bookatu.com/api/v1/your-salon/visits/availability?serviceIds=svc_colour,svc_cut&from=2026-06-10&to=2026-06-12"
Response example
{
"org": { "name": "Parnell Hair", "slug": "parnell-hair", "timezone": "Pacific/Auckland" },
"days": [
{
"date": "2026-06-10",
"open": true,
"slots": [
{
"startMin": 540,
"startAt": "2026-06-09T21:00:00.000Z",
"services": [
{ "serviceId": "svc_colour", "staffId": "stf_anna", "startAt": "2026-06-09T21:00:00.000Z", "endAt": "2026-06-09T22:30:00.000Z", "durationMin": 90 },
{ "serviceId": "svc_cut", "staffId": "stf_anna", "startAt": "2026-06-09T22:30:00.000Z", "endAt": "2026-06-09T23:00:00.000Z", "durationMin": 30 }
]
}
]
}
]
}
// startMin is minutes from local midnight; startAt is the visit's start, the
// UTC instant to book. A day shut or past the booking horizon reads exactly as
// it does on GET /availability.Parameters
| Field | Type | In | Required | Notes |
|---|---|---|---|---|
| serviceIds | string | query | Yes | Two to four different services from GET /services, in the order they are done (the first one first). Comma-separated. |
| 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 | The team member the client picked: they do every service they do, and the rest go to whoever is free. Omit for anyone. |
| addOnIds | string | query | No | Extras for the first service, comma-separated ids from its addOns on GET /services. One with addsTime makes it longer, and the next services start that much later. |
| clientTimeZone | string | query | No | The client's own time zone, the IANA zone of a place (America/Toronto): each slot's when then gives the time on their clock too. Abbreviations (EST, which would be read as Panama), offsets (+13:00) and Etc/ zones are refused with code UNKNOWN_TIME_ZONE (400): ask the person which city they are in. Never guessed from the request. |
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}. |
| 409 | Nothing to offer, and code says why: not_offered (the business books one service at a time online), or cant_combine: these services cannot be one visit, with reason (repeated, unavailable: not offered online, add_on: an extra, class, video, team: a service two or more people do at once) or, when it is one of the business's combination rules, error naming the two ("4 braids and 6 braids can't be booked together.") and combo { kind, first, second }. |
| 422 | serviceIds is not two to four services (fieldErrors.serviceIds), or more than 20 addOnIds. |
| 500 | Unexpected server error (details are never leaked). |
POST /visits/book
PublicPOST /api/v1/{slug}/visits/bookBook a visit of several services (online source)
The customer-facing call for a visit of several services, exactly as the booking page books one: the whole visit or none of it, in one transaction, each service an appointment of its own with its person, time and price, sharing one visitId. The client gets one confirmation listing every service, one discount shared across the services, and, at a business that approves online bookings, one request (approvalRequested). Only at a business that lets clients book several services in one visit (else 409 not_offered, nothing booked); the services must be ones it offers online, and its combination rules hold. Like POST /book it takes no key, so it holds to the same rules: startAt is read strictly (TIME_NEEDS_OFFSET, TIME_INVALID), an email match changes nothing, a marketing yes waits for the client's own confirmation, a visit that takes a deposit is refused with 409 and code DEPOSIT_NEEDS_BOOKING_PAGE with bookingPageUrl (nothing booked), a paused business answers BOOKING_PAUSED, and the same per-address and per-business caps apply. The appointmentId in the answer is the person's private key to their booking: never show it, store it or put it in a chat. No authentication required.
curl -X POST https://bookatu.com/api/v1/your-salon/visits/book \
-H "Content-Type: application/json" \
-d '{
"serviceIds": ["svc_colour", "svc_cut"],
"startAt": "2026-06-09T21:00:00.000Z",
"name": "Alex Smith",
"email": "alex@example.com",
"phone": "+64 21 555 0100"
}'Response example
{
"ok": true,
"visitId": "vis_abc123",
"ref": "VRD-7QK2M9",
"appointmentId": "apt_colour",
"status": "confirmed",
"needsPayment": false,
"services": [
{ "appointmentId": "apt_colour", "ref": "VRD-7QK2M9", "serviceId": "svc_colour", "serviceName": "Colour", "staffId": "stf_anna", "staffName": "Anna", "startAt": "2026-06-09T21:00:00.000Z", "endAt": "2026-06-09T22:30:00.000Z", "durationMin": 90, "priceCents": 12000, "status": "confirmed" },
{ "appointmentId": "apt_cut", "ref": "VRD-8RL3N4", "serviceId": "svc_cut", "serviceName": "Cut", "staffId": "stf_anna", "staffName": "Anna", "startAt": "2026-06-09T22:30:00.000Z", "endAt": "2026-06-09T23:00:00.000Z", "durationMin": 30, "priceCents": 3500, "status": "confirmed" }
]
}
// At a business that approves online bookings, status is "pending" and
// approvalRequested is true until it answers. appointmentId and ref are the
// visit's first service, which its confirmation and manage link speak through.
// A visit that takes a deposit is refused (409, code DEPOSIT_NEEDS_BOOKING_PAGE,
// with bookingPageUrl) and nothing is booked.Request fields
| Field | Type | In | Required | Notes |
|---|---|---|---|---|
| serviceIds | string[] | body | Yes | Two to four different services from GET /services, in the order they are done (the first one first). |
| startAt | string | body | Yes | When the first service starts, ISO 8601: a slot's startAt from GET /visits/availability. Each next service starts the moment the one before ends. |
| 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. |
| notes | string | body | No | Free text, max 500 characters, written on every service of the visit. |
| marketingConsent | object | body | No | Only when the customer agreed to get marketing email from this business: { source, wording }, as on POST /book. Omit when they did not: nobody is opted in by default. |
| staffId | string | body | No | The team member the client picked, as asked of GET /visits/availability: they do every service they do. Omit for anyone. |
| addOnIds | string[] | body | No | Extras for the first service (ids from its addOns), up to 20, held to the business's combination rules. |
| clientTimeZone | string | body | No | The client's own time zone, the IANA zone of a place (America/Toronto), kept on the booking so their emails and pages show their clock too; the answer's when gives the time on both clocks. Abbreviations (EST, which would be read as Panama), offsets (+13:00) and Etc/ zones are refused with code UNKNOWN_TIME_ZONE (422, nothing booked): ask the person which city they are in. Never guessed from the request. |
Errors
| Status | When |
|---|---|
| 400 | The request body is not valid JSON. |
| 404 | No business exists for the {slug}. |
| 409 | Nothing was booked, and code says why: not_offered (the business books one service at a time online), cant_combine: these services cannot be one visit, with reason (repeated, unavailable: not offered online, add_on: an extra, class, video, team: a service two or more people do at once) or, when it is one of the business's combination rules, error naming the two ("4 braids and 6 braids can't be booked together.") and combo { kind, first, second }, no_longer_fits (the whole visit does not fit at that start; line is the service that does not, 0 for the first), taken (someone took it in the moment since it was checked), DEPOSIT_NEEDS_BOOKING_PAGE (the visit takes a deposit, which only the booking page at bookingPageUrl can take) or BOOKING_PAUSED (the business is not taking online bookings right now). |
| 422 | Validation failed: fieldErrors names each field (serviceIds, startAt, name, email, phone, marketingConsent). A startAt with no offset has code TIME_NEEDS_OFFSET and an example in the business's zone; one that is not a real RFC 3339 date-time on a whole minute has code TIME_INVALID. |
| 429 | Too many booking attempts (the same caps as POST /book: per address, and per business a day). The response carries Retry-After. |
| 500 | Unexpected server error (details are never leaked). |
POST /visits
Requires API keyNeeds the bookings:write scope.
POST /api/v1/{slug}/visitsBook a visit of several services (the team's)
Book a visit of several services with the business's own key, as the team's New booking form does: each service with the person named for it (or whoever is free), back to back from startAt, whatever the business lets clients do online. The whole visit is booked or none of it, with one confirmation to the client; it may start off the booking grid and past the online booking horizon, but every service must fit at its time with its person, its room and its hours, and nobody is ever double-booked. When it does not fit, the 409 says which service (line), when and with whom. The business's combination rules hold, naming the two. A service of the visit that needs two or more kinds of resource at once also has resources: every room, chair or machine it holds, { id, name, kind }, its own room first. Requires an API key with bookings:write.
curl -X POST https://bookatu.com/api/v1/your-salon/visits \
-H "Authorization: Bearer bk_live_YOUR_KEY" \
-H "Content-Type: application/json" \
-d '{
"services": [
{ "serviceId": "svc_colour", "staffId": "stf_anna" },
{ "serviceId": "svc_cut", "staffId": "stf_sam" }
],
"startAt": "2026-06-09T21:00:00.000Z",
"name": "Alex Smith",
"email": "alex@example.com",
"phone": "+64 21 555 0100"
}'Response example
{
"ok": true,
"visitId": "vis_abc123",
"ref": "VRD-7QK2M9",
"appointmentId": "apt_colour",
"status": "confirmed",
"needsPayment": false,
"services": [
{ "appointmentId": "apt_colour", "ref": "VRD-7QK2M9", "serviceId": "svc_colour", "serviceName": "Colour", "staffId": "stf_anna", "staffName": "Anna", "startAt": "2026-06-09T21:00:00.000Z", "endAt": "2026-06-09T22:30:00.000Z", "durationMin": 90, "priceCents": 12000, "status": "confirmed" },
{ "appointmentId": "apt_cut", "ref": "VRD-8RL3N4", "serviceId": "svc_cut", "serviceName": "Cut", "staffId": "stf_anna", "staffName": "Anna", "startAt": "2026-06-09T22:30:00.000Z", "endAt": "2026-06-09T23:00:00.000Z", "durationMin": 30, "priceCents": 3500, "status": "confirmed" }
]
}
// The same shape as POST /visits/book. A team booking takes no online deposit.Request fields
| Field | Type | In | Required | Notes |
|---|---|---|---|---|
| services | object[] | body | Yes | Two to four different services, in the order they are done: [{ serviceId, staffId?, addOnIds? }]. staffId is the team member who does that service (from GET /staff; omit for whoever is free); addOnIds its extras (ids from its addOns on GET /services). |
| startAt | string | body | No | An instant with its offset (RFC 3339), such as a startAt from GET /availability ("2026-06-10T09:00:00Z"), in whole minutes. A time with no offset is a 422 TIME_NEEDS_OFFSET with an example on the business's clock; a date that does not exist or seconds other than :00 are TIME_INVALID. Send startAt or localTime, exactly one. |
| 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. |
| notes | string | body | No | Free text, max 500 characters, written on every service of the visit. |
| marketingConsent | object | body | No | Only when the customer agreed to get marketing email from this business: { source, wording }, as on POST /book. Omit when they did not: nobody is opted in by default. |
| localTime | string | body | No | Instead of startAt: a wall-clock time with no offset ("2026-11-04T09:00"), read on the business's clock (the branch's, at a branch) or on timeZone. A time the clocks skip that day is a 422 TIME_DOES_NOT_EXIST with nextValid, one they show twice a 422 TIME_AMBIGUOUS with both candidates (as When objects): resend startAt with the offset meant. |
| timeZone | string | body | No | With localTime only: the IANA zone of a place it is on (America/Toronto). Abbreviations, offsets and Etc/ zones are a 422 UNKNOWN_TIME_ZONE. |
Errors
| Status | When |
|---|---|
| 400 | The request body is not valid JSON. |
| 401 | The API key is missing or invalid. |
| 403 | The key belongs to a different organisation, or it lacks the bookings:write scope (the body names it as missingScope), or its maker can no longer do what the scope needs. A key made before scopes that is read-only is refused too. |
| 404 | No business exists for the {slug}. |
| 409 | Nothing was booked, and code says why: cant_combine: these services cannot be one visit, with reason (repeated, unavailable: not offered online, add_on: an extra, class, video, team: a service two or more people do at once) or, when it is one of the business's combination rules, error naming the two ("4 braids and 6 braids can't be booked together.") and combo { kind, first, second }, no_longer_fits (line is the service that does not fit, and error says when and with whom, e.g. "Anna isn't free for Cut at 10:30.") or taken. |
| 422 | Validation failed: fieldErrors names each field (services, startAt, name, email, phone, marketingConsent). |
| 500 | Unexpected server error (details are never leaked). |
GET /visits/{id}
Requires API keyNeeds the bookings:read scope.
GET /api/v1/{slug}/visits/{id}Get one visit
One visit of several services, scoped to the key's business: its services in order, each with its booking (appointmentId, ref), who does it, when, its price and status, and the visit's start, end, length and price (of the services not cancelled). appointmentId and ref are the service the visit speaks through: its first, or its first still on. A booking's visitId (GET /appointments) names its visit. The client comes by id. A service of the visit that needs two or more kinds of resource at once also has resources: every room, chair or machine it holds, { id, name, kind }, its own room first. Requires an API key with bookings:read.
curl https://bookatu.com/api/v1/your-salon/visits/vis_abc123 \ -H "Authorization: Bearer bk_live_YOUR_KEY"
Response example
{
"id": "vis_abc123",
"appointmentId": "apt_colour",
"ref": "VRD-7QK2M9",
"status": "confirmed",
"customerId": "cus_xyz",
"startAt": "2026-06-09T21:00:00.000Z",
"endAt": "2026-06-09T23:00:00.000Z",
"durationMin": 120,
"priceCents": 15500,
"services": [
{ "appointmentId": "apt_colour", "ref": "VRD-7QK2M9", "serviceId": "svc_colour", "serviceName": "Colour", "staffId": "stf_anna", "staffName": "Anna", "startAt": "2026-06-09T21:00:00.000Z", "endAt": "2026-06-09T22:30:00.000Z", "durationMin": 90, "priceCents": 12000, "status": "confirmed" },
{ "appointmentId": "apt_cut", "ref": "VRD-8RL3N4", "serviceId": "svc_cut", "serviceName": "Cut", "staffId": "stf_anna", "staffName": "Anna", "startAt": "2026-06-09T22:30:00.000Z", "endAt": "2026-06-09T23:00:00.000Z", "durationMin": 30, "priceCents": 3500, "status": "confirmed" }
]
}Parameters
| Field | Type | In | Required | Notes |
|---|---|---|---|---|
| id | string | path | Yes | The visit id (vis_…): a booking's visitId, or POST /visits's. |
Errors
| Status | When |
|---|---|
| 401 | The API key is missing or invalid. |
| 403 | The key belongs to a different organisation, or it lacks the bookings:read scope (the body names it as missingScope), or its maker can no longer do what the scope needs. |
| 404 | No business exists for the {slug}, or no visit with that id in this business (the same 404 either way). |
| 500 | Unexpected server error (details are never leaked). |
POST /visits/{id}/reschedule
Requires API keyNeeds the bookings:write scope.
POST /api/v1/{slug}/visits/{id}/rescheduleMove a whole visit
Move a visit of several services so it starts at startAt: every service keeps its person, its length and its place in the order, and is checked again at its new time as the team's own calendar checks it (it may start off the booking grid and past the online horizon, but it must fit with every person, room and the hours). All of them move or none does; nothing is ever double-booked. The client is told once, the reminders follow the new times, each old time is offered to the waitlist, and each service fires booking.rescheduled. The answer is the visit as GET /visits/{id} now reads it, with the resources each service needing two or more kinds now holds, and when, its new start as a When. Requires an API key with bookings:write.
curl -X POST https://bookatu.com/api/v1/your-salon/visits/vis_abc123/reschedule \
-H "Authorization: Bearer bk_live_YOUR_KEY" \
-H "Content-Type: application/json" \
-d '{ "startAt": "2026-06-11T01:00:00.000Z" }'Response example
{
"ok": true,
"visit": { "id": "vis_abc123", "startAt": "2026-06-11T01:00:00.000Z", "endAt": "2026-06-11T03:00:00.000Z", "services": [ ... ] }
}Request fields
| Field | Type | In | Required | Notes |
|---|---|---|---|---|
| id | string | path | Yes | The visit id. |
| startAt | string | body | No | An instant with its offset (RFC 3339), such as a startAt from GET /availability ("2026-06-10T09:00:00Z"), in whole minutes. A time with no offset is a 422 TIME_NEEDS_OFFSET with an example on the business's clock; a date that does not exist or seconds other than :00 are TIME_INVALID. Send startAt or localTime, exactly one. |
| localTime | string | body | No | Instead of startAt: a wall-clock time with no offset ("2026-11-04T09:00"), read on the business's clock (the branch's, at a branch) or on timeZone. A time the clocks skip that day is a 422 TIME_DOES_NOT_EXIST with nextValid, one they show twice a 422 TIME_AMBIGUOUS with both candidates (as When objects): resend startAt with the offset meant. |
| timeZone | string | body | No | With localTime only: the IANA zone of a place it is on (America/Toronto). Abbreviations, offsets and Etc/ zones are a 422 UNKNOWN_TIME_ZONE. |
Errors
| Status | When |
|---|---|
| 400 | The request body is not valid JSON. |
| 401 | The API key is missing or invalid. |
| 403 | The key belongs to a different organisation, or it lacks the bookings:write scope (the body names it as missingScope), or its maker can no longer do what the scope needs. A key made before scopes that is read-only is refused too. |
| 404 | No business exists for the {slug}, or no visit with that id in this business. |
| 409 | The whole visit does not fit at that start (error says so, and nothing moved), or every service of it is cancelled, finished or a no-show. |
| 422 | startAt is missing, is not a parseable ISO 8601 date-time, or is in the past (fieldErrors.startAt). |
| 500 | Unexpected server error (details are never leaked). |
POST /visits/{id}/cancel
Requires API keyNeeds the bookings:write scope.
POST /api/v1/{slug}/visits/{id}/cancelCancel a whole visit
Cancel every service of a visit still on, as the business. Its first service is cancelled as any booking is (the deposit it holds refunded or kept by the business's notice policy: forfeited says which), the rest go with it, the client is told, each freed time is offered to the waitlist and each service fires booking.cancelled. cancelled is how many services were cancelled. Cancelling a visit already cancelled is idempotent (alreadyCancelled: true). Requires an API key with bookings:write.
curl -X POST https://bookatu.com/api/v1/your-salon/visits/vis_abc123/cancel \
-H "Authorization: Bearer bk_live_YOUR_KEY" \
-H "Content-Type: application/json" \
-d '{ "reason": "Client rang" }'Response example
{ "ok": true, "visitId": "vis_abc123", "cancelled": 2, "forfeited": false }
// Again: { "ok": true, "visitId": "vis_abc123", "cancelled": 0, "alreadyCancelled": true, "forfeited": false }Request fields
| Field | Type | In | Required | Notes |
|---|---|---|---|---|
| id | string | path | Yes | The visit id. |
| reason | string | body | No | Optional reason, up to 200 characters, stored on each service. |
Errors
| Status | When |
|---|---|
| 400 | The request body is not valid JSON. |
| 401 | The API key is missing or invalid. |
| 403 | The key belongs to a different organisation, or it lacks the bookings:write scope (the body names it as missingScope), or its maker can no longer do what the scope needs. A key made before scopes that is read-only is refused too. |
| 404 | No business exists for the {slug}, or no visit with that id in this business. |
| 409 | The visit could not be cancelled (error says why). |
| 500 | Unexpected server error (details are never leaked). |
Check out from another till (Moneris MGR)
A business that takes payment on another point of sale, such as Moneris MGR, can start checkout from a Bookatu booking and post the sale back, so nobody rings up the visit twice and the booking, the sale, who did the work and the money all line up in Bookatu as if its own till had taken it.
- Make an API key with the checkout scopes ticked (See what a booking comes to at checkout, and Check bookings out from another till), and See bookings so your till can list the day's bookings with GET /appointments. Add See clients if your till should get the client's name and contact details.
- When a client comes to pay, start checkout from their booking: read GET /appointments/{id}/checkout and ring up its lines. Each line is the service or an extra at its booked price and discount, with who did it, and dueCents is exactly what to take (tax added on top included, the deposit already taken off).
- Take the money on your terminal, then POST the sale to the same address with your own sale id as externalSaleId. Bookatu checks the booking out with the same code its own till uses. Retrying with the same externalSaleId is always safe.
- Subscribe a webhook endpoint to booking.checked_out to hear about every checkout, including the ones taken at Bookatu's own till, so your till never takes the same visit twice.
Visits of several services
A client can book several services in one visit, one straight after another (a colour, then a cut), with the same person or different people. Each service is a booking of its own, sharing a visitId, and the visit is one thing to the client: booked all or nothing, with one confirmation, one deposit and one manage link.
- Read GET /business: visits is there, with the most services one visit takes, when the business lets clients book several services in one visit. GET /services says which services go together: combinations.onlyWith lists the only other services a service shares a visit with, and combinations.neverWith the ones it is never booked with (a 4 braids and a 6 braids, say).
- Ask GET /visits/availability with the services in order (serviceIds), and the team member the client picked if they picked one. Each slot lists every service with who does it and when, back to back.
- Book a slot's startAt with POST /visits/book and the same services. The business's own key books a visit with POST /visits instead, naming each service's person, whatever clients may do online.
- Read, move and cancel the whole visit with GET /visits/{id}, POST /visits/{id}/reschedule and POST /visits/{id}/cancel; each booking's visitId on GET /appointments names its visit. The client's own /booking/{id} calls move and cancel the whole visit too, from any of its services. Over MCP, the same is get_visit_availability, create_visit, get_visit, reschedule_visit and cancel_visit.
Rooms and equipment
A business sets up its rooms, chairs and machines as resources, each of a kind (Room, Laser). A service can need one kind, which a booking of it holds as its room, or two or more kinds at once: a laser facial needs a treatment room and a laser machine. The booking engine offers a time only when a person and one free resource of each kind are free together, holds each of them for the booking's whole length, and never books one resource twice.
- Read GET /services: a service that needs two or more kinds lists them as resourceKinds, in order (for example ["Room", "Laser"]); the first is the kind of the booking's own room. A service that needs one kind or none has no resourceKinds. Over MCP, list_services says the same.
- Ask GET /availability as for any service: every slot it returns has one free resource of each kind, and its resourceIds are the free ones of the first kind. Book a slot's startAt with POST /book, or with POST /appointments and the business's key; resourceId prefers one of those, and the engine gives one free resource of each other kind.
- Read what a booking holds on GET /appointments and GET /appointments/{id}, or over MCP with list_appointments: resources lists every room, chair or machine it holds, { id, name, kind }, its own room first. POST /appointments answers with it too, and each service of a visit has it on POST /visits, GET /visits/{id} (get_visit) and POST /visits/{id}/reschedule.
- Moving a booking through the API (POST /booking/{id}/reschedule or MCP reschedule_booking) keeps each of its resources where it is still free and gives another of the same kind where it is not; moving a whole visit (POST /visits/{id}/reschedule) gives each of its services a free one of each kind. A time with none of a kind free is refused and nothing changes. Cancelling frees them all. Webhook payloads are unchanged: read the booking with your key to see what it holds.
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, and, when you ask for it, when a booking is checked out. 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.checked_out | A booking is checked out, at Bookatu's own till or by another point of sale through POST /appointments/{id}/checkout, with the sale that closed it. Sent only to an endpoint that asks for it by name: an endpoint taking all events never gets it. Same payload as GET /appointments/{id}/checkout, the client by id only. |
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
}
}booking.checked_out payload
{
"event": "booking.checked_out",
"sentAt": "2026-06-10T22:12:01.000Z",
"data": {
"version": 1,
"appointment": { "id": "apt_abc123", "ref": "VRD-7QK2M9", "status": "completed", "startAt": "2026-06-10T21:00:00.000Z", "endAt": "2026-06-10T22:10:00.000Z", "serviceName": "Cut", "customerId": "cus_xyz", "staffId": "stf_sam", "source": "online", "visitId": null },
"customer": { "id": "cus_xyz" },
"staff": { "id": "stf_sam", "name": "Sam" },
"currency": "CAD",
"lines": [
{ "lineId": "apt_abc123:service", "kind": "service", "appointmentId": "apt_abc123", "label": "Cut", "qty": 1, "unitCents": 4000, "discountCents": 400, "totalCents": 3600, "taxCents": 432, "serviceId": "svc_cut", "staffId": "stf_sam", "staffName": "Sam" }
],
"subtotalCents": 4000,
"discountCents": 400,
"totalCents": 3600,
"tax": { "mode": "added", "taxCents": 432, "lines": [{ "rateId": "gst", "label": "GST", "rateMilli": 5000, "taxCents": 180 }, { "rateId": "pst", "label": "PST", "rateMilli": 7000, "taxCents": 252 }] },
"depositPaidCents": 2000,
"dueCents": 0,
"checkout": { "allowed": false, "blocked": "already_checked_out" },
"sale": { "id": "sale_x1y2z3", "receipt": "X1Y2Z3", "at": "2026-06-10T22:12:00.000Z", "source": "moneris_mgr", "externalSaleId": "MGR-000123", "method": "Card", "tender": "card", "collectedCents": 2032, "saleTotalCents": 2032, "tipCents": 500, "products": [] }
}
}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), draft email campaigns, answer booking requests, run class rosters, read earnings, tips, deposits and the audit log, and see what a booking comes to at checkout — all in a single authenticated session. Each tool needs its scope on the key (writes a write scope; each tool's scope is in the table below), 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 | Scope |
|---|---|---|
| list_services | List all active bookable services, each with the extras offered with it (addOns) at a business that has extras, its combination rules (combinations: onlyWith, neverWith) when it has any, how many people it needs at once (peopleNeeded) when that is more than one, and the kinds of resource it needs at once (resourceKinds) when that is two or more. | catalog:read |
| get_availability | Get open slots for a service over a date range (max 60 days), with durationMin, how long each slot is. Optional addOnIds asks for the longer booking when an extra adds its time. Each slot also has local, its start with the offset in force on its own date on the one timeZone the answer names, and clientLocal on clientTimeZone when that is given. | availability:read |
| create_booking | Book an appointment for a customer; returns a ref and, when a deposit is due, status pending. Optional repeat ({ everyDays | monthly, visits }) books a series, each repeat checked and a clash left out and listed. Optional marketingConsent ({ source, wording }) only when the customer agreed to marketing email; without it they are not opted in. Optional addOnIds adds extras, refused with the reason when the service does not take one. Optional clientTimeZone (a place's IANA zone) is kept on the booking. The time is startAt with its offset, or localTime ("2026-11-04T09:00") on the business's clock or a named timeZone; a time with no offset is refused with TIME_NEEDS_OFFSET, one the clocks skip with TIME_DOES_NOT_EXIST (and nextValid), one they show twice with TIME_AMBIGUOUS (and both candidates). The answer's when is the booked start as a When, on the business's clock and, when their zone is known, the client's. Requires a read-write key. | bookings:write |
| list_customers | List all customers in the org. | customers:read |
| list_appointments | List upcoming pending and confirmed appointments. Returns each booking's id for reschedule_booking and cancel_booking, its visitId when it is one service of a visit (get_visit), additionalStaffIds when two or more of the team do it at once, and resources (every room, chair or machine it holds, { id, name, kind }) when its service needs two or more kinds of resource at once. A booking whose client asked to swap out of a late cancellation (Quick swap) has swap: { status (requested, claimed, withdrawn or expired), requestedAt, cutoffAt, closedAt, claimedAppointmentId }. Times also come as timeZone plus start and end When objects, with the offset in force at each instant. | bookings:read |
| list_staff | List the bookable team members: id, name, title, active and bookable. | staff:read |
| 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. The time is startAt with its offset, or localTime ("2026-11-04T09:00") on the business's clock or a named timeZone; a time with no offset is refused with TIME_NEEDS_OFFSET, one the clocks skip with TIME_DOES_NOT_EXIST (and nextValid), one they show twice with TIME_AMBIGUOUS (and both candidates). The answer's when is the booked start as a When, on the business's clock and, when their zone is known, the client's. Requires a read-write key. | bookings:write |
| 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. | bookings:write |
| list_products | List every retail product in the catalog, active and inactive. | catalog:read |
| create_product | Add a retail product (prices in dollars; optional tracked stock with an opening-stock ledger entry). Requires a read-write key. | catalog:write |
| update_product | Update only the provided fields of a product; a stock change records a ledger adjustment. Requires a read-write key. | catalog:write |
| receive_stock | Record a delivery into a stock-tracked product and log the restock movement. Requires a read-write key. | catalog:write |
| create_service | Add a standard bookable service (duration in minutes, prices in dollars). Requires a read-write key. | catalog:write |
| update_service | Update only the provided fields of a service. Requires a read-write key. | catalog:write |
| blog_list_posts | List the business's blog posts (drafts and published), newest first — lean rows without bodies; optional status filter. | content:read |
| blog_get_post | Get one blog post in full, markdown body included, by id or slug (drafts visible to the owner). | content:read |
| 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. | content:write |
| 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. | content:write |
| 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. | content:write |
| 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. | content:write |
| 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. | content:read |
| forms_get | Get one form in full, including its complete field schema and the services it is required before. | content:read |
| 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. | content:write |
| 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. | content:write |
| 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. | content:write |
| 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. | content:write |
| 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). | customers:read |
| campaigns_list | List email campaigns (drafts through sent) — lean rows with status, audience segment and counts, never bodies. | content:read |
| campaigns_get | Get one campaign in full, body markup included. Audiences are segment names; recipient email addresses are never returned. | content:read |
| campaigns_create_draft | Draft an email campaign (subject, composer-markup body, audience segment). Saved as a draft and nothing more: there is no send or schedule tool — sending stays an owner action in the dashboard. Requires a read-write key. | content:write |
| 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. | reports:read |
| get_findings | Ranked, data-computed findings about the business (severity, headline, detail, suggested action) — what the numbers mean, not prose. Read-only. | reports:read |
| 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. | reports:read |
| webhooks_list | List the business's webhook endpoints: url, subscribed events, active flag and last delivery status. Signing secrets are never returned. | webhooks:read |
| 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:write |
| 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. | webhooks:write |
| list_booking_requests | List the booking requests waiting for the business's answer (a new online booking, or a client's move of a confirmed one), oldest first, each with when it lapses, and resources when its service needs two or more kinds of resource at once. The client by id; their name and contact details only with customers:read. Times also come as timeZone plus start and end When objects, with the offset in force at each instant. A move's asked-for time is requestedStart and requestedEnd, and when it lapses is answerBy, as When objects. | bookings:read |
| accept_booking_request | Accept a booking request: the booking is confirmed or the move carried out, and the client told, as Accept on the Requests list. Answered once. Requires a read-write key. | bookings:write |
| decline_booking_request | Decline a booking request, with an optional note for the client: a booking is cancelled as the business (any deposit refunded), a move is dropped. Requires a read-write key. | bookings:write |
| list_class_sessions | List the business's class sessions, soonest first, with capacity, enrolledCount and spotsLeft; by default the scheduled ones still to start. Times also come as timeZone plus start and end When objects, with the offset in force at each instant. | bookings:read |
| get_class_roster | Who holds a place on a class session: each place's enrolmentId, the client's id, status (booked, attended, no_show) and how it was paid. Names and contact details only with customers:read. The session's times as in list_class_sessions. | bookings:read |
| add_class_attendee | Put a phone or walk-in client on a class session (matched by email or phone, never opted in to marketing, ticket emailed). A full session stays full. Requires a read-write key. | bookings:write |
| cancel_class_place | Take one person off a class session: the spot is freed, what they paid comes back as account credit, they are emailed. Requires a read-write key. | bookings:write |
| mark_class_attendance | Mark a place attended, no_show, or back to booked. Requires a read-write key. | bookings:write |
| send_deposit_link | Send the pay link again for a deposit the business asked for on a booking it made; the link before is closed first, so it can never be paid twice. Requires a read-write key. | bookings:write |
| stop_deposit_request | Stop asking for the deposit on a booking the business made (the client will pay in person): the link stops working. Requires a read-write key. | bookings:write |
| get_earnings | Staff earnings for a date range: each person's visits and products sold, commission under the business's rules, refunds and voids, and tips, by period and line by line. Read-only. | reports:read |
| get_tips | Tips for a date range: by person and period, each row of the record, and each tipped sale with its split now. Read-only. | reports:read |
| get_deposit_report | Deposit reconciliation for a date range: each deposit taken and what became of it (applied at checkout, kept, refunded, held), with Stripe's record beside it. Read-only. | reports:read |
| get_audit_log | The audit log, newest first: who did what and when, and what changed; filter by who, what and dates. Only a key made with the audit:read scope ticked. Read-only. | audit:read |
| get_checkout | What a booking comes to at checkout, for a till that is not Bookatu's: each line with who did it, price, discount, tax, deposit paid and dueCents; once checked out, the sale that closed it. Only a key made with the checkout:read scope ticked. Read-only. | checkout:read |
| get_visit_availability | The starts a visit of several services fits, one after another, with who does each service and when. Only at a business that lets clients book several services in one visit; two services it keeps apart are refused with the reason. Each slot also has local, its start with the offset in force on its own date on the one timeZone the answer names, and clientLocal on clientTimeZone when that is given. The last service's endAt is when the visit ends. Read-only. | availability:read |
| create_visit | Book a visit of several services as the booking page does: the whole visit or none of it, one confirmation, one deposit (payLink) and, where the business approves online bookings, one request. Only at a business that lets clients book several services in one visit. The time is startAt with its offset, or localTime ("2026-11-04T09:00") on the business's clock or a named timeZone; a time with no offset is refused with TIME_NEEDS_OFFSET, one the clocks skip with TIME_DOES_NOT_EXIST (and nextValid), one they show twice with TIME_AMBIGUOUS (and both candidates). Requires a read-write key. | bookings:write |
| get_visit | Read a visit of several services: each service in order with its booking, who does it, when, its price and status (and resources, every room, chair or machine it holds, when it needs two or more kinds at once), and the visit's start, end and total. Read-only. | bookings:read |
| reschedule_visit | Move a whole visit to a new start: every service keeps its person and length and is checked again at its new time, so all of them move or none does. The time is startAt with its offset, or localTime ("2026-11-04T09:00") on the business's clock or a named timeZone; a time with no offset is refused with TIME_NEEDS_OFFSET, one the clocks skip with TIME_DOES_NOT_EXIST (and nextValid), one they show twice with TIME_AMBIGUOUS (and both candidates). Requires a read-write key. | bookings:write |
| cancel_visit | Cancel every service of a visit still on; the client is told once, and the reply says whether a paid deposit was kept. Requires a read-write key. | bookings:write |
| get_business | The business's time zone, the time there now as a When, its weekly opening hours, the next seven days' openings as When ranges with one-off dates applied, closures (one-off dates in the next 90 days and time the whole business is blocked), its branches with their own zones and hours, and its booking policies. A When is { utc, primary, business: { timeZone, local, utcOffset, abbr, display }, client }, with the offset in force at that instant; quote display rather than converting a time yourself. Read-only. | catalog:read |
# 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/mcp with your API key. All tools are automatically scoped to the key's organisation.
Docs MCP server
No key neededA second MCP server reads Bookatu's own documentation: the help centre, in every language it is translated into, the blog, the engineering blog and these developer docs. It needs no API key and no sign-in, and it can only read, so any MCP client can connect to it. It never sees a business's data. To act on a business, use the MCP server above.
Endpoint
https://bookatu.com/api/mcp/docs
| Tool | Description |
|---|---|
| search_help | Search the Bookatu help centre, the plain-language guides to every feature, in any of its 14 languages. Returns the guides that match, closest first, with their summary, page, markdown copy and the dashboard screen each is set up on. |
| get_help_article | Get one help guide in full by its slug: what the feature is, why it helps and the real steps to use it, in the language asked for, with its page and its dashboard screen. |
| search_blog | Search the Bookatu blog for business owners (a translated post in the language asked for), or the engineering blog with section "engineering". Returns the posts that match, closest first, with their excerpt, date, page and markdown copy. |
| get_blog_post | Get one blog or engineering post in full by its slug, as markdown, with its author, dates, tags and page. |
| search_developer_docs | Search the Bookatu developer docs: every REST endpoint, the webhooks and their events, both MCP servers and their tools, and the guides (authentication, CORS, rate limits). Returns the parts that match, closest first, with a short snippet, their place on /developers and the markdown copy. |
# No key needed
curl -X POST https://bookatu.com/api/mcp/docs \
-H "Content-Type: application/json" \
-d '{
"jsonrpc": "2.0",
"id": 1,
"method": "tools/call",
"params": {
"name": "search_help",
"arguments": { "query": "how do I take a deposit", "language": "en" }
}
}'Every answer names the page it came from and its markdown copy, so an agent can cite the page. It is rate limited per IP, like the public REST endpoints.
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.