The Bookings API
The dashboard, app, booking page and MCP server all use this API, so the same rules apply everywhere. This page lists the calls in order; the Bookings reference has every field.
The objects
Section titled “The objects”| Object | Prefix | |
|---|---|---|
| Location | loc_ | Where: timezone, address, opening hours. Every account has one. |
| Service | svc_ | What: duration, price, options (svar_) and add-ons (sadd_). |
| Staff member | stf_ | Who: their services and hours (/v1/staff/{id}/schedules). |
| Booking policy | bpol_ | The client’s terms. One per account, versioned on each change. |
| Booking | bk_ | A visit: items (one per service), client (cus_) and money. |
Test and live keys see only their own mode’s objects.
Keys and scopes
Section titled “Keys and scopes”A secret key (sk_…) can do everything; a restricted key (rk_…) only what its scopes allow.
resource:write includes resource:read.
| Scope | For |
|---|---|
services:*, staff:*, schedules:*, locations:*, resources:* | What you offer, who, where and when |
availability:read | Availability |
bookings:read, bookings:write | Bookings, holds and the calendar |
waitlist:* | The waitlist |
account:read, account:write | The booking policy |
booking_page:* | The booking page |
payments:write | Booking payments and fees |
test_helpers:write | The test-mode helpers |
Find a time
Section titled “Find a time”GET /v1/availability returns every bookable start between two days:
| Parameter | |
|---|---|
service (+ variant, addons) or items | One service, or several back to back |
location | Where |
staff_member | A named person; omit for anyone |
from, to | YYYY-MM-DD, at most 62 days |
GET /v1/availability/next returns the first. Each starts_at is ISO-8601 with the location’s
offset, ready to book.
Availability applies the policy’s notice, advance window and slot interval. Bookings made with your key count as the business’s own: they’re checked against who is free and what is held, not notice or advance window.
Take it
Section titled “Take it”| You’re building | Call | |
|---|---|---|
| Your own booking page | POST /v1/bookings/holds, then POST /v1/bookings/{id}/confirm | A hold takes the slot for hold_minutes without a client (status: held, held_until); confirm adds one. …/holds/{id}/extend and …/holds/{id}/release extend or free it; unconfirmed holds lapse. |
| A desk, phone line or agent | POST /v1/bookings | One call. Needs an Idempotency-Key; retries return the first booking. |
Pass an existing customer or client details (matched by email or phone, created if new). The
result is confirmed or pending_approval. A taken slot returns 409 slot_unavailable with the
nearest free starts in error.alternatives.
On an account with a staffed service (Set up bookings):
api() { # GET a path, or POST it with a body, using your key and a fresh Idempotency-Key if [ $# -eq 1 ]; then curl -sS "https://api.tuppence.ai$1" -H "Authorization: Bearer $TUPPENCE_SECRET_KEY" else curl -sS "https://api.tuppence.ai$1" -H "Authorization: Bearer $TUPPENCE_SECRET_KEY" \ -H "Idempotency-Key: $(uuidgen)" -H "Content-Type: application/json" -d "$2" fi}location=$(api /v1/locations | jq -r '.data[0].id')service=$(api "/v1/services?status=active&limit=1" | jq -r '.data[0].id')
# The first time a client could come.starts_at=$(api "/v1/availability/next?service=$service&location=$location" | jq -r .starts_at)
# Hold it while they give their details: nobody else can take it meanwhile.hold=$(api /v1/bookings/holds \ "{\"location\": \"$location\", \"items\": [{\"service\": \"$service\"}], \"starts_at\": \"$starts_at\"}")id=$(echo "$hold" | jq -r .id)echo "$hold" | jq -r '"\(.status) until \(.held_until)"' # held until …
# Their details make it theirs.api "/v1/bookings/$id/confirm" '{"client": {"name": "Sam Patel", "email": "sam@example.test"}}' \ | jq -r .status # confirmed
# What would cancelling cost them now? Nothing changes yet.api "/v1/bookings/$id/cancellation?by=client" | jq -c '{refund_amount, deposit_kept, fee_due}'
# Cancel it, as the client: the policy decides what is kept.api "/v1/bookings/$id/cancel" '{"by": "client"}' | jq -r .status # cancelledimport { Tuppence } from "@tuppence/node";
const tuppence = new Tuppence(process.env.TUPPENCE_SECRET_KEY);const [location] = (await tuppence.locations.list()).data;const [service] = (await tuppence.services.list({ status: "active", limit: 1 })).data;
// The first time a client could come.const slot = await tuppence.availability.next({ service: service.id, location: location.id });
// Hold it while they give their details: nobody else can take it meanwhile.const hold = await tuppence.bookings.hold({ location: location.id, items: [{ service: service.id }], starts_at: slot.starts_at,});console.log(hold.status, "until", hold.held_until); // held until …
// Their details make it theirs.const booking = await tuppence.bookings.confirm(hold.id, { client: { name: "Sam Patel", email: "sam@example.test" },});console.log(booking.status); // confirmed
// What would cancelling cost them now? Nothing changes yet.const cost = await tuppence.request("GET", `/v1/bookings/${booking.id}/cancellation`, { query: { by: "client" },});console.log(cost.refund_amount, cost.deposit_kept, cost.fee_due);
// Cancel it, as the client: the policy decides what is kept.const cancelled = await tuppence.bookings.cancel(booking.id, { by: "client" });console.log(cancelled.status); // cancelledimport osfrom tuppence import Tuppence
tuppence = Tuppence(os.environ["TUPPENCE_SECRET_KEY"])location = tuppence.locations.list().data[0]service = tuppence.services.list(status="active", limit=1).data[0]
# The first time a client could come.slot = tuppence.availability.next(service=service.id, location=location.id)
# Hold it while they give their details: nobody else can take it meanwhile.hold = tuppence.bookings.hold( location=location.id, items=[{"service": service.id}], starts_at=slot.starts_at)print(hold.status, "until", hold.held_until) # held until …
# Their details make it theirs.booking = tuppence.bookings.confirm( hold.id, client={"name": "Sam Patel", "email": "sam@example.test"})print(booking.status) # confirmed
# What would cancelling cost them now? Nothing changes yet.cost = tuppence.request("GET", f"/v1/bookings/{booking.id}/cancellation", params={"by": "client"})print(cost["refund_amount"], cost["deposit_kept"], cost["fee_due"])
# Cancel it, as the client: the policy decides what is kept.print(tuppence.bookings.cancel(booking.id, by="client").status) # cancelledAfter it is booked
Section titled “After it is booked”| Call | Does |
|---|---|
POST /v1/bookings/{id}/check_in, …/start, …/complete | Arrived, in the chair, done. |
POST /v1/bookings/{id}/no_show | After the start. The fee follows. Undo with …/complete within a day, unless the fee is charged. |
POST /v1/bookings/{id}/reschedule | New starts_at (and staff_member, or any); prices and accepted policy stay. by: "client" inside the window returns 409 policy_window_closed unless consented. notify: false skips notifications. |
GET /v1/bookings/{id}/cancellation, POST /v1/bookings/{id}/cancel | Preview, then cancel. by: "client" applies the policy; by: "business" (default) refunds everything, no fee. |
POST /v1/bookings/{id}/approve, …/decline | Answer a request to book. |
POST /v1/bookings/{id} | Notes and metadata. Use reschedule for time or person. |
GET /v1/bookings | Filters: day, status, person, service, client, source, channel, payment, fee. |
GET /v1/bookings/{id}/timeline | A booking’s history, oldest first. |
Invalid transitions return 409 invalid_status, naming the current status and what’s required.
Its money
Section titled “Its money”Booking money uses ordinary payments: POST /v1/bookings/{id}/payments with
kind deposit, balance, full, tip_only, late_cancel_fee or no_show_fee. amount_due
and payment_status update automatically; never set them. Pay links, desk payments, tips and
fees: Deposits and no-shows and the
reference.
Its events
Section titled “Its events”Subscribe a webhook endpoint. Events identify the client as {id, first_name},
never email or phone.
| Group | Events |
|---|---|
| Lifecycle | booking.created, booking.confirmed, booking.pending_approval, booking.declined, booking.expired, booking.rescheduled, booking.updated, booking.arrived, booking.completed, booking.cancelled, booking.no_show |
| Money | booking.deposit_paid, booking.paid, booking.fee_charged, booking.fee_failed, booking.fee_waived |
In test mode
Section titled “In test mode”| Helper | Does |
|---|---|
POST /v1/test_helpers/bookings/seed_week | Fills a week with example bookings from your services, staff and hours |
POST /v1/test_helpers/bookings/{id}/force_no_show | Marks a no-show before the start |
POST /v1/test_helpers/bookings/expire_holds | Releases all open holds |
| Moving the test clock | Holds lapse, requests expire, reminders send, automatic fees charge |
From the SDKs
Section titled “From the SDKs”| Resource | Node (@tuppence/node) | Python (tuppence) |
|---|---|---|
| Locations, services, staff | locations, services, staff | locations, services, staff |
| Availability | availability.query, .next | availability.query, .next |
| Bookings | bookings.create, .hold, .confirm, .reschedule, .cancel, .checkIn, .noShow… | bookings.create, .hold, .confirm, .reschedule, .cancel, .check_in, .no_show… |
| Policy, page | bookingPolicy, bookingPage | booking_policy, booking_page |
| Anything else | tuppence.request(method, path, { body, query }) | tuppence.request(method, path, body=, params=) |
Both send Idempotency-Key automatically.