Developers

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.

Guides

  • Your sandboxes: Make, reset and delete your sandbox businesses and get their test keys (sign in first).
  • Testing with a sandbox: Build and test against a sandbox business with bk_test_ keys: realistic sample data, a test outbox instead of real messages, a simulated checkout instead of real payments, and a simulator for the events you would otherwise wait for.
  • Test your AI agent: Point an AI agent at a sandbox through the Bookatu MCP server, with a test key or OAuth, and check what it did: the bookings it made, the messages it would have sent and the payments it would have taken, with nothing reaching a real client.
  • Rate limits: How many calls the Bookatu API takes per key, per business and per address; the RateLimit headers on every answer; the standard and raised tiers; and what to do on a 429.
  • Versioning: How the Bookatu API changes without breaking you: versions named by date, the Bookatu-Version header, the version your key is pinned to, the policy, and the deprecation headers.
  • Changelog: Every change to the Bookatu v1 REST API, the MCP server and webhooks a developer needs to know about, newest first, each dated by the change that made it.
  • Going live: Move an integration from a sandbox and its bk_test_ key to a live business and its bk_live_ key, with the checklist of what to change and what to check first.

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. A booking carries a manage key, the client's credential for changing it: it is the k query parameter on the manage link in the client's confirmation email, POST /book returns it as manageKey, and it may be sent as the X-Manage-Key header instead of k. With the appointment id alone, GET /booking/{id} answers a read-only status (access "view", no selfService), and the availability, reschedule and cancel endpoints answer 403 with code MANAGE_KEY_REQUIRED; with the key (access "manage") everything works as described. Bookings made before manage keys existed have none and are managed on their id alone, as the links already emailed for them expect. Ids are apt_ followed by a 16-character nanoid, and are scoped to the business in the URL, so a valid id or key from one business can never be read or acted on through another's. Treat the manage key, and the id, like a password: show them to nobody but the person who booked, and never store them where others can read them. 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, customers:write. 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, customers:write 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, audit:read reads the audit log, and customers:write puts the business's tags on clients and takes them off. 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.
  • Test keys start bk_test_ and belong to a sandbox business, a test business made for building and testing (see /developers/testing): a sandbox issues only test keys and a live business only bk_live_ keys, with the same scopes. A key of the other mode is refused with 403 and code LIVEMODE_MISMATCH, and every JSON answer says livemode (false from a sandbox), as does the Bookatu-Livemode header.

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's manage key (or, for a booking from before manage keys, its id), 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.

EndpointAuthDescription
GET /businessNoneBusiness profile + opening hours
GET /servicesNoneList active, online-bookable services
GET /staffNoneList bookable team members
GET /availabilityNoneOpen slots for a service
POST /bookNoneCreate a booking (online source)
GET /booking/{id}Booking idRead one booking (the customer's own)
GET /booking/{id}/availabilityBooking idOpen slots for MOVING this booking
POST /booking/{id}/rescheduleBooking idMove a booking to a new time
POST /booking/{id}/cancelBooking idCancel a booking (the customer's own)
GET /customersAPI keyList customers
GET /appointmentsAPI keyList appointments
GET /services/{id}NoneGet one service
GET /appointments/{id}API keyGet one appointment
POST /appointmentsAPI keyCreate a booking (admin source)
POST /appointments/{id}/cancelAPI keyCancel a booking
GET /appointments/{id}/checkoutAPI keyWhat a booking comes to at checkout (MGR hand-off)
POST /appointments/{id}/checkoutAPI keyPost an outside sale back: the booking is checked out (MGR hand-off)
POST /appointments/{id}/deposit-requestAPI keySend the deposit pay link again
POST /appointments/{id}/deposit-request/cancelAPI keyStop asking for the deposit
GET /booking-requestsAPI keyList booking requests waiting for an answer
POST /booking-requests/{id}/acceptAPI keyAccept a booking request
POST /booking-requests/{id}/declineAPI keyDecline a booking request
GET /class-sessionsAPI keyList class sessions
GET /class-sessions/{id}/rosterAPI keyWho holds a place on a class session
POST /class-sessions/{id}/rosterAPI keyPut a phone or walk-in client on a class session
POST /class-sessions/{id}/roster/{enrolmentId}/cancelAPI keyCancel one person's place on a class session
POST /class-sessions/{id}/roster/{enrolmentId}/attendanceAPI keyMark who came to a class session
GET /reports/earningsAPI keyStaff earnings and commission
GET /reports/tipsAPI keyTips
GET /reports/depositsAPI keyDeposit reconciliation
GET /audit-logAPI keyThe audit log
POST /customers/{id}/tagsAPI keyTag a client
DELETE /customers/{id}/tags/{tagId}API keyUntag a client
POST /appointments/{id}/tagsAPI keyTag a booking
DELETE /appointments/{id}/tags/{tagId}API keyUntag a booking
GET /sandbox/outboxAPI keyThe test outbox (sandbox only)
POST /sandbox/simulate/{event}API keySimulate an event (sandbox only)
GET /sandbox/paymentsAPI keyThe test payments (sandbox only)
GET /walk-inNoneWalk-in wait now
PUT /walk-in/manualAPI keySet the walk-in wait by hand
DELETE /walk-in/manualAPI keyBack to the automatic walk-in wait
GET /visits/availabilityNoneOpen starts for a visit of several services
POST /visits/bookNoneBook a visit of several services (online source)
POST /visitsAPI keyBook a visit of several services (the team's)
GET /visits/{id}API keyGet one visit
POST /visits/{id}/rescheduleAPI keyMove a whole visit
POST /visits/{id}/cancelAPI keyCancel 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

Public
GET /api/v1/{slug}/business

Business 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). At a business that asks booking questions for every service, questions lists those (each service's own are on GET /services); absent otherwise. 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
StatusWhen
404No business exists for the {slug}.
500Unexpected server error (details are never leaked).

GET /services

Public
GET /api/v1/{slug}/services

List 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. A service the business asks booking questions for lists them as questions, in the order they are asked: { id, type (text, textarea, radio, checkboxes, yes_no, number or date), label, required, helpText?, options? (radio and checkboxes), allServices? (true: asked for every service) }. Answer them as answers on POST /book; a required one must be answered (422 ANSWERS_REQUIRED). A service with no questions has no questions field. A service that lasts whole days (a trip) is booked by the business only (its New booking form and POST /appointments) and is never listed or booked here. 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",
      "questions": [
        { "id": "bq_hairlength", "type": "yes_no", "label": "Are your nails longer than 1 cm?", "required": true, "helpText": "So we book enough time." },
        { "id": "bq_heard", "type": "radio", "label": "How did you hear about us?", "required": false, "options": ["A friend", "Instagram"], "allServices": true }
      ]
    }
  ]
}

// description is the service description, or null when none is set.
// questions is there only on a service the business asks booking questions
// for: answer them as answers on POST /book.
Errors
StatusWhen
404No business exists for the {slug}.
500Unexpected server error.

GET /staff

Public
GET /api/v1/{slug}/staff

List 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
StatusWhen
404No business exists for the {slug}.
500Unexpected server error.

GET /availability

Public
GET /api/v1/{slug}/availability

Open slots for a service

Bookable slots for a service across a date window. The engine accounts for opening hours, existing appointments, blocked time (one-off or weekly, for a person, for everyone, or for a room or chair the service needs: when another room of the kind is free the slot stays and uses it), 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. A slot a surcharge covers (a public holiday or a busy time the business charges more for) also carries priceCents (the price before it, before any discount code), surcharges (the one that applies: label, amountType percent or fixed, amount as a whole percent or in minor units, and amountCents, what it adds) and totalCents, what the booking comes to at that time. When two of the business's surcharges cover one time, only the higher applies, so surcharges has one entry. Show it to the client before they book. Every other slot has none of these fields.

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.

// A slot on a date the business charges a surcharge on also has:
//   "priceCents": 8000,
//   "surcharges": [{ "label": "Public holiday surcharge", "amountType": "percent", "amount": 15, "amountCents": 1200 }],
//   "totalCents": 9200
Parameters
FieldTypeInRequiredNotes
serviceIdstringqueryYesAn active, online-bookable service id.
fromstringqueryYesStart date, YYYY-MM-DD.
tostringqueryYesEnd date, YYYY-MM-DD. Must be on or after from, and at most 60 days later.
staffIdstringqueryNoRestrict slots to one team member.
partySizenumberqueryNoHow 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.
addOnIdsstringqueryNoThe 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.
locationIdstringqueryNoOne of this business's branches. Slots then follow that branch's own opening hours and only the team and rooms based there, plus anyone based at no branch (who works at all of them). Omit to search the whole business. An id that is not this business's, or names a branch that is currently closed, is ignored rather than rejected, and the answer widens back to the whole business.
clientTimeZonestringqueryNoThe 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
StatusWhen
400serviceId 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).
404The {slug} is unknown, or the serviceId is not an active, online-bookable service in this org.
500Unexpected server error.

POST /book

Public
POST /api/v1/{slug}/book

Create 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. So is a service the business requires a card saved to hold (409, code CARD_REQUIRED_USE_BOOKING_PAGE, with bookingPageUrl): a card is only ever saved in the booking page's own card step. At a business that has set booking terms, the person must accept them: send acceptTerms: true (and the termsVersion they were shown); without it the answer is 422 with code TERMS_NOT_ACCEPTED and the terms to show, and nothing is booked. At a business that asks booking questions (GET /services lists them on each service as questions), send the person's answers as answers; a required question without an answer is 422 with code ANSWERS_REQUIRED and missingQuestionIds, an answer that cannot be read 422 ANSWERS_INVALID with invalidAnswers, and 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 manageKey beside it is what changes the booking, and is just as private: an app of the person's own sends it as k (or X-Manage-Key) to /booking/{id}. The answer's when is the booked time on the business's clock and, with clientTimeZone, the client's. A booking made at a surcharged time also has priceCents (its total, the surcharge included) and surcharge: { label, amountCents }; a booking without one has neither. A booking keeps the surcharge it was made with. A service that lasts whole days (a trip) is booked by the business only (its New booking form and POST /appointments) and is never listed or booked here. 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",
  "manageKey": "3q2-Lr8xN0b6VfJm1oZ4sYtKcE9uWgHdAiPkQnTwXeU",
  "status": "confirmed",
  "needsPayment": false
}

// status is "pending" for a request the business still has to accept.
// At a surcharged time (a public holiday), also:
//   "priceCents": 9200, "surcharge": { "label": "Public holiday surcharge", "amountCents": 1200 }
// appointmentId and manageKey are the person's private keys to their booking:
// never show them, store them or put them in a chat; they manage it from the
// email they are sent. An app of their own sends manageKey as k (or
// X-Manage-Key) to /booking/{id} to cancel or move it.

// 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"
}

// So is a service the business requires a card saved to hold:
// HTTP 409
{
  "ok": false,
  "error": "This service needs a card saved to hold the booking, which can only be done on the business's booking page. Nothing was booked: send the person to bookingPageUrl.",
  "code": "CARD_REQUIRED_USE_BOOKING_PAGE",
  "bookingPageUrl": "https://bookatu.com/your-salon/book?service=svc_abc123"
}

// At a business with booking terms, a booking without acceptTerms: true:
// HTTP 422
{
  "ok": false,
  "error": "This business asks clients to accept its booking terms to book. Show the person the terms (terms.text), and send acceptTerms: true only once they have accepted them. Nothing was booked.",
  "code": "TERMS_NOT_ACCEPTED",
  "terms": { "text": "Please arrive 10 minutes early.", "version": "3f2a…" }
}

