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.

Authentication

The public endpoints (business, services, staff, availability, book) require no authentication and can be called from any origin. The key-protected endpoints (customers, appointments) and the MCP server need an API key issued from your admin dashboard at /{slug}/admin/api.

The customer booking-management endpoints take no API key: the appointment id IS the capability. Ids are apt_ followed by a 16-character nanoid — about 96 bits from a 64-character alphabet, the same order of unguessability as a magic link — and are only ever handed to the person who booked (their confirmation email and booking-success screen). They are also scoped to the business in the URL, so a valid id from one business can never be read or acted on through another's. Treat a booking id like a password: it lets the holder view, move or cancel that one booking, and nothing else. Every one of these endpoints is rate limited per IP, returns only what the person who booked already knows, and answers a 404 for anything that is not this business's booking.

Pass the key as a Bearer token in the Authorization header, or in the X-Api-Key header.

Authorization: Bearer bk_live_YOUR_KEY
X-Api-Key: bk_live_YOUR_KEY
  • Keys are issued from the admin dashboard and shown exactly once at creation. Store them in a secret manager or environment variable; they cannot be retrieved again.
  • A key is a random token (bk_live_ followed by a 32-character id). Only its SHA-256 hash is stored, and verification uses a timing-safe comparison.
  • Keys support an optional expiry, are checked against revocation on every request, and record a lastUsedAt timestamp so you can audit usage.
  • Each key has a scope: read-write (full access) or read-only. A read-only key may call the GET endpoints and read-only MCP tools, but write endpoints — creating or cancelling a booking — return 403. Issue read-only keys for integrations that only need to read your data.
  • Revoke a key immediately from the dashboard if it is ever exposed. Revoked and expired keys stop authenticating at once.

Keep keys server-side

The public v1 routes are CORS-open, so a browser can reach those URLs. Never embed a bk_live_ key in browser or mobile-client code — call key-protected endpoints from your own server.

Base URL & CORS

https://bookatu.com/api/v1/{slug}

Replace {slug} with the business URL slug visible in your admin and public booking link. All responses are application/json. Errors always return { ok: false, error: "..." } (validation errors also include a fieldErrors map). Stack traces are never leaked.

The public endpoints (business, services, staff, availability, book) return Access-Control-Allow-Origin: * and answer the CORS preflight (OPTIONS) with 204, so any site or agent can call them directly from a browser. Key-protected endpoints deliberately do NOT send the wildcard — their allowed CORS origin is the platform's own origin, because business data has no legitimate cross-origin browser use. The customer booking-management endpoints (everything under /booking/{id}) do not send it either, and answer no preflight: they are authorised by the booking id itself, which has no business being replayed from a third-party page. Neither restriction affects server-to-server or native mobile callers, which are not subject to browser CORS at all. Never embed a bk_live_ key in browser or mobile-client code — call key-protected endpoints from your own server.

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

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. 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. No authentication required.

curl https://bookatu.com/api/v1/your-salon/services
Response example
{
  "org": {
    "name": "Parnell Nails",
    "slug": "parnell-nails",
    "currency": "NZD",
    "timezone": "Pacific/Auckland"
  },
  "services": [
    {
      "id": "svc_abc123",
      "name": "Gel Manicure",
      "description": "Long-lasting gel polish with cuticle care.",
      "category": "Nails",
      "durationMin": 60,
      "priceCents": 7500,
      "currency": "NZD"
    }
  ]
}

// description is the service description, or null when none is set.
Errors
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, buffer times, resources and minimum booking notice. Each slot's startAt is a UTC ISO 8601 string ready to pass straight to a booking call. No authentication required. This is the read for a NEW booking: to move an existing one, use GET /booking/{id}/availability instead, because this read counts that booking's own slot as busy.

curl "https://bookatu.com/api/v1/your-salon/availability?serviceId=svc_abc123&from=2026-06-10&to=2026-06-14"
Response example
{
  "org": { "name": "Parnell Nails", "slug": "parnell-nails", "timezone": "Pacific/Auckland" },
  "days": [
    {
      "date": "2026-06-10",
      "open": true,
      "slots": [
        { "startMin": 540, "startAt": "2026-06-09T21:00:00.000Z" }
      ]
    },
    { "date": "2026-06-11", "open": false, "slots": [] }
  ]
}