// At a business with booking questions, a booking without a required answer:
// HTTP 422
{
  "ok": false,
  "error": "This business asks a few questions before it takes this booking. Send the answers to the required questions (missingQuestionIds), or book on the business's booking page.",
  "code": "ANSWERS_REQUIRED",
  "missingQuestionIds": ["bq_hairlength"]
}
Request fields
FieldTypeInRequiredNotes
serviceIdstringbodyYesAn active, online-bookable service id from GET /services.
startAtstringbodyNoAn instant with its offset (RFC 3339), such as a startAt from GET /availability ("2026-06-10T09:00:00.000Z") or "2026-06-10T21:00:00+12:00", in whole minutes. Read strictly: no offset is a 422 with code TIME_NEEDS_OFFSET and an example on the business's clock; a date that does not exist, seconds other than :00 or anything else is a 422 with code TIME_INVALID. Nothing is booked or moved either way. Send startAt or localTime, exactly one.
localTimestringbodyNoInstead 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.
timeZonestringbodyNoWith localTime only: the IANA zone of a place it is on (America/Toronto). Abbreviations, offsets and Etc/ zones are a 422 UNKNOWN_TIME_ZONE.
namestringbodyYesCustomer name, 2–80 characters (trimmed).
emailstringbodyYesA valid email address.
phonestringbodyYes6–30 characters; digits, spaces and + ( ) - only.
staffIdstringbodyNoSpecific staff id. Omit to let the system assign a free team member.
notesstringbodyNoFree text, max 500 characters.
partySizenumberbodyNoHow 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.
resourceIdstringbodyNoA 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.
repeatobjectbodyNoBook 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.
marketingConsentobjectbodyNoOnly 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.
addOnIdsstring[]bodyNoExtras 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.
locationIdstringbodyNoThe branch this booking is for, recorded on the appointment so the customer is told the right address. Omit for a business with one address. An id that is not this business's, or names a branch that is currently closed, is ignored, and the booking then follows the assigned team member's own branch.
acceptTermsbooleanbodyNotrue once the person has read and accepted the business's booking terms. Required, as true, while the business has set terms: without it the answer is 422 with code TERMS_NOT_ACCEPTED and terms ({ text, version }) to show them, and nothing is booked. Show terms.text as plain text. A business with no terms never asks, and ignores it.
termsVersionstringbodyNoThe terms.version the person was shown (the SHA-256 of the text). When sent and it is not the version in force (the business changed its terms meanwhile), the answer is the same 422 TERMS_NOT_ACCEPTED with the terms in force, so they are shown again.
answersobjectbodyNoThe person's answers to the business's booking questions, as { [questionId]: answer }, the questions GET /services lists on each service (questions). A string for text (up to 300 characters), textarea (up to 2000), radio (one of its options), number (a plain decimal) and date (YYYY-MM-DD); a list of its options for checkboxes; true or false for yes_no. Every required question must be answered: else the answer is 422 with code ANSWERS_REQUIRED and missingQuestionIds, and nothing is booked. An answer that cannot be read is 422 with code ANSWERS_INVALID and invalidAnswers ({ [questionId]: too_long | not_an_option | not_a_number | not_a_date | not_yes_or_no | bad_value }). An answer to a question the booking does not ask is ignored. The answers are kept for the business only: never echoed back, never in a webhook. A business with no questions never asks, and ignores it.
clientTimeZonestringbodyNoThe 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
StatusWhen
400The request body is not valid JSON.
422Validation 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, or both startAt and localTime, has code TIME_INVALID; a localTime the clocks skip that day has code TIME_DOES_NOT_EXIST with nextValid, one they show twice TIME_AMBIGUOUS with both candidates; a timeZone or clientTimeZone that is not a place's zone has code UNKNOWN_TIME_ZONE. At a business with booking terms, a booking without acceptTerms: true (or with a termsVersion that is not the one in force) has code TERMS_NOT_ACCEPTED and terms { text, version } to show. At a business with booking questions, a required question left without an answer has code ANSWERS_REQUIRED and missingQuestionIds (the ids, in the order asked); an answer that cannot be read has code ANSWERS_INVALID and invalidAnswers. Nothing is booked.
409Nothing 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), the business requires a card saved to hold it (code CARD_REQUIRED_USE_BOOKING_PAGE, with bookingPageUrl), or the business is not taking online bookings right now (code BOOKING_PAUSED).
429Too 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.
404No business exists for the {slug}.
500Unexpected server error.

GET /booking/{id}

Authorised by the booking id

No API key. Authorised by the booking's manage key (k, or X-Manage-Key); the appointment id alone reads a read-only status.

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 booking's manage key (k or X-Manage-Key) — no API key, no account — and deliberately narrow; with the appointment id alone it answers a read-only status (access "view": no selfService, no quickSwap): 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. Inside the last hours before the start (after cutoffAt, the hours the business set for offers to stop, or its free-cancellation window if that is shorter), a business may have chosen to charge a client who cancels the full price of the booking (for a visit, every service of it still on, as one fee), less any deposit it keeps, up to its maximum fee: for every booking, or only for bookings Quick swap could offer. It applies only where cancelling would already cost a late-cancellation fee or the deposit, only where the business requires a card to hold bookings, and only to a booking made on the business's booking page whose client was shown it there before confirming: a booking made through this API, an assistant or by the business, or before the business chose it, keeps the usual late-cancellation fee. If the business later changes its Quick swap choice or hours, the usual fee is charged instead; if it changes its cancellation policy, the fee is not charged; a no-show is charged as before. The manage-booking page says what cancelling would cost. 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"). A booking made at a surcharged time also has surcharge: { label, amountCents }, part of priceCents. 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
FieldTypeInRequiredNotes
idstringpathYesThe appointment id, as returned by POST /book (apt_…).
kstringqueryNoThe booking's manage key: manageKey from POST /book, or the k on the client's manage link. The X-Manage-Key header works too. Needed to read in full (with the id alone it is a read-only status) a booking that has one; bookings from before manage keys have none.
Errors
StatusWhen
404No 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).
429More than 30 requests from one IP in 5 minutes. The response carries Retry-After.
500Unexpected server error (details are never leaked).

GET /booking/{id}/availability

Authorised by the booking id

No API key. Authorised by the booking's manage key (k, or X-Manage-Key); the appointment id alone reads a read-only status.

GET /api/v1/{slug}/booking/{id}/availability

Open 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 booking's manage key (k or X-Manage-Key); with the id alone it answers 403 MANAGE_KEY_REQUIRED. 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
FieldTypeInRequiredNotes
idstringpathYesThe appointment id being moved.
kstringqueryNoThe booking's manage key: manageKey from POST /book, or the k on the client's manage link. The X-Manage-Key header works too. Needed to list move times for a booking that has one; bookings from before manage keys have none.
fromstringqueryYesStart date, YYYY-MM-DD.
tostringqueryYesEnd date, YYYY-MM-DD. Must be on or after from, and at most 60 days later.
locationIdstringqueryNoOne of this business's branches. Slots then follow that branch's own opening hours and only the team and rooms based there, plus anyone based at no branch (who works at all of them). Omit to search the whole business. An id that is not this business's, or names a branch that is currently closed, is ignored rather than rejected, and the answer widens back to the whole business.
staffIdstringqueryNoSomeone 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
StatusWhen
400from/to are not YYYY-MM-DD, from is later than to, or the range exceeds 60 days.
404No business exists for the {slug}, or no booking with that id in this business.
429More than 30 requests from one IP in 5 minutes.
500Unexpected server error (details are never leaked).

POST /booking/{id}/reschedule

Authorised by the booking id

No API key. Authorised by the booking's manage key (k, or X-Manage-Key); the appointment id alone reads a read-only status.

POST /api/v1/{slug}/booking/{id}/reschedule

Move 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
FieldTypeInRequiredNotes
idstringpathYesThe appointment id to move.
kstringqueryNoThe booking's manage key: manageKey from POST /book, or the k on the client's manage link. The X-Manage-Key header works too. Needed to move a booking that has one; bookings from before manage keys have none.
startAtstringbodyNoThe new start time, e.g. a startAt from GET /booking/{id}/availability, in the future. An instant with its offset (RFC 3339), such as a startAt from GET /availability ("2026-06-10T09:00:00.000Z") or "2026-06-10T21:00:00+12:00", in whole minutes. Read strictly: no offset is a 422 with code TIME_NEEDS_OFFSET and an example on the business's clock; a date that does not exist, seconds other than :00 or anything else is a 422 with code TIME_INVALID. Nothing is booked or moved either way. Send startAt or localTime, exactly one.
localTimestringbodyNoInstead 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. Read on the booking's branch's clock when it is at one.
timeZonestringbodyNoWith localTime only: the IANA zone of a place it is on (America/Toronto). Abbreviations, offsets and Etc/ zones are a 422 UNKNOWN_TIME_ZONE.
staffIdstringbodyNoMove 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
StatusWhen
400The request body is not valid JSON.
422Neither startAt nor localTime was sent, or both were; startAt has no offset (code TIME_NEEDS_OFFSET, with an example) or is not a real RFC 3339 date-time on a whole minute (TIME_INVALID); localTime is a time the clocks skip that day (TIME_DOES_NOT_EXIST, with nextValid) or show twice (TIME_AMBIGUOUS, with both candidates); timeZone is not a place's zone (UNKNOWN_TIME_ZONE); the time is in the past; or staffId is not a string. Nothing is moved.
409The 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 }.
404No business exists for the {slug}, or no booking with that id in this business.
429More than 10 requests from one IP in 10 minutes. The response carries Retry-After.
500Unexpected server error (details are never leaked).

POST /booking/{id}/cancel

Authorised by the booking id

No API key. Authorised by the booking's manage key (k, or X-Manage-Key); the appointment id alone reads a read-only status.

POST /api/v1/{slug}/booking/{id}/cancel

Cancel 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
FieldTypeInRequiredNotes
idstringpathYesThe appointment id to cancel.
kstringqueryNoThe booking's manage key: manageKey from POST /book, or the k on the client's manage link. The X-Manage-Key header works too. Needed to cancel a booking that has one; bookings from before manage keys have none.
Errors
StatusWhen
409The 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.
404No business exists for the {slug}, or no booking with that id in this business.
429More than 10 requests from one IP in 10 minutes. The response carries Retry-After.
500Unexpected server error (details are never leaked).

GET /customers

Requires API key

Needs the customers:read scope.

GET /api/v1/{slug}/customers

List customers

Up to 1000 customers in this org (id, name, email, phone and tags), ordered by name. tags lists the business's own tags on it ({ id, name, color }: color is a palette name such as indigo or rose), in name order, [] for none. Tags are the team's internal labels: never show them to a client, and they are never in a webhook payload. 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",
      "tags": [{ "id": "tag_vip123", "name": "VIP", "color": "indigo" }]
    }
  ]
}

// Capped at 1000 customers, ordered by name.
Errors
StatusWhen
401The API key is missing or invalid.
403The key belongs to a different organisation, or it lacks the customers:read scope (the body names it as missingScope).
404No business exists for the {slug}.
500Unexpected server error.

GET /appointments

Requires API key

Needs the bookings:read scope.

GET /api/v1/{slug}/appointments

List 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). notifyClient is false for a booking whose client Bookatu does not message (made with notifyClient: false on POST /appointments, or switched off on the booking's page), else true. A trip (a booking of a service that lasts whole days) also has startDate and endDate (its first and last day on the business's calendar, both inclusive) and days, after notifyClient; startAt is its first day's midnight there and endAt the midnight after its last day. Other bookings have none of the three. 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. tags lists the business's own tags on it ({ id, name, color }: color is a palette name such as indigo or rose), in name order, [] for none. Tags are the team's internal labels: never show them to a client, and they are never in a webhook payload. 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",
      "notifyClient": true,
      "source": "online",
      "leadSource": "google",
      "channel": "google",
      "seriesId": null,
      "visitId": null,
      "tags": [{ "id": "tag_bridal456", "name": "Bridal", "color": "rose" }]
    }
  ]
}

// Only pending + confirmed, startAt >= now, oldest first, max 200 rows.
Parameters
FieldTypeInRequiredNotes
fromstringqueryNoLower time bound, ISO 8601. Overrides the default "now" — use it to list a past window.
tostringqueryNoUpper time bound, ISO 8601 (inclusive).
statusstringqueryNoComma-separated statuses to include: pending, confirmed, completed, cancelled, no_show. Defaults to pending + confirmed.
limitnumberqueryNoPage size, 1–200 (default 200).
offsetnumberqueryNoRows to skip, for pagination. The response carries nextOffset when more may exist.
Errors
StatusWhen
401The API key is missing or invalid.
403The key belongs to a different organisation, or it lacks the bookings:read scope (the body names it as missingScope).
404No business exists for the {slug}.
500Unexpected server error.

GET /services/{id}

Public
GET /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. A service the business asks booking questions for lists them as questions, in the order they are asked: { id, type (text, textarea, radio, checkboxes, yes_no, number or date), label, required, helpText?, options? (radio and checkboxes), allServices? (true: asked for every service) }. Answer them as answers on POST /book; a required one must be answered (422 ANSWERS_REQUIRED). A service with no questions has no questions field. 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
FieldTypeInRequiredNotes
idstringpathYesThe service id (from GET /services).
Errors
StatusWhen
404No business exists for the {slug}, or no active, online-bookable service with that id.
500Unexpected server error.

GET /appointments/{id}

Requires API key

Needs 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, visitId, notifyClient and tags included. notifyClient is false for a booking whose client Bookatu does not message (made with notifyClient: false on POST /appointments, or switched off on the booking's page), else true. A trip (a booking of a service that lasts whole days) also has startDate and endDate (its first and last day on the business's calendar, both inclusive) and days, after notifyClient; startAt is its first day's midnight there and endAt the midnight after its last day. Other bookings have none of the three. 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. tags lists the business's own tags on it ({ id, name, color }: color is a palette name such as indigo or rose), in name order, [] for none. Tags are the team's internal labels: never show them to a client, and they are never in a webhook payload. A booking made at a surcharged time also has surcharge: { label, amountCents }, part of priceCents. 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",
  "notifyClient": true,
  "source": "phone",
  "leadSource": null,
  "channel": null,
  "seriesId": "ser_abc123",
  "visitId": null,
  "tags": []
}
Parameters
FieldTypeInRequiredNotes
idstringpathYesThe appointment id.
Errors
StatusWhen
401The API key is missing or invalid.
403The key belongs to a different organisation, or it lacks the bookings:read scope (the body names it as missingScope).
404No business exists for the {slug}, or no appointment with that id in this org.
500Unexpected server error.

POST /appointments

Requires API key

Needs the bookings:write scope.

POST /api/v1/{slug}/appointments

Create a booking (admin source)

Create a booking with source: admin and the same body as POST /book, plus requestDeposit, additionalStaffIds (the others on a booking two or more of the team do at once, then named in the answer too) and notifyClient (false: Bookatu never messages the client about this booking; the team's alerts and the webhooks are unchanged). 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. A booking made at a surcharged time also has priceCents (its total, the surcharge included) and surcharge: { label, amountCents }; a booking without one has neither. A booking keeps the surcharge it was made with. A service that lasts whole days (a trip: Services, Length in Whole days) is booked by its days, startDate with endDate or with days, never by a time, and is priced once per booking; the answer then has startDate, endDate and days too. Its person must be free on every one of its days, and each resource its service needs is held for them; it takes no extras and does not repeat. notifyClient: false keeps its client unmessaged, as for any booking. 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
FieldTypeInRequiredNotes
serviceIdstringbodyYesAn active, online-bookable service id from GET /services.
startAtstringbodyNoAn 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.
namestringbodyYesCustomer name, 2–80 characters (trimmed).
emailstringbodyYesA valid email address.
phonestringbodyYes6–30 characters; digits, spaces and + ( ) - only.
staffIdstringbodyNoSpecific staff id. Omit to let the system assign a free team member.
notesstringbodyNoFree text, max 500 characters.
partySizenumberbodyNoHow 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.
resourceIdstringbodyNoA 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.
repeatobjectbodyNoBook 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.
marketingConsentobjectbodyNoOnly 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.
addOnIdsstring[]bodyNoExtras 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.
locationIdstringbodyNoThe branch this booking is for, recorded on the appointment so the customer is told the right address. Omit for a business with one address. An id that is not this business's, or names a branch that is currently closed, is ignored, and the booking then follows the assigned team member's own branch.
requestDepositbooleanbodyNoPOST /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.
additionalStaffIdsstring[]bodyNoFor 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.
notifyClientbooleanbodyNoPOST /appointments only. false makes a booking whose client Bookatu never messages, for a business that writes to its own clients: no confirmation, booking request or decline, reminder, ask to confirm they are coming, thank-you or review request, move or cancellation notice, deposit or payment request, "did you make this booking?" check or Quick swap message, by email or text, while it stays off. Someone who may edit the calendar can switch it on or off on the booking's page, and each change is in the audit log; on again, the next message due goes, never the ones held while it was off. The team's own alerts and the webhooks are unchanged. Marketing still follows the client's own consent, so leave marketingConsent out for a client Bookatu should never write to. A series (repeat) made with it is quiet on every visit. true, or left out, is the usual booking. Refused with requestDeposit: true (422, fieldErrors.notifyClient), since the pay link is sent to the client. The keyless doors (POST /book, POST /visits/book) do not take it: a booking made there always tells the person who booked.
startDatestringbodyNoFor a service that lasts whole days (a trip) only, and then instead of startAt or localTime: its first day, YYYY-MM-DD, on the business's own calendar. Send endDate or days with it. The trip covers its first to last day in full: it starts at the first day's midnight on the business's clock and ends at the midnight after the last day, whatever the clocks do in between, and holds its person (staffId, or nobody when left out) for every one of those days. Sent for a service of minutes it is a 422 (fieldErrors.startDate).
endDatestringbodyNoWith startDate: the trip's last day, YYYY-MM-DD, inclusive (1 to 8 Dec is endDate 2026-12-08, 8 days). Not before startDate, and not with days.
daysnumberbodyNoWith startDate, instead of endDate: how many days the trip lasts, a whole number from 1 to 60, the first day included.
localTimestringbodyNoInstead 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.
timeZonestringbodyNoWith 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
StatusWhen
400The request body is not valid JSON.
422Validation 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.
409The 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. For a trip, also when its person is already booked on those days ("Sam is already booked on those days."), or no resource of a kind it needs is free on them; nothing is booked.
422With 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). With notifyClient: it is not true or false, or it is false with requestDeposit: true (fieldErrors.notifyClient). For a service that lasts whole days: a time sent instead of startDate (fieldErrors.startDate, or fieldErrors.startAt with startDate too), a date that is not YYYY-MM-DD and real (fieldErrors.startDate or fieldErrors.endDate), no endDate or days (fieldErrors.endDate), an endDate before startDate, endDate and days together (fieldErrors.days), more than 60 days, or a repeat (fieldErrors.repeat). For a service of minutes: any of startDate, endDate or days (fieldErrors.startDate). Nothing is booked.
401The API key is missing or invalid.
403The 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).
404No business exists for the {slug}.
500Unexpected server error.

POST /appointments/{id}/cancel

Requires API key

Needs the bookings:write scope.

POST /api/v1/{slug}/appointments/{id}/cancel

Cancel 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
FieldTypeInRequiredNotes
idstringpathYesThe appointment id to cancel.
reasonstringbodyNoOptional cancellation reason, truncated to 200 characters and stored on the booking.
Errors
StatusWhen
401The API key is missing or invalid.
403The 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).
404No business exists for the {slug}.
400The booking could not be cancelled (e.g. the appointment id is unknown in this org).
500Unexpected server error.

GET /appointments/{id}/checkout

Requires API key

Needs the checkout:read scope, on a key made with it ticked.

GET /api/v1/{slug}/appointments/{id}/checkout

What a booking comes to at checkout (MGR hand-off)

For a point of sale that is not Bookatu's (Moneris MGR, or any other): 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 point of sale 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 point of sale 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 point of sale 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 point of sale (externalSaleId
// null), else the name the outside point of sale posted. tax.lines[].rateId is null for a business
// with one tax inside its prices. customer has only id without customers:read.
Parameters
FieldTypeInRequiredNotes
idstringpathYesThe appointment id (from GET /appointments or GET /appointments/{id}).
Errors
StatusWhen
401The API key is missing or invalid.
403The 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.
404No business exists for the {slug}, or no appointment with that id in this business (the same 404 either way).
500Unexpected server error (details are never leaked).

POST /appointments/{id}/checkout

Requires API key

Needs the checkout:write scope, on a key made with it ticked.

POST /api/v1/{slug}/appointments/{id}/checkout

Post 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 point of sale 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 points of sale 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 point of sale (sale.tipCents says what was). Products you sell with it stay in your own system. 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
FieldTypeInRequiredNotes
idstringpathYesThe appointment id. For a visit of several services, any of its services: the whole visit is checked out.
externalSaleIdstringbodyYesYour point of sale's own id for the sale, 1 to 100 printable characters without spaces. The idempotency key: unique per business and point of sale, so it can close one booking only.
sourcestringbodyNoWhich point of sale 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 point of sale and is refused.
amountCentsnumberbodyYesWhat you took for the booking, in whole cents, tip excluded: the checkout's dueCents (0 when the deposit covered it all).
currencystringbodyNoISO 4217 code; when given it must be the business's currency.
tenderstringbodyYesHow it was paid: card, cash or other. Recorded as Bookatu's point of sale records its own (Card, Cash, Other).
tipCentsnumberbodyNoThe tip, in whole cents, beside the amount and never inside it. Omit or 0 for none.
tipSplitobject[]bodyNoWho 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.
linesobject[]bodyNoWho 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
StatusWhen
400The request body is not valid JSON.
401The API key is missing or invalid.
403The 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.
404No business exists for the {slug}, or no appointment with that id in this business.
409Nothing 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 point of sale 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).
422Nothing 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).
503Checking bookings out from another point of sale is still being switched on for this business (its database is being updated). Nothing was recorded; retry shortly with the same externalSaleId.
500Unexpected server error (details are never leaked).

POST /appointments/{id}/deposit-request

Requires API key

Needs the bookings:write scope.

POST /api/v1/{slug}/appointments/{id}/deposit-request

Send 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
FieldTypeInRequiredNotes
idstringpathYesThe booking waiting on its deposit.
Errors
StatusWhen
401The API key is missing or invalid.
403The 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.
404No business exists for the {slug}, or no booking with that id in this business.
409The 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), the business cannot take card payments now (unavailable), or the booking's client is not messaged (client_messages_off: no link is made, since it would only reach them as a message).
502Stripe or the email did not answer (reason stripe or not_sent). Try again in a moment.
500Unexpected server error (details are never leaked).

POST /appointments/{id}/deposit-request/cancel

Requires API key

Needs the bookings:write scope.

POST /api/v1/{slug}/appointments/{id}/deposit-request/cancel

Stop 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
FieldTypeInRequiredNotes
idstringpathYesThe booking waiting on its deposit.
Errors
StatusWhen
401The API key is missing or invalid.
403The 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.
404No business exists for the {slug}, or no booking with that id in this business.
409The client already paid it (reason paid), or the booking is not waiting on a deposit (not_waiting).
502Stripe did not answer, so the link is still open (reason stripe). Try again in a moment.
500Unexpected server error (details are never leaked).

GET /booking-requests

Requires API key

Needs the bookings:read scope.

GET /api/v1/{slug}/booking-requests