// startMin is minutes from local midnight; startAt is the UTC instant to book.
Parameters
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, and each slot then names the tables that fit (resourceIds), smallest first — pass one straight back as resourceId when booking. Omit to apply no capacity filter.
Errors
StatusWhen
400serviceId is missing, from/to are not YYYY-MM-DD, from is later than to, or the range exceeds 60 days.
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. The slot is validated server-side (race-condition safe), a customer record is matched by email then phone or created, and a booking reference is returned. As an online-source booking it fully respects availability and minimum notice. When a deposit is required the booking is pending and the response includes a payLink to surface to the customer. No authentication required.

curl -X POST https://bookatu.com/api/v1/your-salon/book \
  -H "Content-Type: application/json" \
  -d '{
    "serviceId": "svc_abc123",
    "startAt": "2026-06-10T09:00:00Z",
    "name": "Alex Smith",
    "email": "alex@example.com",
    "phone": "+64 21 555 0100"
  }'
Response example
{
  "ok": true,
  "ref": "VRD-7QK2M9",
  "appointmentId": "apt_abc123",
  "status": "confirmed",
  "needsPayment": false
}

// When a deposit is required, status is "pending" and a payLink is returned:
{
  "ok": true,
  "ref": "VRD-7QK2M9",
  "appointmentId": "apt_abc123",
  "status": "pending",
  "needsPayment": true,
  "payLink": "https://bookatu.com/your-salon/book/success?apt=apt_abc123"
}
Request fields
FieldTypeInRequiredNotes
serviceIdstringbodyYesAn active, online-bookable service id from GET /services.
startAtstringbodyYesISO 8601 date-time, e.g. a startAt value from GET /availability ("2026-06-10T09:00:00Z").
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. 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.
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 slot was just taken, or the service is unavailable / inactive.
404No business exists for the {slug}.
500Unexpected server error.

GET /booking/{id}

Authorised by the booking id

No API key. Authorised by the appointment id in the path.

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