List 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
StatusWhen
401The API key is missing or invalid.
403The 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.
404No business exists for the {slug}.
500Unexpected server error (details are never leaked).

POST /booking-requests/{id}/accept

Requires API key

Needs the bookings:write scope.

POST /api/v1/{slug}/booking-requests/{id}/accept

Accept 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
FieldTypeInRequiredNotes
idstringpathYesThe request's id (from GET /booking-requests).
Errors
StatusWhen
401The API key is missing or invalid.
403The 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.
404No business exists for the {slug}, or no request with that id in this business.
409It 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.
500Unexpected server error (details are never leaked).

POST /booking-requests/{id}/decline

Requires API key

Needs the bookings:write scope.

POST /api/v1/{slug}/booking-requests/{id}/decline

Decline 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
FieldTypeInRequiredNotes
idstringpathYesThe request's id (from GET /booking-requests).
reasonstringbodyNoA note for the client, up to 500 characters.
Errors
StatusWhen
401The API key is missing or invalid.
403The 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.
404No business exists for the {slug}, or no request with that id in this business.
409It has already been answered, or its deposit is still being paid.
422reason is longer than 500 characters (fieldErrors).
500Unexpected server error (details are never leaked).

GET /class-sessions

Requires API key

Needs the bookings:read scope.

GET /api/v1/{slug}/class-sessions

List 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. At a business with two or more branches, each session also has locationId: the branch it is held at (its own, else its class's, else the main branch); its start and end are on that branch's clock. 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
FieldTypeInRequiredNotes
fromstringqueryNoStart 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.
tostringqueryNoEnd of the window, exclusive: YYYY-MM-DD (the start of that day in the business's timezone) or ISO 8601.
serviceIdstringqueryNoOne class (a group service of this business); another id is 404.
statusstringqueryNoscheduled (the default), cancelled or all.
limitnumberqueryNo1 to 200, default 100.
Errors
StatusWhen
401The API key is missing or invalid.
403The 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.
404No business exists for the {slug}, or serviceId is not one of its classes.
422from, to, status or limit is malformed (fieldErrors).
500Unexpected server error (details are never leaked).

GET /class-sessions/{id}/roster

Requires API key

Needs the bookings:read scope.

GET /api/v1/{slug}/class-sessions/{id}/roster

Who 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. At a business with two or more branches, the session also has locationId, as GET /class-sessions lists it. 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
FieldTypeInRequiredNotes
idstringpathYesThe session's id (from GET /class-sessions).
Errors
StatusWhen
401The API key is missing or invalid.
403The 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.
404No business exists for the {slug}, or no class session with that id in this business.
500Unexpected server error (details are never leaked).

POST /class-sessions/{id}/roster

Requires API key

Needs the bookings:write scope.

POST /api/v1/{slug}/class-sessions/{id}/roster

Put 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
FieldTypeInRequiredNotes
idstringpathYesThe session's id.
namestringbodyYesThe client's name, 2 to 80 characters.
emailstringbodyNoTheir email, for their ticket and to match them.
phonestringbodyNoTheir phone number, to match them.
sourcestringbodyNophone (the default) or walk_in.
Errors
StatusWhen
401The API key is missing or invalid.
403The 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.
400The request body is not valid JSON.
404No business exists for the {slug}, or no class session with that id in this business.
409The session is full or cancelled, or they already hold a place on it. Nothing was written.
422The details are malformed (fieldErrors names each: name, email, phone, source).
500Unexpected server error (details are never leaked).

POST /class-sessions/{id}/roster/{enrolmentId}/cancel

Requires API key

Needs the bookings:write scope.

POST /api/v1/{slug}/class-sessions/{id}/roster/{enrolmentId}/cancel

Cancel 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
FieldTypeInRequiredNotes
idstringpathYesThe session's id.
enrolmentIdstringpathYesThe place (from the roster).
Errors
StatusWhen
401The API key is missing or invalid.
403The 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.
404No business exists for the {slug}, no class session with that id, or that place is not on this session.
409The place is marked attended or a no-show (undo the mark first), or was already taken off.
500Unexpected server error (details are never leaked).

POST /class-sessions/{id}/roster/{enrolmentId}/attendance

Requires API key

Needs the bookings:write scope.

POST /api/v1/{slug}/class-sessions/{id}/roster/{enrolmentId}/attendance

Mark 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
FieldTypeInRequiredNotes
idstringpathYesThe session's id.
enrolmentIdstringpathYesThe place (from the roster).
markstringbodyYesattended, no_show or booked.
Errors
StatusWhen
401The API key is missing or invalid.
403The 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.
400The request body is not valid JSON.
404No business exists for the {slug}, no class session with that id, or that place is not on this session.
409The place was cancelled, so it cannot be marked.
422mark is not attended, no_show or booked (fieldErrors).
500Unexpected server error (details are never leaked).

GET /reports/earnings

Requires API key

Needs the reports:read scope.

GET /api/v1/{slug}/reports/earnings

Staff 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
FieldTypeInRequiredNotes
rangestringqueryNothis_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).
fromstringqueryNoFirst day, YYYY-MM-DD, for range=custom.
tostringqueryNoLast day, YYYY-MM-DD, for range=custom (included).
bystringqueryNoday, week (from Monday) or month: the periods to total by. Defaults to week.
staffstringqueryNoOne person's id (GET /staff, or anyone ever on the team). Someone not on this business's team is 404.
Errors
StatusWhen
401The API key is missing or invalid.
403The 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.
404No business exists for the {slug}, or staff is not one of its team.
500Unexpected server error (details are never leaked).

GET /reports/tips

Requires API key

Needs the reports:read scope.

GET /api/v1/{slug}/reports/tips

Tips

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
FieldTypeInRequiredNotes
rangestringqueryNothis_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).
fromstringqueryNoFirst day, YYYY-MM-DD, for range=custom.
tostringqueryNoLast day, YYYY-MM-DD, for range=custom (included).
bystringqueryNoday, week (from Monday) or month: the periods to total by. Defaults to week.
staffstringqueryNoOne person's id. Someone not on this business's team is 404.
Errors
StatusWhen
401The API key is missing or invalid.
403The 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.
404No business exists for the {slug}, or staff is not one of its team.
500Unexpected server error (details are never leaked).

GET /reports/deposits

Requires API key

Needs the reports:read scope.

GET /api/v1/{slug}/reports/deposits