Read one booking (the customer's own)

The JSON twin of the manage-booking page a customer reaches from their confirmation email: their booking, its service, its team member, its price and deposit, plus the business's cancellation policy. Authorised by the appointment id alone — no API key, no account — and deliberately narrow: only what the person who booked already knows. No customer record, no notes, no other bookings. Rate limited to 30 requests per IP every 5 minutes.

curl https://bookatu.com/api/v1/your-salon/booking/apt_abc123
Response example
{
  "org": {
    "slug": "parnell-nails",
    "name": "Parnell Nails",
    "currency": "NZD",
    "timezone": "Pacific/Auckland"
  },
  "booking": {
    "id": "apt_abc123",
    "ref": "VRD-7QK2M9",
    "status": "confirmed",
    "startAt": "2026-06-10T21:00:00.000Z",
    "endAt": "2026-06-10T22:00:00.000Z",
    "durationMin": 60,
    "service": "Gel Manicure",
    "staff": "Anna",
    "priceCents": 7500,
    "depositCents": 2000,
    "addOns": [{ "name": "Nail art", "priceCents": 1500 }]
  },
  "policy": {
    "cancellationWindowHours": 24,
    "forfeitDepositOnLateCancel": true
  }
}

// policy is the business's cancellation policy: cancelling or moving more than
// cancellationWindowHours before the start is free, and inside that window a
// paid deposit is kept when forfeitDepositOnLateCancel is true. ref and staff
// are null when unset; addOns is [] when there are none.
Parameters
FieldTypeInRequiredNotes
idstringpathYesThe appointment id, as returned by POST /book (apt_…).
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 appointment id in the path.

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 appointment id alone. Rate limited to 30 requests per IP every 5 minutes.

curl "https://bookatu.com/api/v1/your-salon/booking/apt_abc123/availability?from=2026-06-10&to=2026-06-14"
Response example
{
  "org": { "slug": "parnell-nails", "name": "Parnell Nails", "timezone": "Pacific/Auckland" },
  "booking": { "id": "apt_abc123", "durationMin": 60 },
  "days": [
    {
      "date": "2026-06-10",
      "open": true,
      "slots": [
        { "startMin": 540, "startAt": "2026-06-09T21:00:00.000Z" }
      ]
    },
    { "date": "2026-06-11", "open": false, "slots": [] }
  ]
}

// booking.durationMin is the length being fitted (the booking's, not the
// service's default). startMin is minutes from local midnight; startAt is the
// UTC instant to send back when rescheduling.
Parameters
FieldTypeInRequiredNotes
idstringpathYesThe appointment id being moved.
fromstringqueryYesStart date, YYYY-MM-DD.
tostringqueryYesEnd date, YYYY-MM-DD. Must be on or after from, and at most 60 days later.
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 appointment id in the path.

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. 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.
startAtstringbodyYesThe new start time as an ISO 8601 date-time, e.g. a startAt from GET /booking/{id}/availability. Must be in the future.
Errors
StatusWhen
400The request body is not valid JSON.
422startAt is missing, is not a parseable ISO 8601 date-time, or is in the past.
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.
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 appointment id in the path.

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 and the waitlist re-offer are identical however it is triggered. The forfeited flag says whether a paid deposit was kept under the policy, which an app needs in order to tell the person the truth on the confirmation screen. Cancelling an already-cancelled booking is idempotent (alreadyCancelled: true), so a retry after a dropped connection is safe. Rate limited to 10 requests per IP every 10 minutes.

curl -X POST https://bookatu.com/api/v1/your-salon/booking/apt_abc123/cancel
Response example
{ "ok": true, "alreadyCancelled": false, "forfeited": false }

// forfeited is true when a paid deposit was kept under the cancellation policy
// (see policy on GET /booking/{id}).
Parameters
FieldTypeInRequiredNotes
idstringpathYesThe appointment id to cancel.
Errors
StatusWhen
409The booking could not be cancelled — the response carries the reason.
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
GET /api/v1/{slug}/customers

List customers

Up to 1000 customers in this org (id, name, email, phone), ordered by name. Requires a valid API key whose org matches the {slug}.

curl https://bookatu.com/api/v1/your-salon/customers \
  -H "Authorization: Bearer bk_live_YOUR_KEY"
Response example
{
  "customers": [
    { "id": "cus_xyz", "name": "Alex Smith", "email": "alex@example.com", "phone": "+64211234567" }
  ]
}

// Capped at 1000 customers, ordered by name.
Errors
StatusWhen
401The API key is missing or invalid.
403The key belongs to a different organisation.
404No business exists for the {slug}.
500Unexpected server error.

GET /appointments

Requires API key
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. Requires a valid API key scoped to this org.

curl "https://bookatu.com/api/v1/your-salon/appointments?status=completed&from=2026-06-01&limit=50" \
  -H "Authorization: Bearer bk_live_YOUR_KEY"
Response example
{
  "appointments": [
    {
      "id": "apt_abc123",
      "ref": "VRD-7QK2M9",
      "serviceName": "Gel Manicure",
      "startAt": "2026-06-10T21:00:00.000Z",
      "endAt": "2026-06-10T22:00:00.000Z",
      "durationMin": 60,
      "priceCents": 7500,
      "status": "confirmed",
      "customerId": "cus_xyz",
      "staffId": "stf_abc",
      "notes": null,
      "createdAt": "2026-06-01T03:12:00.000Z"
    }
  ]
}

// Only pending + confirmed, startAt >= now, oldest first, max 200 rows.
Parameters
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.
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. 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
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. A read-only key may call this. Requires a valid API key scoped to this org.

curl https://bookatu.com/api/v1/your-salon/appointments/apt_abc123 \
  -H "Authorization: Bearer bk_live_YOUR_KEY"
Response example
{
  "id": "apt_abc123",
  "ref": "VRD-7QK2M9",
  "serviceName": "Gel Manicure",
  "startAt": "2026-06-10T21:00:00.000Z",
  "endAt": "2026-06-10T22:00:00.000Z",
  "durationMin": 60,
  "priceCents": 7500,
  "status": "confirmed",
  "customerId": "cus_xyz",
  "staffId": "stf_abc",
  "notes": null,
  "createdAt": "2026-06-01T03:12:00.000Z"
}
Parameters
FieldTypeInRequiredNotes
idstringpathYesThe appointment id.
Errors
StatusWhen
401The API key is missing or invalid.
403The key belongs to a different organisation.
404No business exists for the {slug}, or no appointment with that id in this org.
500Unexpected server error.

POST /appointments

Requires API key
POST /api/v1/{slug}/appointments

Create a booking (admin source)

Create a booking with source: admin and the same body as POST /book. An admin-source booking deliberately bypasses online restrictions: minimum booking notice is not enforced, and — unlike POST /book — a slot conflict does NOT reject the request. The availability engine is used only to resolve a free staff member and resource; it does not block the booking. The one guard that still applies is the database uniqueness constraint on (staff, start time), which returns 409 if that exact staff member is already booked at that instant. Use this for trusted staff-side tools and agents acting on behalf of the business; use POST /book for customer-facing flows that must respect availability. Requires a valid API key scoped to this org.

curl -X POST https://bookatu.com/api/v1/your-salon/appointments \
  -H "Authorization: Bearer bk_live_YOUR_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "serviceId": "svc_abc123",
    "startAt": "2026-06-10T09:00:00Z",
    "name": "Alex Smith",
    "email": "alex@example.com",
    "phone": "+64 21 555 0100"
  }'
Response example
{
  "ok": true,
  "ref": "VRD-7QK2M9",
  "appointmentId": "apt_abc123",
  "status": "confirmed",
  "needsPayment": false
}

// When a deposit is required, status is "pending" and a payLink is returned:
{
  "ok": true,
  "ref": "VRD-7QK2M9",
  "appointmentId": "apt_abc123",
  "status": "pending",
  "needsPayment": true,
  "payLink": "https://bookatu.com/your-salon/book/success?apt=apt_abc123"
}
Request fields
FieldTypeInRequiredNotes
serviceIdstringbodyYesAn active, online-bookable service id from GET /services.
startAtstringbodyYesISO 8601 date-time, e.g. a startAt value from GET /availability ("2026-06-10T09:00:00Z").
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. 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.
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).
401The API key is missing or invalid.
403The key belongs to a different organisation, or the key 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
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 the key 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.

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. 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).
booking.created payload
{
  "event": "booking.created",
  "sentAt": "2026-06-01T03:12:00.000Z",
  "data": {
    "id": "apt_abc123",
    "ref": "VRD-7QK2M9",
    "service": "Gel Manicure",
    "startAt": "2026-06-10T21:00:00.000Z",
    "durationMin": 60,
    "priceCents": 7500,
    "status": "pending"
  }
}
booking.cancelled payload
{
  "event": "booking.cancelled",
  "sentAt": "2026-06-02T09:30:00.000Z",
  "data": {
    "id": "apt_abc123",
    "ref": "VRD-7QK2M9",
    "service": "Gel Manicure",
    "startAt": "2026-06-10T21:00:00.000Z",
    "status": "cancelled"
  }
}
booking.completed payload
{
  "event": "booking.completed",
  "sentAt": "2026-06-10T23:05:00.000Z",
  "data": {
    "id": "apt_abc123",
    "ref": "VRD-7QK2M9",
    "service": "Gel Manicure",
    "startAt": "2026-06-10T21:00:00.000Z",
    "status": "completed"
  }
}
booking.rescheduled payload
{
  "event": "booking.rescheduled",
  "sentAt": "2026-06-03T11:00:00.000Z",
  "data": {
    "id": "apt_abc123",
    "ref": "VRD-7QK2M9",
    "service": "Gel Manicure",
    "startAt": "2026-06-12T21:00:00.000Z",
    "status": "confirmed"
  }
}
order.paid payload
{
  "event": "order.paid",
  "sentAt": "2026-06-04T02:20:00.000Z",
  "data": {
    "id": "ord_abc123",
    "description": "2 items",
    "amountCents": 5400,
    "currency": "nzd",
    "items": [
      { "productId": "prd_a1", "product": "Repair Shampoo", "quantity": 1 },
      { "productId": "prd_b2", "product": "Leave-in Conditioner", "quantity": 1 }
    ]
  }
}
form.submitted payload
{
  "event": "form.submitted",
  "sentAt": "2026-06-05T20:41:00.000Z",
  "data": {
    "formId": "frm_abc123",
    "formName": "New client intake",
    "responseId": "frs_def456",
    "customerId": "cus_ghi789",
    "appointmentId": "apt_jkl012"
  }
}
campaign.sent payload
{
  "event": "campaign.sent",
  "sentAt": "2026-06-06T09:00:00.000Z",
  "data": {
    "campaignId": "cmp_abc123",
    "subject": "Winter offers are live",
    "audience": "recent",
    "sent": 182,
    "recipients": 187
  }
}

Delivery & signing

Each delivery is signed with HMAC-SHA256 over the string "{timestamp}.{rawBody}", hex-encoded. Verify it before trusting the body. The endpoint secret is shown once when you create it and starts with whsec_.

  • X-Bookatu-EventThe event name, e.g. booking.created.
  • X-Bookatu-Signaturet={unixSeconds},v1={hmacSha256Hex}. Use t when recomputing the signature.
  • X-Bookatu-AttemptDelivery attempt number, 1 to 3.
  • User-AgentBookatu-Webhooks/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");
  return crypto.timingSafeEqual(Buffer.from(expected), Buffer.from(v1));
}

Rate limits

There is currently no per-key rate limit enforced on the v1 REST API. That is not a promise of unlimited throughput. Build clients defensively: treat HTTP 429 and 5xx responses as retryable, back off (ideally exponentially, with jitter), and cache rarely-changing responses such as business, services and staff. Limits may be introduced in future, designed not to break well-behaved clients. The exception is the customer booking-management family under /booking/{id}, which IS limited per IP today — they carry no key, so the limit is what stops someone hammering them; each endpoint states its own budget, and a 429 always carries Retry-After.