Deposit 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
FieldTypeInRequiredNotes
rangestringqueryNo30d, 90d, month, last_month, year, all; custom with from and to. Defaults to 90d. Days are the business's own (its timezone).
fromstringqueryNoFirst day, YYYY-MM-DD, for range=custom.
tostringqueryNoLast day, YYYY-MM-DD, for range=custom (included).
bystringqueryNoday, week (from Monday) or month: the periods to total by. Defaults to month.
basisstringqueryNotaken (the default: by the day the deposit was taken) or visit (by the visit's day).
Errors
StatusWhen
401The API key is missing or invalid.
403The 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.
404No business exists for the {slug}.
500Unexpected server error (details are never leaked).

GET /audit-log

Requires API key

Needs the audit:read scope, on a key made with it ticked.

GET /api/v1/{slug}/audit-log

The 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
FieldTypeInRequiredNotes
actorstringqueryNouser:<id>, api_key:<id>, app:<id>, client or system.
actionstringqueryNoOne action (booking.cancelled) or a whole area (booking.*).
targetstringqueryNoWhat it was done to: booking, client, invoice, api_key, settings, class, ...
targetIdstringqueryNoThat record's id.
fromstringqueryNoFirst day, YYYY-MM-DD, in the business's timezone.
tostringqueryNoLast day, YYYY-MM-DD, included.
pagenumberqueryNoPage number, from 1.
pageSizenumberqueryNoRows a page, 1 to 200 (default 50). hasMore says whether there is another page.
Errors
StatusWhen
401The API key is missing or invalid.
403The 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.
404No business exists for the {slug}.
500Unexpected server error (details are never leaked).

POST /customers/{id}/tags

Requires API key

Needs the customers:write scope, on a key made with it ticked.

POST /api/v1/{slug}/customers/{id}/tags

Tag a client

Put one of the business's tags on a client, by the tag's id or its name. Changes nothing else about the client. Org-scoped: a client or a tag of another business is 404 and nothing is written. Answers the client's tags as they now stand. Needs the opt-in customers:write scope: only a key made with "Tag clients" ticked carries it, never a key made before scopes or a connected app. A read-only key is refused.

curl -X POST https://bookatu.com/api/v1/your-salon/customers/cus_xyz/tags \
  -H "Authorization: Bearer bk_live_YOUR_KEY" \
  -H "Content-Type: application/json" \
  -d '{ "name": "VIP" }'
Response example
{
  "ok": true,
  "id": "cus_xyz",
  "changed": true,
  "tags": [{ "id": "tag_vip123", "name": "VIP", "color": "indigo" }]
}

// changed is false when nothing needed doing (the tag was already on, or already off).
Request fields
FieldTypeInRequiredNotes
idstringpathYesThe client's id (from GET /customers).
tagIdstringbodyNoThe tag's id (from tags on GET /customers). Send this or name.
namestringbodyNoAn existing tag's name, whatever its case. Tags are made on the Tags page, never through the API. The tag must be for clients (or both).
Errors
StatusWhen
400The body is not valid JSON.
401The API key is missing or invalid.
403The key belongs to a different organisation, it does not carry customers:write (the body names it as missingScope; a key made before scopes never does), or it is read-only.
404No client with that id in this business, or no tag with that id or name (code TAG_NOT_FOUND).
422Neither tagId nor name was sent (code TAG_REQUIRED), or the tag is for bookings only (code TAG_WRONG_KIND).
500Unexpected server error (details are never leaked).

DELETE /customers/{id}/tags/{tagId}

Requires API key

Needs the customers:write scope, on a key made with it ticked.

DELETE /api/v1/{slug}/customers/{id}/tags/{tagId}

Untag a client

Take a tag off a client. Already off answers changed: false. Org-scoped like the POST. Needs the opt-in customers:write scope, on a key made with it ticked.

curl -X DELETE https://bookatu.com/api/v1/your-salon/customers/cus_xyz/tags/tag_vip123 \
  -H "Authorization: Bearer bk_live_YOUR_KEY"
Response example
{
  "ok": true,
  "id": "cus_xyz",
  "changed": true,
  "tags": []
}

// changed is false when nothing needed doing (the tag was already on, or already off).
Parameters
FieldTypeInRequiredNotes
idstringpathYesThe client's id.
tagIdstringpathYesThe tag's id.
Errors
StatusWhen
401The API key is missing or invalid.
403The key belongs to a different organisation, it does not carry customers:write (missingScope), or it is read-only.
404No client with that id in this business, or no tag with that id (code TAG_NOT_FOUND).
500Unexpected server error (details are never leaked).

POST /appointments/{id}/tags

Requires API key

Needs the bookings:write scope.

POST /api/v1/{slug}/appointments/{id}/tags

Tag a booking

Put one of the business's tags on a booking, by the tag's id or its name. Changes nothing else about the booking and sends no email or webhook. Org-scoped: a booking or a tag of another business is 404 and nothing is written. Answers the booking's tags as they now stand. Needs bookings:write; a read-only key is refused.

curl -X POST https://bookatu.com/api/v1/your-salon/appointments/apt_abc123/tags \
  -H "Authorization: Bearer bk_live_YOUR_KEY" \
  -H "Content-Type: application/json" \
  -d '{ "tagId": "tag_bridal456" }'
Response example
{
  "ok": true,
  "id": "apt_abc123",
  "changed": true,
  "tags": [{ "id": "tag_bridal456", "name": "Bridal", "color": "rose" }]
}

// changed is false when nothing needed doing (the tag was already on, or already off).
Request fields
FieldTypeInRequiredNotes
idstringpathYesThe appointment id (from GET /appointments).
tagIdstringbodyNoThe tag's id (from tags on GET /appointments). Send this or name.
namestringbodyNoAn existing tag's name, whatever its case. Tags are made on the Tags page, never through the API. The tag must be for bookings (or both).
Errors
StatusWhen
400The body is not valid JSON.
401The API key is missing or invalid.
403The key belongs to a different organisation, it lacks bookings:write (the body names it as missingScope), or it is read-only.
404No booking with that id in this business, or no tag with that id or name (code TAG_NOT_FOUND).
422Neither tagId nor name was sent (code TAG_REQUIRED), or the tag is for clients only (code TAG_WRONG_KIND).
500Unexpected server error (details are never leaked).

DELETE /appointments/{id}/tags/{tagId}

Requires API key

Needs the bookings:write scope.

DELETE /api/v1/{slug}/appointments/{id}/tags/{tagId}

Untag a booking

Take a tag off a booking. Already off answers changed: false. Org-scoped like the POST. Needs bookings:write.

curl -X DELETE https://bookatu.com/api/v1/your-salon/appointments/apt_abc123/tags/tag_bridal456 \
  -H "Authorization: Bearer bk_live_YOUR_KEY"
Response example
{
  "ok": true,
  "id": "apt_abc123",
  "changed": true,
  "tags": []
}

// changed is false when nothing needed doing (the tag was already on, or already off).
Parameters
FieldTypeInRequiredNotes
idstringpathYesThe appointment id.
tagIdstringpathYesThe tag's id.
Errors
StatusWhen
401The API key is missing or invalid.
403The key belongs to a different organisation, it lacks bookings:write (missingScope), or it is read-only.
404No booking with that id in this business, or no tag with that id (code TAG_NOT_FOUND).
500Unexpected server error (details are never leaked).

GET /sandbox/outbox

Requires API key

Needs the customers:read scope.

GET /api/v1/{slug}/sandbox/outbox

The test outbox (sandbox only)

Test mode: what a sandbox business would have sent, newest first. Every email, text, WhatsApp message (and voice or push message) a sandbox sends, to clients or to its own team, is kept here and never sent: booking confirmations, reminders, receipts, staff alerts, inbox replies, scheduled reports. The newest 500 are kept. Filter by channel and by time, and page with limit and nextCursor. The answer says livemode false. Only for a sandbox (a live business answers 404 NOT_A_SANDBOX), with the sandbox's TEST key (bk_test_): a live key is refused with 403 LIVEMODE_MISMATCH. Requires an API key with customers:read (the messages carry client addresses). 30 calls a minute per sandbox, all its test keys together. Over MCP, the sandbox_outbox tool reads the same, within the same limits.

curl "https://bookatu.com/api/v1/your-test-salon/sandbox/outbox?channel=email&limit=20" \
  -H "Authorization: Bearer bk_test_YOUR_KEY"
Response example
{
  "messages": [
    {
      "id": "sbo_8xKq2...",
      "channel": "email",
      "kind": "booking_confirmation",
      "to": "pat.example@example.com",
      "subject": "You're booked in at Test Salon",
      "text": null,
      "html": "<!doctype html>...",
      "appointmentId": "apt_abc123",
      "customerId": "cus_xyz",
      "createdAt": "2026-10-30T09:15:00.000Z",
      "meta": { "dedupeKey": "booking_confirmation:apt_abc123" }
    },
    {
      "id": "sbo_3Lm9...",
      "channel": "sms",
      "kind": "reminder_24h",
      "to": "+15555550123",
      "subject": null,
      "text": "Test Salon: reminder of your Cut tomorrow at 10:00 am.",
      "html": null,
      "appointmentId": "apt_abc123",
      "customerId": "cus_xyz",
      "createdAt": "2026-10-30T09:14:00.000Z",
      "meta": {}
    }
  ],
  "nextCursor": "MjAyNi0xMC0zMFQwOToxNDowMC4wMDBafHNib18zTG05",
  "livemode": false
}
Parameters
FieldTypeInRequiredNotes
channelstringqueryNoemail, sms, whatsapp, voice or push.
sincestringqueryNoOnly messages kept at or after this ISO 8601 time.
limitnumberqueryNoMessages a page, 1 to 100 (default 50).
cursorstringqueryNoThe nextCursor of the page before, for the next (older) page.
Errors
StatusWhen
401The API key is missing or invalid.
403LIVEMODE_MISMATCH: a live key (bk_live_) sent to a sandbox, or a test key sent to a live business (the body says keyLivemode and businessLivemode). Otherwise: the key belongs to a different business ("Forbidden"), or it lacks the customers:read scope (the body names it as missingScope).
404NOT_A_SANDBOX: the {slug} is a live business, which has no test outbox, no test payments and nothing to simulate. Or no business exists for the {slug}.
422A filter is not valid: channel not one of the five, since not a time, or limit outside 1 to 100 (fieldErrors names each).
429More than 30 calls a minute to this sandbox, all its test keys together (Retry-After says when to try again).
500Unexpected server error (details are never leaked).

POST /sandbox/simulate/{event}

Requires API key

Needs the bookings:write scope.

POST /api/v1/{slug}/sandbox/simulate/{event}

Simulate an event (sandbox only)

Test mode: make something happen to one of a sandbox's bookings, through the same code a real payment, cancellation or clock would run. {event} is one of: deposit.paid, payment.failed, checkout.expired, booking.client_cancelled, booking.no_show, reminders.due. deposit.paid: Pays the booking's open test checkout, as the test checkout page's "Pay (test)" does: checkout.session.completed runs through the Stripe webhook's own handler, the booking is confirmed with its deposit paid, the confirmation goes to the outbox and booking webhooks are sent. payment.failed: Declines the test card on the booking's open checkout, as "Decline (test)": the checkout stays open (as on Stripe's page) and nothing else changes; the answer says so (result.declined true, result.changed false). checkout.expired: Abandons the booking's open checkout, as "Abandon": checkout.session.expired runs, and an online booking held for its deposit is let go (cancelled as deposit_abandoned) and its time offered to the waitlist. booking.client_cancelled: The client's own cancellation (the manage page's), with the business's cancellation window, deposit rule, late-cancellation fee, emails and the booking.cancelled webhook. booking.no_show: Marks the booking a no-show. A no-show fee the cancellation policy sets is queued for the owner, as at a live business (result.fee): nothing charges it but the owner's Charge on the test business's No-show fees screen, which charges the client's saved TEST card: paid for a card saved with "Save test card", declined (card_declined) for one saved with "Save a card that will decline". reminders.due: Time passing: the booking's reminder email and reminder texts are sent as if now were its start minus each lead the business set (reminderHoursBefore, and its own text times). A booking reminded already is not reminded again. The answer gives the booking as it is now, the ids of the outbox messages the event kept (outbox), the webhook events it sent (webhooks) and what the event itself reported (result). Messages sent after the answer (a webhook retry, an email the system sends a moment later) still appear in GET /sandbox/outbox. Only for a sandbox (a live business answers 404 NOT_A_SANDBOX) and only for a booking of that sandbox (any other id is 404 APPOINTMENT_NOT_FOUND), with its TEST key (bk_test_): a live key is refused with 403 LIVEMODE_MISMATCH. Requires an API key with bookings:write. 10 calls a minute and 500 a day per sandbox, all its test keys together. Over MCP, the sandbox_simulate tool takes the same events, within the same limits.

curl -X POST "https://bookatu.com/api/v1/your-test-salon/sandbox/simulate/deposit.paid" \
  -H "Authorization: Bearer bk_test_YOUR_KEY" \
  -H "Content-Type: application/json" \
  -d '{ "appointmentId": "apt_abc123" }'
Response example
{
  "ok": true,
  "event": "deposit.paid",
  "appointment": {
    "id": "apt_abc123",
    "status": "confirmed",
    "depositStatus": "paid",
    "depositCents": 2000,
    "depositPaidCents": 2000,
    "startAt": "2026-11-04T21:00:00.000Z",
    "endAt": "2026-11-04T21:30:00.000Z",
    "customerId": "cus_xyz",
    "checkoutSessionId": "cs_test_sbx_9Fh2..."
  },
  "outbox": ["sbo_8xKq2...", "sbo_Qw71..."],
  "webhooks": ["booking.confirmed"],
  "result": { "checkoutSessionId": "cs_test_sbx_9Fh2..." },
  "livemode": false
}
Request fields
FieldTypeInRequiredNotes
eventstringpathYesdeposit.paid, payment.failed, checkout.expired, booking.client_cancelled, booking.no_show, reminders.due.
appointmentIdstringbodyYesThe id of a booking of this sandbox.
Errors
StatusWhen
400UNKNOWN_SIMULATION: {event} is not one of the six (the body lists them as events). Or the body is not JSON.
401The API key is missing or invalid.
403LIVEMODE_MISMATCH: a live key (bk_live_) sent to a sandbox, or a test key sent to a live business (the body says keyLivemode and businessLivemode). Otherwise: the key belongs to a different business ("Forbidden"), or it lacks the bookings:write scope (the body names it as missingScope).
404NOT_A_SANDBOX: the {slug} is a live business, which has no test outbox, no test payments and nothing to simulate. Or no business exists for the {slug}.
404APPOINTMENT_NOT_FOUND: this sandbox has no booking with that appointmentId (a live business's booking is never found here).
409NO_OPEN_CHECKOUT: deposit.paid, payment.failed or checkout.expired on a booking with no open test checkout (it takes no deposit online, or its checkout is already paid or expired). SIMULATION_REFUSED: the client's cancellation is not allowed (the business's own rules: too late, a closed booking...), with the refusal.
422appointmentId is missing (fieldErrors).
429More than 10 calls a minute, or 500 a day, to this sandbox, all its test keys together (Retry-After says when to try again).
500Unexpected server error (details are never leaked).

GET /sandbox/payments

Requires API key

Needs the reports:read scope.

GET /api/v1/{slug}/sandbox/payments

The test payments (sandbox only)

Test mode: every payment a sandbox business made in test mode, newest first, as its admin's Test payments page lists them. No card was charged and no money moved: the payment simulator made each one, through the same code a real payment runs. kind is one of: checkout, a test checkout (a deposit, a class place, the shop, a gift card, a package, a print, gallery extras or an invoice): paid, declined (the test card was declined and it is still open), abandoned (it expired) or open; payment, the payment a paid checkout made: paid, refunded or partly_refunded; card_hold, a card saved for later at the card step: card_saved, or open while it waits for the card; no_show_charge, a no-show or late-cancellation fee charged to a saved test card: paid, declined, refunded or partly_refunded; refund, a refund of a test payment: refunded. Each has its outcome, amountCents (whole cents of its currency; null for a card hold, which takes no money), refundedCents, currency, description (the checkout's own line items), the booking it belongs to (appointmentId) and the booking or sale it belongs to (belongsTo: booking, class_place, order, gift_card, package, invoice or gallery_photos, with its id when there is one), the checkout and payment it came through, and when it was made. Nothing in it names a client: no name, email or phone. Filter by kind and by time, and page with limit and nextCursor. The answer says livemode false. Only for a sandbox (a live business answers 404 NOT_A_SANDBOX), with the sandbox's TEST key (bk_test_): a live key is refused with 403 LIVEMODE_MISMATCH. Requires an API key with reports:read (the payments are the business's money, as the money reports are). 30 calls a minute per sandbox, all its test keys together. Over MCP, the sandbox_payments tool reads the same, within the same limits.

curl "https://bookatu.com/api/v1/your-test-salon/sandbox/payments?kind=checkout&limit=20" \
  -H "Authorization: Bearer bk_test_YOUR_KEY"
Response example
{
  "payments": [
    {
      "id": "cs_test_sbx_9Fh2...",
      "kind": "checkout",
      "outcome": "paid",
      "amountCents": 2000,
      "refundedCents": 0,
      "currency": "NZD",
      "description": "Deposit · Cut",
      "appointmentId": "apt_abc123",
      "belongsTo": { "type": "booking", "id": "apt_abc123" },
      "checkoutSessionId": "cs_test_sbx_9Fh2...",
      "paymentIntentId": "pi_test_sbx_4Kd8...",
      "createdAt": "2026-10-30T09:15:00.000Z",
      "updatedAt": "2026-10-30T09:16:10.000Z"
    },
    {
      "id": "re_test_sbx_7Qp1...",
      "kind": "refund",
      "outcome": "refunded",
      "amountCents": 2000,
      "refundedCents": 0,
      "currency": "NZD",
      "description": "Refund: Deposit · Colour",
      "appointmentId": "apt_def456",
      "belongsTo": { "type": "booking", "id": "apt_def456" },
      "checkoutSessionId": "cs_test_sbx_2Lm7...",
      "paymentIntentId": "pi_test_sbx_8Hs3...",
      "createdAt": "2026-10-30T09:12:00.000Z",
      "updatedAt": "2026-10-30T09:12:00.000Z"
    }
  ],
  "nextCursor": "MjAyNi0xMC0zMFQwOToxMjowMC4wMDBafHJlX3Rlc3Rfc2J4Xzd",
  "livemode": false
}
Parameters
FieldTypeInRequiredNotes
kindstringqueryNocheckout, payment, card_hold, no_show_charge, refund.
sincestringqueryNoOnly payments made at or after this ISO 8601 time.
limitnumberqueryNoPayments a page, 1 to 100 (default 50).
cursorstringqueryNoThe nextCursor of the page before, for the next (older) page.
Errors
StatusWhen
401The API key is missing or invalid.
403LIVEMODE_MISMATCH: a live key (bk_live_) sent to a sandbox, or a test key sent to a live business (the body says keyLivemode and businessLivemode). Otherwise: the key belongs to a different business ("Forbidden"), or it lacks the reports:read scope (the body names it as missingScope).
404NOT_A_SANDBOX: the {slug} is a live business, which has no test outbox, no test payments and nothing to simulate. Or no business exists for the {slug}.
422A filter is not valid: kind not one of the five, since not a time, or limit outside 1 to 100 (fieldErrors names each).
429More than 30 calls a minute to this sandbox, all its test keys together (Retry-After says when to try again).
500Unexpected server error (details are never leaked).

GET /walk-in

Public
GET /api/v1/{slug}/walk-in

Walk-in wait now

The business's live walk-in wait: the answer its booking page, its walk-in widget and the MCP tool get_walk_in_wait give for the same moment. Worked out from its calendar for today, on its own clock, as a walk-in is served: the team's in-person bookings count and the minimum notice does not apply (online booking keeps it, so the booking page may offer a later first time). Or set by hand by the team (mode manual, with manual.setAt and manual.expiresAt) until it lapses. state is no_wait (the earliest start is within 5 minutes: walk in now), wait (5 to 90 minutes away; waitMin is rounded up to 5), busy (more than 90 minutes away; nextAt says when), full_today (open now, nothing fits before closing), closed (closed now; closed.opensAt says when it opens, null when no opening is in the next 14 days) or not_taking (set by hand). waitMin and nextAt beside state are the shortest wait. busy gives counts only (staffBusy of staffWorking with a client, appointmentsLeftToday) when the business shows them; no client or team member is ever named. services lists up to 12 walk-in services in menu order, each with its own state, waitMin, nextAt and bookUrl (its booking page on today's date; null for a service not booked online). Every time is an instant in UTC; timeZone is the business's clock. A business with walk-in wait times off answers 200 with status off and no numbers. Kept about a minute.

curl https://bookatu.com/api/v1/your-salon/walk-in
Response example
{
  "status": "on",
  "mode": "automatic",
  "state": "wait",
  "waitMin": 20,
  "nextAt": "2026-11-03T01:20:00.000Z",
  "updatedAt": "2026-11-03T01:02:00.000Z",
  "timeZone": "Pacific/Auckland",
  "busy": { "staffBusy": 3, "staffWorking": 4, "appointmentsLeftToday": 6 },
  "services": [
    { "id": "svc_cut", "name": "Cut", "state": "wait", "waitMin": 20, "nextAt": "2026-11-03T01:20:00.000Z", "bookUrl": "https://bookatu.com/your-salon/book?service=svc_cut&date=2026-11-03" },
    { "id": "svc_beard", "name": "Beard trim", "state": "busy", "nextAt": "2026-11-03T03:30:00.000Z", "bookUrl": "https://bookatu.com/your-salon/book?service=svc_beard&date=2026-11-03" }
  ],
  "livemode": true
}

// Turned off: { "status": "off", "livemode": true }
Parameters
FieldTypeInRequiredNotes
serviceIdstringqueryNoOne walk-in service (an id from services); omit for every walk-in service. An id that is not one of this business's walk-in services is 404 SERVICE_NOT_FOUND.
locationIdstringqueryNoOne of this business's branches: the wait at that branch, on its own clock. An id that is not this business's, or names a branch that is currently closed, is ignored rather than rejected, and the answer is the whole business's.
Errors
StatusWhen
404No published business with that slug, or serviceId names no walk-in service of this business (code SERVICE_NOT_FOUND).
429Too many requests from this address (Retry-After says when to try again).
500Unexpected server error (details are never leaked).

PUT /walk-in/manual

Requires API key

Needs the bookings:write scope.

PUT /api/v1/{slug}/walk-in/manual

Set the walk-in wait by hand

Set the walk-in wait by hand, as the team does from the dashboard: minutes (0 to 240; 0 is no wait) or notTaking true, for the whole business (or one branch while branches are on), until it lapses: expiry 1h, 2h or end_of_day (midnight at the end of the business's day), the business's own default when left out. Replaces the wait set before. Within about a minute GET /walk-in, the booking page and MCP give mode manual with manual.setAt and manual.expiresAt (the walk-in widget at its next refresh, within about two), and the booking page says when the team updated it; once it lapses the wait is worked out from the calendar again. Answers the walk-in answer as it now stands. Recorded in the audit log, naming the key. Refused with 409 WALK_IN_OFF while the business has walk-in wait times off. Needs bookings:write; a read-only key is refused.

curl -X PUT https://bookatu.com/api/v1/your-salon/walk-in/manual \
  -H "Authorization: Bearer bk_live_YOUR_KEY" \
  -H "Content-Type: application/json" \
  -d '{ "minutes": 30, "expiry": "2h" }'
Response example
{
  "status": "on",
  "mode": "manual",
  "state": "wait",
  "waitMin": 30,
  "updatedAt": "2026-11-03T01:02:00.000Z",
  "timeZone": "Pacific/Auckland",
  "manual": { "setAt": "2026-11-03T01:02:00.000Z", "expiresAt": "2026-11-03T03:02:00.000Z" },
  "busy": { "staffBusy": 3, "staffWorking": 4, "appointmentsLeftToday": 6 },
  "services": [
    { "id": "svc_cut", "name": "Cut", "state": "wait", "waitMin": 30, "bookUrl": "https://bookatu.com/your-salon/book?service=svc_cut&date=2026-11-03" }
  ],
  "livemode": true
}
Request fields
FieldTypeInRequiredNotes
minutesnumberbodyNoMinutes a walk-in would wait, a whole number from 0 to 240. Send this or notTaking.
notTakingbooleanbodyNotrue: not taking walk-ins right now. Send this or minutes.
expirystringbodyNo1h, 2h or end_of_day. The business's own default (Settings, Walk-ins) when left out.
locationIdstringbodyNoOne of this business's branches: the wait at that branch, on its own clock. An id that is not this business's, or names a branch that is currently closed, is ignored rather than rejected, and the answer is the whole business's.
Errors
StatusWhen
400The body is not valid JSON.
401The API key is missing or invalid.
403The key belongs to a different organisation (or the other mode: LIVEMODE_MISMATCH), it lacks bookings:write (the body names it as missingScope), or it is a read-only key made before scopes.
404No business with that slug.
409Walk-in wait times are turned off for this business (code WALK_IN_OFF).
422Neither or both of minutes and notTaking: true, minutes not a whole number from 0 to 240, or an unknown expiry (fieldErrors names the field).
500Unexpected server error (details are never leaked).

DELETE /walk-in/manual

Requires API key

Needs the bookings:write scope.

DELETE /api/v1/{slug}/walk-in/manual

Back to the automatic walk-in wait

Take off the wait set by hand, for the whole business (or one branch while branches are on), so the wait is worked out from the calendar again at once. Answers the walk-in answer as it now stands, with cleared true when a wait set by hand was in force and false when there was none. Recorded in the audit log, naming the key, when one was cleared. Works while walk-in wait times are off too (the answer is then status off). Needs bookings:write; a read-only key is refused.

curl -X DELETE https://bookatu.com/api/v1/your-salon/walk-in/manual \
  -H "Authorization: Bearer bk_live_YOUR_KEY"
Response example
{
  "cleared": true,
  "status": "on",
  "mode": "automatic",
  "state": "no_wait",
  "waitMin": 0,
  "nextAt": "2026-11-03T01:05:00.000Z",
  "updatedAt": "2026-11-03T01:05:00.000Z",
  "timeZone": "Pacific/Auckland",
  "services": [
    { "id": "svc_cut", "name": "Cut", "state": "no_wait", "waitMin": 0, "nextAt": "2026-11-03T01:05:00.000Z", "bookUrl": "https://bookatu.com/your-salon/book?service=svc_cut&date=2026-11-03" }
  ],
  "livemode": true
}

// cleared is false when no wait set by hand was in force.
Parameters
FieldTypeInRequiredNotes
locationIdstringqueryNoOne of this business's branches: the wait at that branch, on its own clock. An id that is not this business's, or names a branch that is currently closed, is ignored rather than rejected, and the answer is the whole business's.
Errors
StatusWhen
401The API key is missing or invalid.
403The key belongs to a different organisation (or the other mode: LIVEMODE_MISMATCH), it lacks bookings:write (the body names it as missingScope), or it is a read-only key made before scopes.
404No business with that slug.
500Unexpected server error (details are never leaked).

GET /visits/availability

Public
GET /api/v1/{slug}/visits/availability

Open 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. A slot a surcharge covers (a public holiday or a busy time the business charges more for) also has priceCents, surcharges (label, amountType, amount, amountCents) and totalCents, the price at that time, to show the client before they book; other slots have none of these. 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
FieldTypeInRequiredNotes
serviceIdsstringqueryYesTwo to four different services from GET /services, in the order they are done (the first one first). Comma-separated.
fromstringqueryYesStart date, YYYY-MM-DD.
tostringqueryYesEnd date, YYYY-MM-DD. Must be on or after from, and at most 60 days later.
staffIdstringqueryNoThe team member the client picked: they do every service they do, and the rest go to whoever is free. Omit for anyone.
addOnIdsstringqueryNoExtras 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.
locationIdstringqueryNoOne of this business's branches. An id that is not this business's, or names a branch that is currently closed, is ignored rather than rejected.
clientTimeZonestringqueryNoThe 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
StatusWhen
400from/to are not YYYY-MM-DD, from is later than to, or the range exceeds 60 days.
404No business exists for the {slug}.
409Nothing 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 }.
422serviceIds is not two to four services (fieldErrors.serviceIds), or more than 20 addOnIds.
500Unexpected server error (details are never leaked).

POST /visits/book

Public
POST /api/v1/{slug}/visits/book

Book 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), or localTime is sent instead (TIME_DOES_NOT_EXIST, TIME_AMBIGUOUS), 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), so is one with a service the business requires a card saved to hold (409, code CARD_REQUIRED_USE_BOOKING_PAGE, with bookingPageUrl), booking terms the business has set must be accepted (acceptTerms: true, else 422 TERMS_NOT_ACCEPTED with the terms to show), the business's booking questions are answered once for the whole visit as answers (a required one without an answer is 422 ANSWERS_REQUIRED with missingQuestionIds, an unreadable answer 422 ANSWERS_INVALID), 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. A visit booked at a surcharged time (a public holiday or a busy time the business charges more for) also has priceCents (the visit's total, the surcharge included) and surcharge: { label, amountCents }; a visit without one has neither. 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, as is one the business requires
// a card saved to hold (409, code CARD_REQUIRED_USE_BOOKING_PAGE, with
// bookingPageUrl). At a business with booking terms, a visit without
// acceptTerms: true is a 422 with code TERMS_NOT_ACCEPTED and the terms. At a
// business with booking questions, a visit without a required answer is a 422
// with code ANSWERS_REQUIRED and missingQuestionIds.
Request fields
FieldTypeInRequiredNotes
serviceIdsstring[]bodyYesTwo to four different services from GET /services, in the order they are done (the first one first).
startAtstringbodyNoAn instant with its offset (RFC 3339), such as a startAt from GET /availability ("2026-06-10T09:00:00.000Z") or "2026-06-10T21:00:00+12:00", in whole minutes. Read strictly: no offset is a 422 with code TIME_NEEDS_OFFSET and an example on the business's clock; a date that does not exist, seconds other than :00 or anything else is a 422 with code TIME_INVALID. Nothing is booked or moved either way. Send startAt or localTime, exactly one.
localTimestringbodyNoInstead 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.
timeZonestringbodyNoWith localTime only: the IANA zone of a place it is on (America/Toronto). Abbreviations, offsets and Etc/ zones are a 422 UNKNOWN_TIME_ZONE.
namestringbodyYesCustomer name, 2–80 characters (trimmed).
emailstringbodyYesA valid email address.
phonestringbodyYes6–30 characters; digits, spaces and + ( ) - only.
notesstringbodyNoFree text, max 500 characters, written on every service of the visit.
marketingConsentobjectbodyNoOnly 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.
staffIdstringbodyNoThe team member the client picked, as asked of GET /visits/availability: they do every service they do. Omit for anyone.
addOnIdsstring[]bodyNoExtras for the first service (ids from its addOns), up to 20, held to the business's combination rules.
locationIdstringbodyNoOne of this business's branches. An id that is not this business's, or names a branch that is currently closed, is ignored rather than rejected.
acceptTermsbooleanbodyNotrue once the person has read and accepted the business's booking terms. Required, as true, while the business has set terms: without it the answer is 422 with code TERMS_NOT_ACCEPTED and terms ({ text, version }) to show them, and nothing is booked. Show terms.text as plain text. A business with no terms never asks, and ignores it.
termsVersionstringbodyNoThe terms.version the person was shown (the SHA-256 of the text). When sent and it is not the version in force (the business changed its terms meanwhile), the answer is the same 422 TERMS_NOT_ACCEPTED with the terms in force, so they are shown again.
answersobjectbodyNoThe person's answers to the business's booking questions, as { [questionId]: answer }, the questions GET /services lists on each service (questions). A string for text (up to 300 characters), textarea (up to 2000), radio (one of its options), number (a plain decimal) and date (YYYY-MM-DD); a list of its options for checkboxes; true or false for yes_no. Every required question must be answered: else the answer is 422 with code ANSWERS_REQUIRED and missingQuestionIds, and nothing is booked. An answer that cannot be read is 422 with code ANSWERS_INVALID and invalidAnswers ({ [questionId]: too_long | not_an_option | not_a_number | not_a_date | not_yes_or_no | bad_value }). An answer to a question the booking does not ask is ignored. The answers are kept for the business only: never echoed back, never in a webhook. A business with no questions never asks, and ignores it.
clientTimeZonestringbodyNoThe 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
StatusWhen
400The request body is not valid JSON.
404No business exists for the {slug}.
409Nothing 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), CARD_REQUIRED_USE_BOOKING_PAGE (the business requires a card saved to hold it, which only the booking page at bookingPageUrl can do) or BOOKING_PAUSED (the business is not taking online bookings right now).
422Validation 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. At a business with booking terms, a visit without acceptTerms: true (or with a termsVersion that is not the one in force) has code TERMS_NOT_ACCEPTED and terms { text, version } to show. At a business with booking questions, a required question left without an answer has code ANSWERS_REQUIRED and missingQuestionIds (the ids, in the order asked); an answer that cannot be read has code ANSWERS_INVALID and invalidAnswers. Nothing is booked.
429Too many booking attempts (the same caps as POST /book: per address, and per business a day). The response carries Retry-After.
500Unexpected server error (details are never leaked).