MCP server

AI 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), and draft email campaigns — all in a single authenticated session. Writes require a read-write key, and there are no delete tools by design. Two deliberate limits: form-response listings are summaries only (clients' actual answers never leave the admin dashboard), and campaigns can only be drafted — there is no send or schedule tool, so mass email always stays an owner-confirmed action in the app.

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":{}}'
ToolDescription
list_servicesList all active bookable services.
get_availabilityGet open slots for a service over a date range (max 60 days).
create_bookingBook an appointment for a customer; returns a ref and, when a deposit is due, status pending. Requires a read-write key.
list_customersList all customers in the org.
list_appointmentsList upcoming pending and confirmed appointments. Returns each booking's id for reschedule_booking and cancel_booking.
list_staffList the bookable team members: id, name, title, active and bookable.
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. Requires a read-write key.
cancel_bookingCancel a booking. The client is told and the slot goes back on offer; the reply says whether a paid deposit was kept under the business's notice policy. Requires a read-write key.
list_productsList every retail product in the catalog, active and inactive.
create_productAdd a retail product (prices in dollars; optional tracked stock with an opening-stock ledger entry). Requires a read-write key.
update_productUpdate only the provided fields of a product; a stock change records a ledger adjustment. Requires a read-write key.
receive_stockRecord a delivery into a stock-tracked product and log the restock movement. Requires a read-write key.
create_serviceAdd a standard bookable service (duration in minutes, prices in dollars). Requires a read-write key.
update_serviceUpdate only the provided fields of a service. Requires a read-write key.
blog_list_postsList the business's blog posts (drafts and published), newest first — lean rows without bodies; optional status filter.
blog_get_postGet one blog post in full, markdown body included, by id or slug (drafts visible to the owner).
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.
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.
blog_publish_postPublish a draft post; the first publish stamps publishedAt and a re-publish keeps the original date. Requires a read-write key.
blog_unpublish_postTake a post off the public blog (back to draft), keeping its content and original publish date. Requires a read-write key.
forms_listList the business's intake / consent forms — lean rows with field, attachment and response counts, never full field schemas; optional active/inactive filter.
forms_getGet one form in full, including its complete field schema and the services it is required before.
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.
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.
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.
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.
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).
campaigns_listList email campaigns (drafts through sent) — lean rows with status, audience segment and counts, never bodies.
campaigns_getGet one campaign in full, body markup included. Audiences are segment names; recipient email addresses are never returned.
campaigns_create_draftDraft an email campaign (subject, composer-markup body, audience segment). Always saved as a draft: there is no send or schedule tool — sending stays an owner action in the dashboard. Requires a read-write key.
get_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.
get_findingsRanked, data-computed findings about the business (severity, headline, detail, suggested action) — what the numbers mean, not prose. Read-only.
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.
webhooks_listList the business's webhook endpoints: url, subscribed events, active flag and last delivery status. Signing secrets are never returned.
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_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.
# Call a tool
curl -X POST https://bookatu.com/api/mcp \
  -H "Authorization: Bearer bk_live_YOUR_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "jsonrpc": "2.0",
    "id": 2,
    "method": "tools/call",
    "params": {
      "name": "get_availability",
      "arguments": {
        "serviceId": "svc_abc123",
        "from": "2026-06-10",
        "to": "2026-06-14"
      }
    }
  }'

Configure your AI assistant by pointing it at https://bookatu.com/api/mcpwith your API key. All tools are automatically scoped to the key's organisation.

Connect from Claude

OAuth 2.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 and the access level (read and write, or read only), then approve.

The same rules apply as with API keys: every tool is scoped to the connected business, read-only connections are refused on any write tool, and there are no delete tools at all. Disconnect any time from Settings → Developer API → Connected apps in your dashboard, or from Claude's connector settings. The flow is the standard MCP authorization stack for any other OAuth-capable MCP client too: discovery at /.well-known/oauth-authorization-server and /.well-known/oauth-protected-resource, dynamic client registration, authorization code with PKCE, and refresh token rotation.

Build an agent on Bookatu

The closed 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 the MCP surface), send mass email (campaigns are drafts until the owner presses send), read clients' form answers, or touch another business (the org is resolved from the key, and endpoint URLs must be public https, so events can never be routed to internal infrastructure). Webhook payloads carry ids and names, not customer contact details; fetch full records with your key when you need them.

Start building

Your first booking is a curl away.

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