POST /visits

Requires API key

Needs the bookings:write scope.

POST /api/v1/{slug}/visits

Book 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
FieldTypeInRequiredNotes
servicesobject[]bodyYesTwo 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).
startAtstringbodyNoAn 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.
namestringbodyYesCustomer name, 2–80 characters (trimmed).
emailstringbodyYesA valid email address.
phonestringbodyYes6–30 characters; digits, spaces and + ( ) - only.
notesstringbodyNoFree text, max 500 characters, written on every service of the visit.
marketingConsentobjectbodyNoOnly 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.
localTimestringbodyNoInstead 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.
timeZonestringbodyNoWith localTime only: the IANA zone of a place it is on (America/Toronto). Abbreviations, offsets and Etc/ zones are a 422 UNKNOWN_TIME_ZONE.
locationIdstringbodyNoOne of this business's branches. An id that is not this business's, or names a branch that is currently closed, is ignored rather than rejected.
Errors
StatusWhen
400The request body is not valid JSON.
401The API key is missing or invalid.
403The 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.
404No business exists for the {slug}.
409Nothing 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.
422Validation failed: fieldErrors names each field (services, startAt, name, email, phone, marketingConsent).
500Unexpected server error (details are never leaked).

GET /visits/{id}

Requires API key

Needs 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. A visit booked with a surcharge also has surcharge: { label, amountCents }, part of priceCents. 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
FieldTypeInRequiredNotes
idstringpathYesThe visit id (vis_…): a booking's visitId, or POST /visits's.
Errors
StatusWhen
401The API key is missing or invalid.
403The 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.
404No business exists for the {slug}, or no visit with that id in this business (the same 404 either way).
500Unexpected server error (details are never leaked).

POST /visits/{id}/reschedule

Requires API key

Needs the bookings:write scope.

POST /api/v1/{slug}/visits/{id}/reschedule

Move 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
FieldTypeInRequiredNotes
idstringpathYesThe visit id.
startAtstringbodyNoAn 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.
localTimestringbodyNoInstead 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.
timeZonestringbodyNoWith 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
StatusWhen
400The request body is not valid JSON.
401The API key is missing or invalid.
403The 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.
404No business exists for the {slug}, or no visit with that id in this business.
409The 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.
422startAt is missing, is not a parseable ISO 8601 date-time, or is in the past (fieldErrors.startAt).
500Unexpected server error (details are never leaked).

POST /visits/{id}/cancel

Requires API key

Needs the bookings:write scope.

POST /api/v1/{slug}/visits/{id}/cancel

Cancel 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
FieldTypeInRequiredNotes
idstringpathYesThe visit id.
reasonstringbodyNoOptional reason, up to 200 characters, stored on each service.
Errors
StatusWhen
400The request body is not valid JSON.
401The API key is missing or invalid.
403The 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.
404No business exists for the {slug}, or no visit with that id in this business.
409The visit could not be cancelled (error says why).
500Unexpected server error (details are never leaked).

Check out from another point of sale (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 point of sale had taken it.

  1. Make an API key with the checkout scopes ticked (See what a booking comes to at checkout, and Check bookings out from another point of sale), and See bookings so your point of sale can list the day's bookings with GET /appointments. Add See clients if your point of sale should get the client's name and contact details.
  2. 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).
  3. 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 point of sale uses. Retrying with the same externalSaleId is always safe.
  4. Subscribe a webhook endpoint to booking.checked_out to hear about every checkout, including the ones taken at Bookatu's own point of sale, so your point of sale 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.

  1. 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).
  2. 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.
  3. 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.
  4. 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.

  1. 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.
  2. 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.
  3. 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.
  4. 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.

Website widget: events, deep links and allowed sites

A business puts booking on its own website with one script, /embed.js (or the pinned /embed.v1.js, which never changes and can be loaded with the integrity hash shown in the admin's Website widget page). The widget runs in an iframe from our origin and tells the page it sits on what happens inside it, without ever handing over the client's name, email, phone, notes, the appointment id or a manage key.

  1. Listen for window events named bookatu: followed by the event, or, with Google Tag Manager, dataLayer events named bookatu_ followed by the event, with the details under bookatu. Version 1 has these events: ready, service_selected, staff_selected, time_selected, booked, request_sent, handoff, error, closed. A handoff says why the visitor went to a new tab (deposit, card_required, multi_service, waitlist); an error carries one fixed code (generic, slot, details, phone, paused, velocity, deposit, staff, not_enabled, unavailable).
  2. Every event has v (1), event, slug and dedupeId. Where they apply it also has ref (an opaque booking reference that is not the appointment id and opens nothing), service with id and name, staffId, branchId, start (RFC 3339, UTC), timeZone (an IANA name), value (minor units) with currency, and deposit. A booking fires once per dedupeId, even when its confirmation screen reloads.
  3. With data-track="gtag" or data-track="meta" on the snippet, the widget also calls the Google tag or the Meta pixel the page has already loaded (a booking is the Meta standard event Schedule). It never loads a tag itself, so the page's own consent banner decides whether any of them run. The names from before version 1, bookatu:booked and bookatu_booking_complete, still fire, once per booking and, as before, for a request too (its event field then says request_sent).
  4. Deep links work in every mode: data-service, data-staff, data-branch, data-date (YYYY-MM-DD), data-promo and data-lang. The booking page reads service, staff, branch, date and promo as query parameters too. The page's utm_source, utm_medium, utm_campaign, utm_term and utm_content go onto the booking's lead source, cleaned and shortened; a gclid or fbclid only as the marker +gclid or +fbclid, never the id.
  5. When the business sets allowed domains, every widget page sends a Content-Security-Policy frame-ancestors naming exactly those sites and their subdomains, so browsers refuse to frame any step anywhere else, and the widget posts its messages only to the allowed site's origin.

Webhooks

Signed

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

EventWhen it fires
booking.createdA booking is created (online, admin or via the API).
booking.cancelledA booking is cancelled (by staff, the customer, or the API).
booking.completedA booking is marked complete after the visit.
booking.rescheduledA booking is moved to a new time (startAt is the new time).
order.paidA shop order is paid (fires once per order; the buyer's contact details are never in the payload).
form.submittedA client completes an intake / consent form (fires once per response; answers and signature never leave the admin dashboard).
campaign.sentAn email campaign finishes sending (from the dashboard or the scheduler; recipient addresses are never in the payload). audience is the segment it went to: all, recent, lapsed, vip, or tag for a "Clients tagged" audience, which never says which tag (tags are the team's internal labels).
booking.checked_outA booking is checked out, at Bookatu's own point of sale 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/1
  • Content-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");
  const a = Buffer.from(expected);
  const b = Buffer.from(String(v1));
  // timingSafeEqual throws on buffers of different lengths: refuse those.
  return a.length === b.length && crypto.timingSafeEqual(a, b);
}

Rate limits

Every answer of the v1 REST API and the MCP servers says where the call stands: RateLimit-Limit, RateLimit-Remaining and RateLimit-Reset (in seconds) for the window closest to its limit, and RateLimit-Policy naming every window it was counted against, for example 30;w=60, 2000;w=86400. Each key is limited on its own and with its business's other keys, per minute and per day, at its plan's numbers; the keyless endpoints are limited per address at each business; test keys and sandboxes have lower limits whatever the plan. A call past a limit is answered 429 with Retry-After: wait that long, and back off with jitter on repeated 429 and 5xx answers. The full table, the raised tier and how to back off are at /developers/rate-limits. The customer booking-management family under /booking/{id} is limited per IP, and each of those endpoints states its own budget.

MCP server

AI agents

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

AI agent · MCP
POST /api/mcp · JSON-RPC 2.0
Live
Claude · any MCP-capable agent
Find me a cut & style at Maison Vera on Thursday afternoon
list_services
…
get_availability(serviceId, from, to)
…
create_booking
…
Confirmed · ref bk_7f3a · deposit link sent
Powered by the Bookatu MCP server

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":{}}'
ToolDescriptionScope
list_servicesList 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, and the booking questions it asks (questions, in the order asked, as GET /services lists them) at a business that asks any.catalog:read
get_availabilityGet 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. A slot a surcharge covers (a public holiday or a busy time the business charges more for) also has priceCents, surcharges (label, amountType, amount, amountCents) and totalCents, the price at that time, to show the client before they book; other slots have none of these.availability:read
create_bookingBook an appointment for a customer; returns a ref. A service that takes a deposit is refused with DEPOSIT_NEEDS_BOOKING_PAGE and bookingPageUrl (the business's booking page, where the customer books it and pays the deposit), and nothing is booked. 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. Optional answers ({ questionId: answer }) answers the business's booking questions (list_services lists them on each service), read exactly as answers on POST /book: a required question left out is refused with ANSWERS_REQUIRED and missingQuestionIds, an answer that cannot be read with ANSWERS_INVALID and invalidAnswers, and nothing is booked; ask the person and send their answers, or send them to bookingPageUrl. The answers are never echoed back or sent in a webhook. 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_customersList all customers in the org.customers:read
list_appointmentsList 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_staffList the bookable team members: id, name, title, active and bookable.staff:read
reschedule_bookingMove 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_bookingCancel a booking, as the business. The slot goes back on offer and the client is sent no message about it; the reply says whether a paid deposit was kept under the business's notice policy. Requires a read-write key.bookings:write
list_productsList every retail product in the catalog, active and inactive.catalog:read
create_productAdd a retail product (prices in dollars; optional tracked stock with an opening-stock ledger entry). Requires a read-write key.catalog:write
update_productUpdate only the provided fields of a product; a stock change records a ledger adjustment. Requires a read-write key.catalog:write
receive_stockRecord a delivery into a stock-tracked product and log the restock movement. Requires a read-write key.catalog:write
create_serviceAdd a standard bookable service (duration in minutes, prices in dollars). Requires a read-write key.catalog:write
update_serviceUpdate only the provided fields of a service. Requires a read-write key.catalog:write
blog_list_postsList the business's blog posts (drafts and published), newest first — lean rows without bodies; optional status filter.content:read
blog_get_postGet one blog post in full, markdown body included, by id or slug (drafts visible to the owner).content:read
blog_create_postWrite 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_postUpdate 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_postPublish 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_postTake a post off the public blog (back to draft), keeping its content and original publish date. Requires a read-write key.content:write
forms_listList 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_getGet one form in full, including its complete field schema and the services it is required before.content:read
forms_createCreate 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_updateUpdate 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_serviceRequire 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_serviceStop 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_responsesSummary 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_listList email campaigns (drafts through sent) — lean rows with status, audience segment and counts, never bodies.content:read
campaigns_getGet one campaign in full, body markup included. Audiences are segment names; recipient email addresses are never returned.content:read
campaigns_create_draftDraft 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_reportBusiness 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_findingsRanked, data-computed findings about the business (severity, headline, detail, suggested action) — what the numbers mean, not prose. Read-only.reports:read
get_capacityChair 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_listList the business's webhook endpoints: url, subscribed events, active flag and last delivery status. Signing secrets are never returned.webhooks:read
webhooks_createRegister 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_disableSwitch 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_requestsList 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_requestAccept 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_requestDecline 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_sessionsList the business's class sessions, soonest first, with capacity, enrolledCount and spotsLeft; by default the scheduled ones still to start. At a business with two or more branches, each session also has locationId: the branch it is held at (its own, else its class's, else the main branch); its start and end are on that branch's clock. Times also come as timeZone plus start and end When objects, with the offset in force at each instant.bookings:read
get_class_rosterWho 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_attendeePut 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_placeTake 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_attendanceMark a place attended, no_show, or back to booked. Requires a read-write key.bookings:write
send_deposit_linkSend 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_requestStop 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_earningsStaff 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_tipsTips 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_reportDeposit 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_logThe 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_checkoutWhat a booking comes to at checkout, for a point of sale 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_availabilityThe 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. A slot a surcharge covers (a public holiday or a busy time the business charges more for) also has priceCents, surcharges (label, amountType, amount, amountCents) and totalCents, the price at that time, to show the client before they book; other slots have none of these. Read-only.availability:read
create_visitBook a visit of several services as the booking page does: the whole visit or none of it, one confirmation and, where the business approves online bookings, one request. Only at a business that lets clients book several services in one visit. A visit that takes a deposit is refused with DEPOSIT_NEEDS_BOOKING_PAGE and bookingPageUrl (the business's booking page), and nothing is booked. Optional answers ({ questionId: answer }) answers the business's booking questions (list_services lists them on each service), read exactly as answers on POST /book: a required question left out is refused with ANSWERS_REQUIRED and missingQuestionIds, an answer that cannot be read with ANSWERS_INVALID and invalidAnswers, and nothing is booked; ask the person and send their answers, or send them to bookingPageUrl. The answers are never echoed back or sent in a webhook. Each question is asked once for the whole 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_visitRead 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_visitMove 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_visitCancel 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_businessThe 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
get_walk_in_waitThe business's walk-in wait now, the same answer as GET /walk-in: worked out from its calendar as a walk-in is served, or set by hand by the team. state is no_wait (the earliest start is within 5 minutes: walk in now), wait (5 to 90 minutes away; waitMin is rounded up to 5), busy (more than 90 minutes away; nextAt says when), full_today (open now, nothing fits before closing), closed (closed now; closed.opensAt says when it opens, null when no opening is in the next 14 days) or not_taking (set by hand). status off (with message) while the business has it turned off, and no numbers then. Counts only, never names. Read-only.availability:read
set_walk_in_waitSet the walk-in wait by hand (minutes 0 to 240, or notTaking), until it expires (1h, 2h or end_of_day; the business's default when left out), as PUT /walk-in/manual does. Refused as WALK_IN_OFF while turned off. Audited. Requires a read-write key.bookings:write
clear_walk_in_waitBack to the automatic walk-in wait now, as DELETE /walk-in/manual does; cleared says whether a wait set by hand was in force. Audited. Requires a read-write key.bookings:write
# 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.

Sandbox-only tools

A sandbox (test mode) business lists four more tools, never shown at a live business: sandbox_outbox reads the messages it kept instead of sending, sandbox_payments reads the payments it made in test mode, sandbox_simulate makes a payment, a cancellation, a no-show or a reminder happen to one of its bookings, and sandbox_reset wipes it and seeds it again (only with its own test key). Every successful tool result says livemode in structuredContent: false at a sandbox, true at a live business. Every tool result, a refusal included, says it in result._meta["com.bookatu/livemode"], and every answer in the Bookatu-Livemode header.

ToolDescriptionScope
sandbox_outboxTest mode only: listed at a sandbox (test) business. Read the sandbox's test outbox: every email, text, WhatsApp message, call and push notification it would have sent to a client or its team, kept and never sent, newest first. The same messages, filters and cursor as GET /api/v1/{slug}/sandbox/outbox: optional channel (email, sms, whatsapp, voice or push), since (an ISO 8601 time), limit (1 to 100, default 50) and cursor (the nextCursor of the page before). Each message has id, channel, kind (such as booking_confirmation or reminder_24h), to, subject, text, html, appointmentId, customerId, createdAt and meta. Use it to check what a client was told after a booking, a change or a cancellation. Needs customers:read.customers:read
sandbox_paymentsTest mode only: listed at a sandbox (test) business. Read the sandbox's test payments: every payment it made in test mode, newest first, with nothing charged and no money moved. kind is checkout (a test checkout: paid, declined, abandoned or open), payment (the payment a paid checkout made: paid, refunded or partly_refunded), card_hold (a card saved for later: card_saved or open), no_show_charge (a no-show or late-cancellation fee charged to a saved test card: paid, declined, refunded or partly_refunded) or refund (refunded). The same payments, filters and cursor as GET /api/v1/{slug}/sandbox/payments: optional kind, since (an ISO 8601 time), limit (1 to 100, default 50) and cursor (the nextCursor of the page before). Each payment has id, kind, outcome, amountCents (null for a card hold), refundedCents, currency, description, appointmentId, belongsTo (the booking or sale it belongs to: type and id), checkoutSessionId, paymentIntentId, createdAt and updatedAt, and never a client's name, email or phone. Use it to check what a test checkout, a refund or a no-show charge did. Needs reports:read.reports:read
sandbox_simulateTest mode only: listed at a sandbox (test) business. Make one event happen now to one of the sandbox's own bookings, through the same code as the real thing: deposit.paid (its open test checkout is paid), payment.failed (the test card is declined and nothing else changes), checkout.expired (the checkout is abandoned and a booking held for its deposit is let go), booking.client_cancelled (the client's own cancellation, under the business's rules), booking.no_show (marked a no-show; a fee the policy sets waits for the owner) or reminders.due (its reminders go out as if their time had come). Takes event and appointmentId (from create_booking or list_appointments). Answers the booking as it is now, the ids of the outbox messages the event kept (read them with sandbox_outbox), the webhook events it sent and what the event reported. A booking of any other business is APPOINTMENT_NOT_FOUND. The same events, refusals and limits as POST /api/v1/{slug}/sandbox/simulate/{event}. Needs bookings:write.bookings:write
sandbox_resetTest mode only: listed at a sandbox (test) business, to its own test key. Wipe the sandbox and fill it with fresh sample data: every booking, client, service and message made since is gone. It keeps its id, slug, test keys, webhook endpoints and connected apps, so this key goes on working. The same reset as POST /api/developer/sandboxes/{id}/reset with the sandbox's test key: only a bk_test_ key of this sandbox with bookings:write, never a connected app. Answers the sandbox: id, slug, name, timezone, currency, createdAt, lastActiveAt, expiresAt, adminUrl, storefrontUrl and apiBaseUrl. Ask the person before you reset.bookings:write

How to point an agent at a sandbox, from Claude Desktop, Cursor or the MCP Inspector, and what to ask it: Test your AI agent.

Docs MCP server

No key needed

A 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
ToolDescription
search_helpSearch 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_articleGet 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_blogSearch 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_postGet one blog or engineering post in full by its slug, as markdown, with its author, dates, tags and page.
search_developer_docsSearch 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.1

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

  1. In Claude, open Settings → Connectors → Add custom connector.
  2. Enter https://bookatu.com/api/mcp as the server URL and continue.
  3. Claude sends you to Bookatu to sign in. You must be an owner or admin of the business you connect.
  4. Choose the business, the access level (read and write, or read only) and whether the app may see your clients' names, emails and phone numbers: a box on the consent page that stays unticked unless you tick it. Then approve.

Without that box ticked, list_customers answers 403 naming customers:read, and booking requests, class rosters and form responses come without the client's details. To change your answer later, connect the app again.

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 loop

An 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 a live business's MCP surface; a sandbox's sandbox_reset wipes only its own made-up test data), 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.

Start building

Your first booking is a curl away.

Create a free account and generate your first API key from the admin dashboard.