Skip to content

Bookings reference

Tuppence books appointments and takes the money for them. This page is the reference: every object, field and call. The guides above walk through the common paths.

Locationloc_ · whereServicesvc_ · whatStaff memberstf_ · who + hoursAvailabilityGET /v1/availabilityBooking policybpol_ · the termsBookingbk_ · POST /v1/bookingsCustomercus_ · the clientPaymentsdeposit · balance · feesYour serverbooking.* webhooksstarts_atpolicy_snapshotclientkind

A booking takes its slot in one transaction, so two clients can never hold the same one.

You need…Section
Services, staff, hours, availability, a first bookingSet up, then book · Start from a template
Bring data from another systemImporting from a spreadsheet
Holds, statuses, moving and cancellingHolds · The booking’s life · Requests to book
Classes, repeating bookings, the waitlistClasses, series and the waitlist
Terms and moneyThe policy · Money for a booking
Prepaid productsGift vouchers · Session packages · Memberships
The clientMessages · Their own page · Reviews · Marketing · The client record · Forms · Data export and deletion
Running the businessThe business’s side · The calendar · Reports · External calendars
Being found and bookedThe booking page · The booking widget · Channels · For agents

Every account starts with one location, with its own name and timezone:

Terminal window
curl -sS https://api.tuppence.ai/v1/locations \
-H "Authorization: Bearer $TUPPENCE_SECRET_KEY"

The whole flow: create a service and a staff member with a week of hours, list next week’s starts, and book the first one for a client.

import { Tuppence } from "@tuppence/node";
const tuppence = new Tuppence(process.env.TUPPENCE_SECRET_KEY);
const day = (offset) => new Date(Date.now() + offset * 86_400_000).toISOString().slice(0, 10);
const [location] = (await tuppence.locations.list()).data;
const cut = await tuppence.services.create({ name: "Cut & finish", duration_minutes: 45, amount: 4500 });
const ana = await tuppence.staff.create({ name: "Ana", locations: [location.id] });
await tuppence.staff.createSchedule(ana.id, {
location: location.id,
pattern: [Object.fromEntries(["1", "2", "3", "4", "5", "6", "7"].map((d) => [d, [["09:00", "17:00"]]]))],
effective_from: day(0),
});
const free = await tuppence.availability.query({
service: cut.id,
location: location.id,
from: day(1),
to: day(7),
});
const booking = await tuppence.bookings.create({
location: location.id,
items: [{ service: cut.id }],
starts_at: free.slots[0].starts_at,
client: { name: "Sam Patel", email: "sam@example.test" },
});
console.log(booking.status, booking.total_amount); // confirmed 4500

What each part does:

POST /v1/servicesA name, duration_minutes and amount (minor units). variants are options with their own minutes and price (“Short hair”, “Long hair”); addons are extras; resources is what it needs ([{kind: "chair"}]); staff is who performs it (everyone active, by default). max_group_size above 1 makes it a class, booked as seats.
POST /v1/staff and …/{id}/schedulesA person, the locations they work at, and their hours: a weekly pattern ({"1": [["09:00", "17:00"]]} per ISO weekday) or a two-week rotation (two patterns), from effective_from. POST /v1/schedule_exceptions is time off, a break, or extra hours.
GET /v1/availability?service= (&variant=, &addons[]=), or ?items[]= for several services in a row, at ?location=, ?from= to ?to= (YYYY-MM-DD, up to 62 days), for ?staff_member= or anyone. Each slot’s starts_at is what a booking takes. GET /v1/availability/next is the first one.
POST /v1/bookingsThe items, a starts_at availability offered, staff_member or anyone, and the client: an existing customer, or client details (matched by email or phone, created when new). Needs an Idempotency-Key: a double booking is as bad as a double charge. 409 slot_unavailable names other starts.

Times are ISO-8601 with the location’s offset, and every booking has a timezone. A mobile groomer in Bristol and a client’s browser in Lisbon see the same 10:00.

Point POST /v1/booking_setup/ai_proposal at the business’s website, its Instagram profile or a photo of its price list. Claude drafts its services (categories, durations, prices and options), its opening hours, and a few sentences for its page. Nothing is saved: you accept or edit each line through the ordinary APIs (POST /v1/services, a location’s opening_hours, the booking page’s about text).

Terminal window
curl -sS --fail-with-body https://api.tuppence.ai/v1/booking_setup/ai_proposal \
-H "Authorization: Bearer $TUPPENCE_SECRET_KEY" \
-H "Content-Type: application/json" \
-d '{"url": "https://yourstudio.co.uk"}'

Send exactly one of url, instagram (@yourstudio or the profile’s link) or image (the photo as base64 or a data: URL; JPEG, PNG, WebP or GIF, up to 3.75 MB). The response’s status is proposed, empty (nothing found) or discarded. Its services use POST /v1/services’s fields: price_type (fixed, from, on_request or free), amounts in minor units of the account’s currency, duration_estimated where the page gave no length, and variants for options with their own length or price. hours is a location’s opening_hours.

  • What it reads is data, never instructions. A page that tells the model to do something else gets a discarded proposal. The answer can only be services, hours and words, and every value is checked again: a price outside 0p0¢0c–£10,000.00$10,000.00€10,000.00 is left out (dropped counts them).
  • Public pages only: https, never a private or internal address, at most three redirects, 10 seconds and 2 MB. A page that cannot be read is 400 page_unreadable, an address it may not read 400 url_not_allowed. Instagram often shows signed-out visitors little more than the bio, so a photo of the price list works best.
  • A photo is never stored, not even in your request log, which keeps this call’s response but not its body.
  • AI-assisted setup is rolling out. Until it is on for your account, the call returns 403 feature_not_enabled. Each account has a daily allowance; past it, the call returns 429 ai_setup_limit_reached with Retry-After. A retry with the same Idempotency-Key returns the same proposal.

A change that would leave bookings outside someone’s hours (a schedule’s pattern or dates, time off, a break, a closure) is refused with 409 bookings_affected. error.bookings lists them (id, starts_at, ends_at and staff_member, earliest first). Move each one to someone else (POST /v1/bookings/{id}/reschedule at the same starts_at with another staff_member), or resend the change with keep_bookings: ["bk_…"] to leave it where it is. Only bookings the change newly strands count: a booking kept once does not block the next change.

Staff ask with POST /v1/time_off_requests: whole days (all_day, date, end_date) or starts_at to ends_at, and a reason. A staff login asks for its own time; a key or the desk names the staff_member. Asking needs the time_off scope. Whoever runs the rota decides once, with schedules:write. …/{id}/approve adds the time to the rota as a time_off exception (refused like any other change that would strand bookings, and takes the same keep_bookings). …/{id}/decline leaves the rota unchanged, with a note for the requester. Either way, time_off_request.decided fires.

In the app, a staff login asks from Account → Time off: whole days (a first day, plus a last day for several), or part of one day in the location’s time, with a reason. Requests are listed there, pending first, each with its answer and any note. Whoever runs the rota sees everyone’s requests and chooses Approve, or Decline with a reason. If an approval would strand bookings, they are listed first, to keep (Approve, keeping them) or to move on the dashboard’s calendar.

GET /v1/booking_setup/templates lists business-type templates, from hair salons, barbers and nail technicians to dog groomers, physiotherapists, driving instructors, cleaners and consultants. Each has services at starting prices in your account’s currency (with options, add-ons, processing and buffers), the resources they need, typical hours, a policy, forms, reminders and booking page text. It also suggests an MCC for going live, the page’s JSON-LD type, who or what visits are for (subject_kind), and the three share channels to offer first.

Terminal window
curl -sS --fail-with-body https://api.tuppence.ai/v1/booking_setup/templates \
-H "Authorization: Bearer $TUPPENCE_SECRET_KEY"

Every duration in a template is a multiple of 5 minutes, and every price is a starting price. A template is a starting point, not an account type: nothing later depends on which one you picked.

POST /v1/booking_setup/apply_template applies one through the same APIs you would call by hand. Preview it first:

Terminal window
curl -sS --fail-with-body https://api.tuppence.ai/v1/booking_setup/apply_template \
-H "Authorization: Bearer $TUPPENCE_SECRET_KEY" \
-H "Content-Type: application/json" \
-d '{"template": "dog_grooming", "preview": true}'
  • It fills in and never overwrites. A category, resource, service or form you already have (by name, kind or form template) is left alone, as is anything you have changed from its default: hours, policy, page text, reminders. Applying twice has the same effect as applying once.
  • preview: true writes nothing and returns what applying would do. Each change names an object (service, booking_policy, …) and an action: create, exists, set (the fields still at their defaults) or keep (the fields you had already chosen).
  • sections applies only some parts: services, forms, location, policy, page and reminders. Each needs its own API’s scope: a key without account:write can apply everything but the policy.
  • Prices are “from” prices. Every service is created with price_type: "from", so an unedited page shows from £40.00$40.00€40.00, never a wrong fixed price. Each carries metadata.booking_template.

A template adds no staff. Add the people who do the work (POST /v1/staff) and their hours, and clients can book.

Import services, clients, staff, time off and upcoming bookings from a CSV, one kind at a time. Nothing is created until you have seen what would happen to every row:

  1. Upload. POST /v1/imports with the kind and the file as CSV text (UTF-8; comma, semicolon or tab; up to 5,000 rows). Columns are matched to the kind’s fields by header, or by their values where the header is unclear (a column of email addresses is the email). Or send your own mapping, {field: column}.
  2. Dry run. Once enough is mapped, the import is ready and dry_run shows what the run would do: how many rows it would create, which are duplicates (and of what), and every row it would refuse and why, by the row number the spreadsheet shows (the header is row 1). A dry run is the real run, rolled back, so it is never wrong. Remap, or fix your setup, and check again with POST /v1/imports/{id}/dry_run.
  3. Run. POST /v1/imports/{id}/run creates all the rows at once, through the same checks as the API. result reports the outcome. Everything it creates carries its import id.
  4. Undo. Within 24 hours, POST /v1/imports/{id}/undo removes exactly what it created. Anything used since stays and is listed in undo.kept: a client who has booked again or has a note, a booking that has been checked in, paid or sent a reminder, a staff member someone signs in as. An undone booking is also removed from connected calendars. After 24 hours the call returns 410 undo_window_closed.

GET /v1/imports is the import log. The file is deleted when the import runs; an upload that never runs is deleted after a day.

KindFields
servicesname, duration_minutes (60, 1h 30m, 1:30), price (£45; From £45 or £45+$45; From $45 or $45+€45; From €45 or €45+ for a starting price; Free; POA), category (found by name, or made), description, online_bookable
clientsname (or first_name and last_name), email, phone, notes, date_of_birth, address_line1, address_line2, city, state, postal_code, country, tags
staffname (or first_name and last_name), services (their names, separated by semicolons), locations (names; your first location when empty), bio, colour, online_bookable
time_offstaff (their name, or a first name only one person has), date, end_date (for several days), start_time and end_time (for part of a day), reason
bookingsstarts_at (a date and time in one column) or date and time; service (its name; Service - Option for an option; several joined by +); staff (empty for whoever is free); the client’s name, email and phone; location (empty for your first); notes (kept as internal notes); status (cancelled ones are skipped)
  • Clients are matched on email, then phone. An existing client is a duplicate and left unchanged, as is a second row for the same person. Phone numbers are stored as E.164 (+44…, +1…), read in your account’s country. An imported client’s first_booking_source is import, and they have no marketing consent: consent doesn’t carry over from another system, so an opted-in column is deliberately refused (refused). Ask them again.
  • Services: one already on your menu with the same name is a duplicate.
  • Staff: someone with the same name on your team is a duplicate. Hours are not in the file: set them on the rota afterwards. The named services are linked to them as on the staff screen, and a service linked to people is performed only by those people.
  • Time off goes on the rota as time_off exceptions. Time off that would leave bookings outside someone’s hours is refused, and the report names the bookings: move them, then dry-run again. Past time off is skipped; time off already on the rota is a duplicate.
  • Upcoming bookings are created as the desk creates them (checked against availability, the database holds the slot), confirmed regardless of your approval setting, with source: import and the import’s id. Nobody is messaged: the old system already confirmed the booking, so the first the client hears from you is the reminder. No policy is attached (they never accepted yours, so no fee can be charged under it) and no deposit is requested. The client is matched by email, then phone. If the row gives only a name (as some exports do), it matches the one client with that name; a name two clients share is refused, so add an email or phone. Otherwise the client is created, with no marketing consent. Past bookings and bookings cancelled in the old system are skipped; one already in your calendar for the same client and time is a duplicate.
  • Conflicts: a booking whose time is not free is refused with slot_unavailable, and the report names the clash: an earlier row, a booking already in your calendar (its id in existing), or hours the person does not work. Move a booking or change someone’s hours, then dry-run again. dry_run.conflicts counts them.
  • Dates are read day-first or month-first based on the file’s own dates (13/02 can only be day-first), otherwise as your country writes them. date_order shows which, and you can change it.
import os
from tuppence import Tuppence
tuppence = Tuppence(os.environ["TUPPENCE_SECRET_KEY"])
spreadsheet = """First name,Last name,Email,Mobile,Accepts marketing
Jo,Bloggs,jo@example.com,07700 900123,yes
Sam,Patel,sam@example.com,07700 900124,no
Kim,Lee,kim@example,,yes
"""
upload = tuppence.request("POST", "/v1/imports", body={"kind": "clients", "file": spreadsheet})
print(upload["mapping"]) # {'first_name': 'First name', 'last_name': 'Last name', ...}
print(upload["refused"][0]["reason"]) # Marketing consent doesn't carry over…
report = upload["dry_run"]
print(report["created"], "to make,", report["failed"], "refused") # 2 to make, 1 refused
for issue in report["issues"]:
print(f"row {issue['row']}: {issue['message']}") # row 4: `email` is an email address.
ran = tuppence.request("POST", f"/v1/imports/{upload['id']}/run")
print(ran["result"]["objects"]["customers"], "clients until", ran["undo_until"])
undone = tuppence.request("POST", f"/v1/imports/{upload['id']}/undo")
print(undone["undo"]["removed"]["customers"], "taken back") # 2 taken back
import os
from tuppence import Tuppence
tuppence = Tuppence(os.environ["TUPPENCE_SECRET_KEY"])
spreadsheet = """First name,Last name,Email,Mobile,Accepts marketing
Jo,Bloggs,jo@example.com,(201) 555-0123,yes
Sam,Patel,sam@example.com,(201) 555-0124,no
Kim,Lee,kim@example,,yes
"""
upload = tuppence.request("POST", "/v1/imports", body={"kind": "clients", "file": spreadsheet})
print(upload["mapping"]) # {'first_name': 'First name', 'last_name': 'Last name', ...}
print(upload["refused"][0]["reason"]) # Marketing consent doesn't carry over…
report = upload["dry_run"]
print(report["created"], "to make,", report["failed"], "refused") # 2 to make, 1 refused
for issue in report["issues"]:
print(f"row {issue['row']}: {issue['message']}") # row 4: `email` is an email address.
ran = tuppence.request("POST", f"/v1/imports/{upload['id']}/run")
print(ran["result"]["objects"]["customers"], "clients until", ran["undo_until"])
undone = tuppence.request("POST", f"/v1/imports/{upload['id']}/undo")
print(undone["undo"]["removed"]["customers"], "taken back") # 2 taken back

Imports need the imports scope. Staff logins cannot run them.

Imports does the same without code. Choose what the file holds, then choose or drop the CSV (from Excel, Numbers or Google Sheets; Excel’s Windows encoding works too). The next page shows the matched columns, each field with an example from your file, and the columns deliberately left out, with reasons. Change a match and Check with these columns dry-runs again. Below is the outcome: how many rows import, which already exist and which won’t import, each by row number, with a link to the client or booking and every booking clash spelled out. Import confirms the count first, and for 24 hours afterwards Undo import reverses it. The import log lists every import and its outcome.

An export from Fresha, Booksy, Square Appointments, Vagaro or Treatwell Connect imports as it is. It is recognised by its columns, or you name it with source (csv reads any file as a plain spreadsheet). It is read the way that system writes it: a visit of several services becomes one booking, and a client named only by name in the appointments is matched among your clients. format shows how it was read, the export’s columns found, and any missing, each with the field it feeds and the column matched instead (a renamed column is matched by header or values like any other). How to export from each system, and what comes over: Fresha · Booksy · Square · Vagaro · Treatwell.

A hold keeps a slot while the client decides, so it can’t be sold twice. POST /v1/bookings/holds holds it for the policy’s hold_minutes (ten by default), with no client yet: status: held, and held_until. POST /v1/bookings/{id}/confirm with the client’s details turns it into a booking; …/holds/{id}/extend extends it; …/holds/{id}/release releases it. An unconfirmed hold releases the slot automatically.

held · pending_approval · confirmed · arrived · in_progress · completed · cancelled · no_show · declined · expired. A booking is confirmed immediately, or pending_approval when the policy requires the business to approve bookings (POST …/approve, …/decline). On the day: …/check_in, …/start, …/complete; or …/no_show after the start. Undo a mistaken no-show with …/complete within a day. If its fee has been charged, the no-show stands; refund the fee if the client did come.

  • Moving it: POST /v1/bookings/{id}/reschedule with a new starts_at (and staff_member, or any), checked against availability and moved in one step. The prices and policy the client accepted stay the same. by: "client" inside the policy’s window is refused (409 policy_window_closed); the business can always move a booking. When both sides agreed to a move inside the window, send consented: true: a client’s move is then allowed, and a business’s move keeps the policy’s fees, since it was not imposed on the client. The client is told of a move by email and text; notify: false moves it silently, and reminders still follow the new time.

  • Cancelling: POST /v1/bookings/{id}/cancel. by: "client" applies the policy, and the booking’s cancellation and fee show the outcome:

    • in time, a refundable deposit is returned and a non-refundable one is kept;
    • late (inside the free-cancellation window), the deposit is kept and the late fee is due, with what the client paid counting towards it: a £10.00$10.00€10.00 deposit and a £15.00$15.00€15.00 late fee leave £5.00$5.00€5.00 to charge (fee.deposit_applied is the part already covered). The fee is the total cost of a late cancellation, never on top of the deposit.

    When the business cancels (a booking, or a class or series with bookings), it owes nothing and returns everything, including a non-refundable deposit. Refunds are made for you when the cancellation is made: cancellation.refunded becomes true, with the refunds. Any part Tuppence can’t refund itself (paid in cash, or on a disputed payment) is cancellation.unrefunded, for you to settle with the client.

  • Before you cancel, GET /v1/bookings/{id}/cancellation?by=client (or business) returns the outcome (refund_amount, deposit_kept, the fee_due left to charge) without changing anything. Show it to whoever is about to confirm.

  • Events: booking.created, .confirmed, .rescheduled, .cancelled, .no_show and the rest reach your webhooks, with the client as {id, first_name}. Their email and phone stay on the customer.

  • A service with max_group_size above 1 is a class. POST /v1/class_sessions schedules one (a teacher, a start, a capacity); availability lists sessions with seats left, and a booking takes class_session and group_size seats.
  • POST /v1/booking_series is a rule such as “every Monday at 10:00 with Ana”, kept as ordinary bookings eight weeks ahead. …/{id} moves this and following occurrences, …/{id}/skip skips one date, …/{id}/end stops it.
  • When nothing is free, POST /v1/waitlist records what the client wants: a service, a person or anyone, and the days and times that suit. When a slot frees up, it is held for them and offered (status: offered, thirty minutes); …/{id}/accept books it.

GET /v1/booking_policy is what a client agrees to: the deposit, the free-cancellation window and late fee, the no-show fee, how far ahead and how late a booking can be made, and whether a card is kept on file. Every change is a new version, and a booking keeps the version its client accepted (policy_snapshot). A stricter policy tomorrow never applies to today’s bookings. POST /v1/booking_policy/render returns the text a client reads, for a service or an amount.

A fee is at most the price. A late-cancellation or no-show fee is a percentage up to 100, or a fixed amount no more than the service’s price. The CMA’s guidance on unfair contract terms limits a cancellation charge to what the business actually loses, and an unfilled slot can’t lose more than its price. Above that, POST /v1/booking_policy (and a service’s policy_override) returns 400 parameter_invalid with the reason. If the business cancels, the deposit is refunded in full and no fee is ever due; this is in the terms every client accepts, whatever the policy says. A booking the business moved inside the window without the client’s consent owes no fee.

A booking’s money is ordinary payments. POST /v1/bookings/{id}/payments creates one, and the client pays it like any other: by card on the pay page (approval_url), or with @tuppence/js. kind says what each is for:

kindWhat it takes, unless you send amount
depositThe booking’s deposit_amount, under its policy.
balanceWhat is left to pay (amount_due), plus tip_amount if the client adds a tip.
fullThe whole price, before anything is paid.
tip_onlyA tip alone, after the visit.
late_cancel_fee · no_show_feeThe fee due in the booking’s fee. You can charge less, never more, within 14 days of the appointment (410 fee_window_closed after).
  • amount is the whole charge, tip included, and our fee is the card rate on all of it. A tip never counts towards the price and is never added to a deposit or a fee.
  • The payment belongs to the booking’s client (its customer), with source: "booking", the booking and its kind. GET /v1/payments?booking=bk_… lists a booking’s payments.
  • The booking’s payment_status and amount_due follow its payments; you never set them: unpaid → deposit_paid (part paid) → paid (nothing left). A refund makes it partially_refunded or refunded; a charged fee makes it fee_charged. You receive booking.deposit_paid, booking.paid and booking.fee_charged.
  • A booking that is cancelled, a no-show, declined or expired takes no payments except the fee it owes, once.
  • The client’s receipt names the visit (service, time, staff member). For a fee, it quotes the policy sentence it was charged under.

When the policy takes a deposit, the booking page shows it before the client commits (in the last step’s summary and on its button, Continue to pay the £10.00$10.00€10.00 deposit) and books only once it is paid.

  • The deposit is calculated when the slot is held, from the visit’s price: a fixed amount (never more than the price), a percentage rounded down to the penny in the client’s favour, or the whole price.
  • Booking the hold keeps it held, with the client’s details and accepted policy, for a fresh hold_minutes to pay in, and creates the deposit: a kind: deposit payment for the client. The page’s pay step takes it, including the bank’s check (3-D Secure). The booking is made when the payment settles, as confirmed (or pending_approval): booking.created, booking.confirmed and booking.deposit_paid fire then, not before.
  • It also saves a card on file: when the policy requires one, the deposit saves the card (one card entry for both) under the policy’s terms, with its largest fee as the limit.
  • A declined card changes nothing: the client tries another, or the hold lapses with its unpaid deposit. An in-flight deposit keeps the slot past its time (while the bank processes it, or up to 30 minutes during the bank’s check). One that succeeds after its hold has gone is refunded in full.

The policy’s card_required sets when a client must keep a card with the business: always, new_clients (clients who haven’t booked here before), or over_amount (bookings above card_required_over_amount). A client is not asked again if they already have a suitable card: one saved under a booking policy (a card saved for something else was never agreed for a fee), covering at least this policy’s largest fee, and valid in the month of the visit. A client whose card expires before then, or whose fee has risen above what they agreed, is asked again.

After no-shows. card_after_no_shows (1 to 10, or null for never) requires a card from a client with that many no-shows, whatever card_required says. It counts from their bookings, like the record’s no_show_count, so a no-show changed to a visit stops counting at once. With 2, a client who missed two appointments needs a card to book online a third time; one who missed once does not. The terms every client accepts say so (“If you have missed 2 appointments without cancelling, a card is needed to hold the booking.”).

  • With a deposit, the deposit saves the card: one card entry for both.
  • Without one, booking the hold keeps it held and returns next_step: setup and setup: {setup_session, client_secret, held_until}: a setup session that saves the card with nothing charged. The page’s card step shows the terms and takes the card. The booking is made when the card is saved; booking.created and booking.confirmed follow.
  • Either way, the terms are the policy’s own, and no single charge can exceed its largest fee; the mandate has source: booking_policy. A late-cancellation or no-show fee is then charged to the card off-session.
  • A card in the bank’s check keeps the slot for up to 30 minutes; releasing a hold cancels its unfinished setup.
Section titled “What is left: pay links and money taken at the desk”

A booking’s outstanding amount is its amount_due. Settle it in parts or at once; the booking is paid when nothing is left, and booking.paid fires.

  • A pay link: POST /v1/bookings/{id}/payment_link (amount optional, up to what is due) returns {payment, amount, url}: a balance payment and the page the client pays it on. The link’s secret is in its fragment and is shown once: send it to the client. With nothing due, it returns 409 nothing_due.
  • Money taken outside Tuppence (cash at the desk, another terminal, a bank transfer) is recorded so the booking’s money adds up: POST /v1/bookings/{id}/payment_records with method (cash, other_terminal, bank_transfer, other), amount, and optional tip_amount and note. No money moves through us and there is no fee. It counts towards amount_due like a payment, never more than is left (tips aside). GET …/payment_records lists them.
  • Void a mistaken record with POST …/payment_records/{record}/void (reason optional). It is kept, marked voided with who, when and why, and no longer counts.
  • Checked out at the till: POST /v1/bookings/{id}/order (register optional; a paired till uses its own) turns the booking into an open till order: a line per item at the booked price, with the staff member; the client as its customer; the deposit already paid as a booking_deposit tender (a link to its payment, not new money); and any voucher, package or membership hold applied. Add a product or a tip and take the rest as on any order. When the order completes, the tip goes to the visit’s staff, the booking is paid and the visit is marked completed. Its money’s till shows what the till took, and orders lists the order with its tenders (kind: balance after a deposit, full otherwise). A booking is checked out once: 409 already_checked_out while its order stands. Voiding the order releases it.
  • The whole picture: GET /v1/bookings/{id}/money returns what is paid (and how much at the desk), tips, refunds, what is left, and the card on file under the booking’s policy. This is what the dashboard’s money rail on a booking shows.
  • One way to pay at a time: once money moves, a pending link that would now take more than is left is cancelled, so the client can’t pay twice.
  • Charged after the visit, at the client’s request. Turn on the policy’s charge_balance_after (in the dashboard: Booking policy → Offer to charge the rest after the visit; its preview shows the tick box before you save). Where a card is kept, the booking page then offers the client an optional tick box, separate from the terms: Charge my saved card for the rest of my bill (£35.00$35.00€35.00) an hour after my appointment. A draft preview (POST /v1/booking_policy/render with draft.charge_balance_after) shows it too.
    • If ticked, the booking’s snapshot keeps charge_balance_after: true, and the card is saved under a mandate that says so, with a limit covering the rest.
    • An hour after the visit is completed, the rest is charged to that card once, off-session (system:balance-after in the audit log). The booking’s balance_charge shows the outcome: succeeded, failed (the bank refused it), authentication_required (the bank wanted the client) or no_card_on_file. Nothing is retried after a refusal: send a pay link.
    • Never without the tick, whatever the policy says. Off by default. In test mode, moving the test clock runs it.

A tip is part of a payment’s charge (tip_amount), at the card rate on the whole, and never counts towards the booking’s price. It goes on a booking’s bill (a balance or full payment), never on a deposit or a fee.

  • On the pay page: once the visit is confirmed, a pay link’s page offers a tip: No tip, 10, 15 or 20% of the bill (each rounded down to the penny), or a custom amount up to the bill. Nothing is preselected. The page says who the tip is for (the staff who did the visit) and shows the total; the bill and tip are charged together.
  • On your own pages: a payment’s public view (GET /public/payments/{id}, with its client secret) shows the tip offer in tip ({base_amount, amount, presets, max_amount, staff, changeable}), or null if it takes none. Send the payer’s choice as tip_amount on POST /public/payments/{id}/confirm. It can change until a card has been tried; after that the same tip goes through, and a different one returns 409 tip_locked.
  • Set by you: tip_amount on POST /v1/bookings/{id}/payments, or on money recorded at the desk. A client paying on its page can still change the tip before a card is tried. In the dashboard and the app, Record a payment offers the same 10, 15 and 20% of the bill amount, and the booking’s money rail shows who each tip is for.
  • Who gets it: a tip is split between the staff who did the visit, in proportion to the price of each person’s part, when it is given: tip_shares: [{staff_member, amount}] on the payment or the record. The client’s receipt shows it: Of which, tip £5.25$5.25€5.25, for Ana.
  • Counted: the booking’s money (GET /v1/bookings/{id}/money) shows its tips, and GET /v1/reports/summary (and its CSV) shows the tips in each day’s payments, already included in gross. Tips taken at the desk never passed through Tuppence, so the report excludes them.

Charging a late-cancellation or no-show fee

Section titled “Charging a late-cancellation or no-show fee”

When a client cancels inside the policy’s window, or doesn’t come, the booking’s fee shows what is due: {kind: "late_cancel" | "no_show", amount, status: "due"}. Charge it off-session to the card they saved under the policy:

Terminal window
curl https://api.tuppence.ai/v1/bookings/bk_…/charge_fee \
-H "Authorization: Bearer $TUPPENCE_SECRET_KEY" \
-H "Idempotency-Key: fee-bk_…" \
-d '{}'
  • What is charged: the policy’s fee, less what the client already paid towards it (their deposit, deposit_applied), or less if you send amount; never more. Once, and within 14 days of the appointment (410 fee_window_closed after). A second charge returns 409, as does one made while the first is in progress.
  • The response is always the payment. Check its status:
    • succeeded: the fee is charged, the booking’s payment_status is fee_charged, and booking.fee_charged fires. The client’s receipt quotes the policy sentence it was charged under.
    • requires_payment_method with last_error.code: authentication_required: the client’s bank needs them to authenticate. The fee is pending_authentication and the client is emailed a link to confirm the same payment with their bank. Once they do, it is charged.
    • Refused by the bank: the fee is failed, with the bank’s reason in fee.failure, and booking.fee_failed fires. Nothing is retried: take the fee with a pay link (POST /v1/bookings/{id}/payments with kind: no_show_fee), or try the card again later.
  • No card to charge (none saved under a booking policy, or none covering the fee) returns 409 no_card_on_file. Use a pay link.
  • Waive it with POST /v1/bookings/{id}/waive_fee (reason optional). Who and why are kept on the fee, and booking.fee_waived fires. A waived fee can still be charged within the 14 days.
  • Refund it like any payment: POST /v1/refunds with the fee’s payment. The fee is then refunded and can’t be charged again.
  • Reciprocity: a booking the business cancelled owes nothing. A booking the business moved when the client could no longer move it (inside the policy’s reschedule window) owes no fee, and charge_fee refuses it.
  • Find fees with GET /v1/bookings?fee_status=due (or pending_authentication, failed, charged, waived, refunded). Each fee carries clause: the policy sentence the client accepted that it is charged under, to restate before you charge it.

In the dashboard (and in the app, where charging asks for Face ID), Fees lists them by status. Charge restates the amount, the policy sentence and the mode before anything moves, shows the bank’s answer, and offers a pay link if it is refused. Waive records the reason.

Charging automatically. With the policy’s auto_charge on, a no-show fee is charged two hours after the booking is marked no_show, and a late-cancellation fee immediately, by the same path as charge_fee, with system:auto-charge in the audit log. It tries once: a refusal, a bank that needs the client, or no card is left to you, and the fee is never retried automatically, even if it returns to due. It never charges a booking the business moved; a person still can. Off by default. In test mode, moving the test clock runs it.

If the client’s bank disputes a booking payment (a deposit, balance or fee), the dispute opens with evidence drafted from the booking’s record (evidence_drafted_at): the policy as the client accepted it (text, version, when and from where), the card’s mandate, the visit or no-show mark and who made it, the fee and the sentence it was charged under, the receipt, and everything sent to the client about the booking, in time order and in terms a bank reads. Check the draft and submit it (POST /v1/disputes/:id/submit, once); nothing is sent automatically. See Disputes.

A gift voucher is credit with one business for whoever holds its code. It is bought on the business’s gift page (POST /public/book/{slug}/gift: the amount, the buyer, the recipient and a message) or sold through the API (POST /v1/vouchers, Idempotency-Key required; £5.00$5.00€5.00 to £1,000.00$1,000.00€1,000.00; purchaser is an existing customer, or a name and email that becomes one). Either way the response carries the payment that buys it (approval_url for the pay page, client_secret for @tuppence/js), and the voucher is pending until that payment succeeds. It then becomes active: its code is generated and emailed to the recipient (with a copy to the buyer), with the message and expiry (twelve months unless you set otherwise), and voucher.issued fires. The API never returns the code, only code_last4 to tell vouchers apart.

Its money uses the same rails as prepaid credit: the voucher has its own credit_balance, topped up by the purchase. The books hold it as what the business owes the bearer from payment, and recognise revenue only as it is used. It is nobody’s prepaid credit, so it is not in GET /v1/credits. GET /v1/vouchers lists them (?status, ?customer for the buyer). POST /v1/vouchers/{id}/void cancels an active one, refunds the unused amount to the payment that bought it and sends voucher.voided. An unpaid voucher can’t be voided, and a void or expired one is final.

Redeeming. On the booking page’s pay step, a code (POST /public/book/{slug}/holds/{id}/voucher with the hold’s token and the code) covers as much of the deposit as the voucher can. The response gives the next step: pay a smaller deposit (a new payment, in deposit), setup the card the policy requires, or done, booked with no card payment. At the desk, POST /v1/bookings/{id}/voucher (bookings:write, Idempotency-Key; code, and amount for less than what is left) returns the booking’s money. Either way the voucher is held for that sum: the booking counts it as paid at once (vouchers on GET /v1/bookings/{id}/money, each in voucher_redemptions), amount_due drops by it, and voucher.redeemed fires. Nothing moves on the books until the visit is final. A day after the booking is completed, each hold is used (usage on the voucher’s balance, which the hourly rollup turns into revenue). A booking cancelled in time, declined or lapsed returns its holds to the voucher; a late cancellation or no-show keeps what the policy keeps of a deposit, as with a card payment. A voucher payment is never refunded in cash: it returns to the voucher. A voucher with a hold can’t be voided until the hold settles.

Where it is bought and redeemed. The business’s gift page is its booking page’s address with /gift on the end (<page>/gift; /book/<slug>/gift on the booking origin): amount, from, to and a message, then the pay page. The dashboard’s Vouchers page lists sales (active, unpaid, voided, expired) with the remaining and held amounts. Sell a voucher creates a pay link for a buyer, and Void refunds the unused part. The booking page’s pay step asks Have a gift voucher?; at the desk, use the dashboard’s money rail or the app’s Redeem a voucher. Each voucher used is a line on the rail: held for the visit, used, or returned. Expiry: a daily job expires vouchers past their date (voucher.expired). The remaining value becomes the business’s (breakage: usage on its balance, rolled up to revenue, never deleted), and a hold settles with its booking whatever the date. In test mode, GET /v1/vouchers/{id}/code (a test_helpers:write key) returns the emailed code for your own tests.

A session package is N visits of one service sold up front for one price, such as ten washes for £100.00$100.00€100.00. Define one with POST /v1/packages (bookings:write, Idempotency-Key; the service, sessions from 2 to 100, the amount, and validity_days, a year by default). GET /v1/packages lists them; POST /v1/packages/{id}/archive stops selling one, and packages clients already bought still work.

Selling one. POST /v1/package_balances (payments:write, Idempotency-Key; the package and the customer) creates the client’s package balance and the payment that buys it (approval_url for the pay page, client_secret for @tuppence/js). It is pending, with no sessions, until the payment succeeds. It then becomes active with every session, its expiry set from the payment date, and package.purchased fires. Like a gift voucher, it has its own credit_balance on the prepaid-credit rails: the books hold the price as what the business owes the client until the visits happen, and each session’s share of the price (Money.allocate: £100.00$100.00€100.00 over ten is £10.00$10.00€10.00 each; over three, £33.34$33.34€33.34, £33.33$33.33€33.33 and £33.33$33.33€33.33) becomes revenue only once its visit is final. It is nobody’s prepaid credit, so it is not in GET /v1/credits. GET /v1/package_balances lists them (?customer, ?package, ?status). Each shows remaining (unused sessions), held (of those, sessions on bookings not yet final) and balance (the unused sessions’ share of the price).

Using one. A client with a live package for the service books without paying. On the booking page, a booking under the email the package was sold to takes no deposit and no card: a session is held for it and the booking is confirmed, paid with nothing due (packages on GET /v1/bookings/{id}/money, each session in package_uses). A booking made at the desk or through the API doesn’t use a session automatically: POST /v1/bookings/{id}/package (bookings:write, Idempotency-Key; package_balance, or the client’s oldest live one for the service) applies one and returns the booking’s money. A session is held for the visit and used a day after the booking is completed or marked a no-show (package.consumed). A booking cancelled in time, declined or lapsed returns its session. A late cancellation or no-show uses the session, as the policy would keep a deposit. Sessions are never refunded in cash.

Refunding one. POST /v1/package_balances/{id}/refund (payments:write, Idempotency-Key) refunds the unused sessions’ share of the price to the payment that bought it (seven of ten left on a £100.00$100.00€100.00 package refunds £70.00$70.00€70.00) and sends package.refunded. Held sessions must settle first, so a refund while one is held is refused (409). An unpaid package can’t be refunded. When a package expires, what is left is used as breakage (package.expired) and no more sessions can be booked on it.

On the dashboard (Packages): create and archive what you sell. See what clients hold (sold by pay link to a customer found by email or created on the spot), each with sessions left, held and unused, and refund them in full or pro rata.

On the booking page. A package with offer_on_page (POST /v1/packages, or POST /v1/packages/{id} to toggle it) is listed in the page’s catalogue (packages on GET /public/book/{slug}/page: name, service, sessions, price, validity) and can be bought there. POST /public/book/{slug}/packages/{id} (with the page’s bot check; client.email and client.name) finds or creates the customer and returns the payment to make (approval_url for the pay page, client_secret for @tuppence/js) with the package_balance it will fill. A package that isn’t offered, is archived, or belongs to another business returns 404. A booking paid by a session tells the client: package_session: true on the page’s response and on their view of the booking, with nothing to pay and no card saved.

The page lists packages under its services (Packages: the name, 10 × Full groom, the price, the validity) with Buy: a name and the email the client will book under, then the pay page. A booking paid by a session says so on its confirmation (Your package covers this visit, nothing to pay) instead of asking for a deposit or card. On the dashboard’s Packages page, each package shows whether it is on the page, toggled with one click; a new one can be offered from the start.

At the desk. A booking’s money rail on the dashboard (and the app’s booking screen) shows what a package covered (£45.00$45.00€45.00 of it from a package), each session a line: held for the visit, used, or given back to the package. It offers Use a package while something is left to pay and the client holds a live package for a service in the visit with a session free. One click applies the session (POST /v1/bookings/{id}/package), and the booking is paid.

A membership is a subscription plan that includes visits: entitlements on a plan (POST /v1/plans, or POST /v1/plans/{id} to set or remove them), as {services, per_period}: the services covered and how many visits each billing period, 1 to 100. A plan with offer_on_page is listed on the booking page (memberships in the page’s catalogue: the plan, its price and interval, what it includes) with a subscription link to join (join_url). This is the newest live payment_link in subscription mode for that plan, created by the business; without one, the page lists the membership with no way to join.

Using one. A client with a live subscription (active or trialing, within its period) books a covered service without paying. On the booking page, a booking under the subscription customer’s email takes no deposit and no card: a visit is held for it (membership_session: true) and the booking is confirmed, paid with nothing due (memberships on GET /v1/bookings/{id}/money, each visit in membership_uses). This applies up to per_period visits a period; the next one is paid normally. If the client holds both, a package (above) is used before a membership. At the desk, POST /v1/bookings/{id}/membership (bookings:write, Idempotency-Key; subscription, or the client’s live one that covers the service) applies a visit to a booking made there. A visit is used a day after the booking is completed or marked a no-show (membership.consumed), and returned when the booking is cancelled in time, declined or lapsed (membership.released), as with packages. Nothing moves on the books for a visit: the subscription’s charge is the revenue, as M17 recognises it.

A subscription that is not live (past due after a failed renewal, or cancelled) covers nothing. M17 has no pause in v1; when it does, a paused subscription will cover nothing either.

On the dashboard (Subscriptions → Create a plan): tick the covered services, set visits per period, and choose whether to offer it on the booking page. The plans table shows what each includes. On the page, memberships are listed under packages (name, price and frequency, 4 × Full groom a month) with Join, the business’s subscription link, where the client enters an email, subscribes and pays. Without a link, the page says to ask at the desk. A booking paid by a membership visit says so on its confirmation: Your membership covers this visit, nothing to pay.

At the desk. A booking’s money rail on the dashboard (and the app’s booking screen) shows what a membership covered (£45.00$45.00€45.00 of it by membership), each visit a line: held for the visit, used, or given back to the membership. It offers Use a membership while something is left to pay and the client holds a live membership covering a service in the visit. One click applies the visit (POST /v1/bookings/{id}/membership); when none are left this period, it says so.

Clients hear from the business by email, and by text if they asked (WhatsApp to follow): the confirmation when the booking is made, reminders before the visit at the business’s offsets (a day before and two hours before by default; the day-before one states the policy and fee they accepted), and a notice when the booking moves or is cancelled. Each is a booking_message (GET /v1/bookings/{id}/messages): the template, the channel, when it is due, and its status (planned, sent, delivered once the carrier confirms, failed if it couldn’t be sent, cancelled if the booking was, skipped if it no longer applied). A text also has segments and cost_minor. Once sent, preview shows the text version, with links removed. Messages are scheduled against the booking’s start and the account’s clock, so advancing a test-mode clock to the day before the visit sends the reminder. Each message is sent once, however often the sending job runs.

Every message is in the business’s name: its name and branding, its owner’s address as Reply-To (a support address can be set on the settings screen), nothing promotional. The confirmation, and any reschedule, attaches the visit as a calendar file (booking.ics, one entry the client’s calendar updates rather than duplicates) with Add to Google Calendar / Add to Outlook links; a cancellation cancels the same entry. The full set: booking.requested, booking.approved and booking.declined (a request to book), booking.confirmed, booking.reminder, booking.rescheduled, booking.cancelled (with what happened to the money), booking.fee_charged, booking.fee_action_required and booking.balance_due (the money), waitlist.offer and waitlist.expired, and booking.review_request. Each has an email, a text and an approved WhatsApp template.

When each is sent. A request to book is acknowledged immediately (booking.requested), then followed by booking.approved (with reminders) or booking.declined with the business’s reason. A cancellation notice says what happened to the money: the late fee due, the deposit kept, the refund. A fee charged to the card on file triggers booking.fee_charged, quoting the policy sentence it was charged under. A fee needing the client’s bank authentication sends the link (booking.fee_action_required). A post-visit balance the card couldn’t cover sends a pay link (booking.balance_due). Every notice goes by email, and by the client’s chosen text channel (WhatsApp if they opted in, otherwise SMS), immediately; a text due in quiet hours waits until morning.

Texts go to clients who asked for them and gave a phone number: every notice, plus reminders whose rule names sms or whatsapp. They go by WhatsApp if the client ticked it (on the page, WhatsApp me my confirmation and reminders, which sets client.messaging.whatsapp on the booking; or on the customer, messaging_preferences.whatsapp), as an approved template at the platform’s WhatsApp price; or by SMS if they ticked SMS only. A number WhatsApp can’t reach gets the same message by SMS instead, once. Texts are metered per segment (160 characters of plain text, 70 if it contains a curly quote or emoji) at the platform’s price. A text due in the business’s quiet hours waits until they end (and isn’t sent if that would be after the visit). Once the daily cap is reached, the rest are skipped; email is unaffected. Outside production, only an environment’s test numbers can receive texts.

Settings (GET /v1/messaging_settings, POST /v1/messaging_settings, bookings:write):

  • reminders: up to five {offset_minutes, channels}, from an hour to four weeks before the visit.
  • sender_name: the SMS sender id, letters and digits, eleven at most (defaults to the business’s name, sanitised).
  • templates: switch templates off by name, {"booking.reminder": false}.
  • quiet_hours: no SMS between these times, in the location’s timezone.
  • daily_sms_cap.

New bookings are scheduled with the current settings. A client’s own choices are on the customer (messaging_preferences on POST /v1/customers/{id}: reminders, sms, whatsapp, each true or false). A client who turns off reminders gets the confirmation and nothing else before the visit.

More than one place. Each location can set its own sender_name (SMS sender id, sanitised as above) and reply_to (where clients’ email replies go) on POST /v1/locations/{id}. A booking’s messages use its location’s values, then the business’s; null clears them. Every message names the booking’s location and address. In the dashboard, Location adds a place (Add a location), shows one at a time (Showing, kept in the URL as ?location=), sets its Messages from this place, and archives a place the business has left (never the last one). The calendar selects a place the same way, and reports can show one place or all, including archived ones. How the booking page shows several places is under More than one place.

Under the test clock, POST /v1/test_helpers/clock/advance sends whatever is due at the new time, so advancing to the day before the visit sends the reminder at once.

Waitlist offers. When a slot opens for someone on the waitlist (POST /v1/waitlist, and the matching after a cancellation or move), the offer is sent immediately as waitlist.offer by email and their chosen text channel. It links to the offer on the booking page and gives them half an hour to accept (the token is in the link’s fragment, like the manage link’s, and hashed at rest). Accepting there (POST /public/book/{slug}/bookings/{token}/accept) turns the held slot into a confirmed booking, with confirmation and reminders, and the same link becomes their booking’s page. The desk’s POST /v1/waitlist/{id}/accept does the same from the dashboard. An offer not accepted in time lapses: the client is told the slot went to the next person (waitlist.expired), their link returns 410 offer_expired, and the next in line gets an offer with their own link. When nobody is left, the business is notified (waitlist.offer_expired, the A waitlist offer went untaken alert). In test mode, POST /v1/test_helpers/waitlist/{id}/offer_link mints a fresh link for an open offer (the client’s link is theirs alone), so a test can accept the offer from the page.

The confirmation’s link (…/manage#token=…; the token is in the fragment, which no server sees) is the client’s page for that booking, until thirty days after the visit. It shows the booking, its calendar links, and the actions available now (GET /public/book/{slug}/bookings/{token}/manage):

  • Move it inside the policy’s window (POST …/reschedule with a slot from availability; reminders are rescheduled). Past the window, the business can still move it.
  • Cancel it, with the outcome shown first (GET …/cancellation shows the late fee due, the deposit kept and the refund; POST …/cancel cancels under the policy). A request the business hasn’t answered can be withdrawn.
  • Pay the rest (POST …/payment_link, the payment’s own page).
  • Save a card (POST …/card, a setup session).
  • Choose how to hear from the business (POST …/messaging: reminders, sms, whatsapp).
  • Find the business (the location’s address, phone and directions).
  • Once they have reviewed the visit, withdraw their review (review shows it; DELETE …/review).

Every action is audited as the client’s. Thirty days after the visit, the link returns 410 link_expired and the page says so; an unknown token returns 404. A booking made by the client’s own agent waits here for them: confirm shows which agent booked it and the deadline, and Confirm it makes it theirs (see a client’s own agent).

Two hours after a visit is completed, the client is asked for a review (booking.review_request, by email and their chosen text channel), with one email reminder a week later if they haven’t written one. Only completed bookings are asked, and never twice: the review is created when requested, one per booking, so the client hears once however often the job runs. Businesses whose booking page isn’t published are not asked (the review form is on it). To stop asking, switch the template off in messaging settings (templates: {"booking.review_request": false}).

The request’s link (<page>/review#token=…; the token is in the fragment and hashed at rest) opens the visit and a form (GET and POST /public/book/{slug}/reviews/{token}): 1–5 stars, a comment (up to 1,000 characters) and who looked after them (one of the visit’s staff, preselected if there was only one). Text is cleaned before anyone sees it: email addresses, links, phone and card numbers and UK postcodes become [email removed], [link removed], [number removed] and [postcode removed], and swearing is masked except for its first letter. Nothing else is moderated. Clients can edit a review for seven days, and withdraw it from their booking’s link; its stars and text are removed, and the visit isn’t asked again. The link accepts a review for thirty days from the request, then returns 410 link_expired.

On the page, the link opens the visit (what, with whom, when), five stars (each a real radio button, read out as 4 stars out of 5), Who looked after you? when the visit had several staff, and a comment box, with a note of what the page will show and what is removed. After submitting, the client sees a thank-you, the review as the page will show it, the business’s reply once there is one, Change my review for the week, and the business’s Google link whatever the rating. Their booking’s page shows the review with Take my review back, with a confirmation step.

Never incentivised. Tuppence offers nothing for a review and has no way to: no discount, no voucher, no asking only happy clients. The Google link is offered after every review, whatever it said.

The business’s side (reviews:read, reviews:write; the team, not a staff login): GET /v1/reviews lists reviews, newest first (?status= published or hidden, ?staff_member=, ?customer=, ?booking=), each signed as on the page (Sam P.) with the visit date. GET /v1/reviews/summary is the rating: total reviews, average to one decimal place, and the count for each star. POST /v1/reviews/{id}/reply adds the business’s reply, one per review, shown beneath it; sending again replaces it. …/hide takes a review off the page for abuse. Its stars still count, on the page and in the summary, because hiding must never filter good reviews from bad (the DMCC Act). …/publish shows it again. review.created fires when a client writes one (with the cleaned text); review.replied when the business replies.

In the dashboard, Reviews (the team; not a staff login) shows the rating (average, total, count per star, number hidden), then Show reviews on my booking page and the Google review link, then reviews, newest first, 25 at a time: all, on the page, or hidden. Each shows its stars, the client as the page signs them, the visit date, who looked after them and a link to the booking, with Reply (or Change reply) and Hide from your page / Show on your page. Booking reports gives each person’s rating in How each person did.

In the app, Account → Reviews (the team’s; not a staff login’s) shows the rating and reviews, newest first, each with its stars, the visit and who looked after them. Reply there (Reply, or Change reply): one reply per review, shown beneath it on the page. Hiding a review is dashboard-only.

On the page. Reviews show only when the business turns them on: reviews.show on POST /v1/booking_page (off by default). The page then has a Reviews section: the rating, then the latest ten, each with its stars and text, the client’s first name and initial, the service and staff member (when the page shows that person), its month, and the business’s reply. Each staff member’s page shows their own reviews, and the page’s structured data carries an AggregateRating. When off, none of this appears. reviews.google_review_url is the business’s Google review link (from its Business Profile’s Ask for reviews; only Google addresses are accepted), offered to a client after they have written their review, never before.

In test mode, POST /v1/test_helpers/bookings/{id}/review_link mints a fresh review link for a completed booking whose review has been requested. The client’s own link stops working.

Marketing goes only to clients who opted in by ticking the booking page’s box, worded by the business (flow.marketing_consent_text on POST /v1/booking_page; the default is Send me news and offers. I can unsubscribe at any time.). Their answer is on their record as marketing_consent and is checked twice, when a message is scheduled and again when it is sent, so a client who opts out in between hears nothing. Blocked clients never receive marketing. Every message is in the business’s name and words, says why the client is receiving it (You said yes to news from Bella’s Barks), and includes an unsubscribe link. Its one button books on the page with ch=nudge, so resulting bookings record their source.

What is sent.

  • A rebooking nudge follows a completed visit for a service with a rule, once its weeks have passed (It’s been 6 weeks since your Full groom), within the following week, and only if the client hasn’t booked since. Once per visit. Its button books the same service.
  • A birthday message on the client’s birthday (their record’s date_of_birth; 29 February birthdays on the 28th in non-leap years), in the business’s words. Once a year.
  • A campaign (the business’s own news) goes to opted-in clients matching its segment: last completed visit before or after a date, a service they have had, or a tag.
  • A last-minute slot is a campaign about one free time (A Full groom slot has opened at Bella’s Barks); its button books that service, day and staff member.

Nudges and birthdays are scheduled once a day from 10:00 in the business’s time (its first location’s). Everything goes by email, and also by text when the business turns texts on and the client accepts them: outside quiet hours, from the location’s sender name, each text billed on the month’s messaging bill like a reminder (/v1/messaging_usage). A monthly cap (monthly_cap, 1,000 by default) counts every channel. Once reached, the rest are skipped, not sent later.

Unsubscribing. Every email includes <page>/unsubscribe#token=… (the token is in the fragment, hashed at rest) and RFC 8058 one-click headers, which mail clients act on automatically; every text ends with the same link. The link opens a page naming the business, with Stop the news. Either way, the client’s marketing_consent becomes {granted: false, source: "unsubscribe"}, audited, and their consent log gets one line, however often the link is used. Nothing more is scheduled for them, and anything already scheduled is skipped. Their bookings, and the messages about them, continue.

The business’s side (marketing:read, marketing:write: the team reads; an owner, admin or manager writes; never a staff login). GET/POST /v1/marketing_settings: monthly_cap (0–100,000), texts, rebooking (up to 50 {service, after_weeks}, 1–52) and birthday {enabled, words} (up to 300 characters; null uses our default). POST /v1/marketing_campaigns creates a draft:

{ "name": "Spring", "body": "Spring grooms are open.", "segment": { "tag": "regulars" }, "cap": 200 }

or, for a free time, {"kind": "last_minute", "name": "Monday's groom", "slot": {"service": "svc_…", "starts_at": "2026-10-12T08:00:00Z", "staff_member": "stf_…"}} (staff_member null: anyone). An omitted or null subject or heading uses our default, as does a free slot’s text. GET /v1/marketing_campaigns/{id}/preview shows who it would reach now (matching opted-in clients, recipients within its cap, longest-standing first, and a few first names) and the message. POST …/{id}/send sends it once (409 invalid_state after that, or while the booking page is unpublished). marketing.sent reports that a campaign was sent and how many it reached, never who.

In the dashboard, Marketing (the team; not a staff login) sets nudges (a service and its weeks), birthdays and their text, the monthly cap and texts, and flags when the booking page doesn’t ask for consent yet. Campaigns writes news or a free slot (choosing from the day’s free times), previews reach and content, and sends it. Alongside is the number of bookings from marketing links, with See them opening the bookings list at channel=nudge.

A booking’s client is a customer (/v1/customers), the same object the payments API uses. The record holds:

  • phone is kept as E.164 however it is typed (07700 900123, 7700900123 and +44 (0)7700 900123 are all +447700900123(201) 555-0123, 2015550123 and +1 201 555 0123 are all +12015550123for a business in Ireland, 087 123 4567, 871234567 and +353 87 123 4567 are all +353871234567, read in your account’s country; other countries’ numbers need their + or 00), so a client added at the desk is matched when they book online. In the dashboard, a country picker sits beside the number, defaulting to yours: choose another, or paste a number with its code and the country updates.
  • address (line1, line2, city, state, postal_code, country; state is a US state’s two letters, such as TX), date_of_birth (YYYY-MM-DD), tags (your labels, up to 20, kept in lower case) and preferred_staff_member (a preference, not a rule). Send null, or [] for tags, to clear one.
  • A block: POST /v1/customers/{id}/block with a reason (an internal note, never shown to the client) and online, what happens to their online bookings: refuse (the default) or request. Unblock with POST …/unblock. The record shows blocked, blocked_reason, blocked_at and blocked_online. Confirmed bookings stay; cancel them yourself. The block applies online only: you can still book them at the desk or through the API.
  • Refused online: a blocked client booking on your page gets 400 client_blocked, whose message is your policy’s blocked_message (your own text, up to 500 characters, set with POST /v1/booking_policy; ours by default). The slot they held is freed at once. With online: request, their booking becomes a request instead, even if your policy confirms bookings immediately.
  • Booking history, calculated each time rather than stored: no_show_count, late_cancel_count (cancelled by the client inside their booking policy’s free-cancellation window, as the report counts it) and first_booking (which booking, its source and channel, and when).
  • marketing_consent as last given or withheld on your page, with the wording they saw.

Notes (/v1/customers/{id}/notes) are the team’s notes about a client, optionally pinned, returned pinned first, then newest, paginated like every list. Each has one of three kinds, set when written:

  • shared: for everyone who works with the client.
  • private: for owners, admins and managers, and for your keys.
  • clinical: health information, such as a reaction to a product or a contra-indication. Written and read only in the dashboard, by owners, admins and managers. Stored encrypted; the text never appears in an event, the request log (no note is logged with its body) or the audit trail, which records only which note and its kind.

A staff login reads the clients of its own bookings: their record and shared notes. Other clients return 404, and its list and search cover only its own clients. It can’t read money or cards, and can’t write.

Edit a note’s text and pin with POST …/notes/{note}, and delete it with DELETE …/notes/{note}. Note routes take no Idempotency-Key, because a replay would be answered from a stored copy of the note.

In the dashboard, a client’s page shows their record and booking history, visits, saved card, notes (write, pin, choose who reads) and the messages sent to them (GET /v1/messaging_messages?customer=cus_…, across their bookings). Edit the record and block or unblock the client from the same page. Blocking asks what happens when they book online (Refuse the booking or Send it to me to approve); their header shows which, as do the booking’s panel and page (with their no-show count). The Booking policy page sets Card after no-shows and What a blocked client reads; the booking page shows a refused client that text instead of booking. Book opens the calendar for them: choose a time to book. Email and Text open your own mail or messages app with their address or number.

In the app, Clients (on the Bookings tab) finds a client by the start of their name, email or number, like the dashboard’s search; tapping a client’s name on a visit also opens their record. It shows contact actions (Call, Text, Email), any block, tags, preferred staff member, and their no-show and late-cancellation counts. It also shows who or what their visits are for, their notes (with kinds) and their visits, each linking to its booking. A staff login finds only its own bookings’ clients and reads only their shared notes.

Customers lists everyone with their number, tags and any block. Search by the start of a name, an email or a number as typed (07700 900201 555087 123); click a tag to filter by it, or show only blocked clients. Filters are kept in the URL. Adding a client whose email or number already exists warns you first (“This looks like…”, with a link). You can still add them (for example, a family sharing a number): clients are never merged.

Filter clients with ?phone= (in any format), ?tag=, ?blocked=true|false and ?query=, which also matches the start of a phone number. Every edit, block and unblock is in the audit trail (GET /dashboard/audit_log?target=cus_…), which names the changed fields, never their values.

A visit can be for someone or something other than the client: a pet, a child, a vehicle, or anything else. The adult client books and pays; the subject is stored on their record, with only its kind’s fields:

KindFields
petspecies, breed, size (small, medium, large, giant), weight_kg, date_of_birth, vaccinated_on, temperament, notes
childage_band (0-2, 3-5, 6-11, 12-15, 16-17), relationship (parent, guardian, other), notes
vehiclemake_model, mot_due, notes (the registration is its name)
othernotes

Any other field is refused with 400, naming it. A child’s record never holds a date of birth or a school: an age band is all a booking needs.

GET /v1/customers/{id}/subjects lists a client’s subjects. POST adds one: {kind, name, fields}. POST …/subjects/{subject} changes the name or fields (an empty field is cleared), or archives it with status: "archived". A subject’s kind never changes, and subjects can’t be deleted, because bookings reference them. A staff login reads its own clients’ subjects.

A service can require one with subject_requirements:

{ "kind": "pet", "vaccinated_within_days": 365 }
  • vaccinated_within_days requires a pet’s last vaccination (vaccinated_on) within that many days before the visit.
  • age_bands (for a child’s service) lists the eligible ages.

The booking page asks for what a service needs (the date of a pet’s last vaccination, a child’s age) and, before anything is sent, says if it doesn’t qualify and what would. Through the API, a visit whose subject doesn’t meet the requirement is refused with subject_not_eligible, naming the field and the reason: Milo’s last vaccination, on 2025-08-12, is more than 365 days before this visit on 2026-10-26: Full groom needs one on or after 2025-10-26. A missing required field returns parameter_missing. Once booked, the subject is kept on the client’s record (the same pet, updated, however often it visits) and each booking item names it as subject.

A booking made at the desk or through the API names one with subject (csub_…): one of the client’s own, unarchived subjects. Other clients’ subjects are not found. Each calendar card shows who the visit is for beside the client’s name (subject on GET /v1/calendar).

In the dashboard, a client’s page lists who their visits are for: add a pet, child, vehicle or other, edit details, or archive. The form asks only each kind’s fields. A service’s page sets who it’s for: a pet vaccinated within so many days, or a child in given age bands. Booking a client from their page asks which subject the visit is for, shown on the booking’s page and calendar card.

A form is a list of questions: short or long answer, single or multiple choice, yes or no, date, a number on a scale, a signature (one per form, drawn and stored as an image), or a consent (the exact text the client agrees to, always required).

Start from a template (GET /v1/form_templates: consultation, tattoo consent, aesthetics consent, lash, brow and tint consent, physiotherapy intake, massage intake, pet intake or patch test) with {"template": "consent_lash_brow"}; you can override its name, kind and questions. Or build one from questions:

{ "name": "Massage check-in", "kind": "intake", "questions": [
{ "type": "scale", "label": "How tense are you today?", "min": 0, "max": 10, "required": true }
] }

POST /v1/forms creates one; POST /v1/forms/{id} updates it. Each question keeps its id (q1, q2… if you omit it) across versions. Changing the questions creates a new version: old versions are kept, so an answer is always read against the questions it answered (GET /v1/forms/{id}?version=1). Changing the name, services or days does not.

A form names its services, and when it is asked:

{ "attachment": [{ "service": "svc_…", "when": "before_booking" }] }
  • before_booking: asked on the booking page.
  • on_the_day: completed from the booking’s link, or at the desk.
  • optional: whenever the client chooses.

A patch test is a form of kind: "patch_test" whose result lasts valid_for_days (180 by default). It is before_booking for services that need one, and on_the_day for the patch-test appointment itself. A form marked clinical (every template except pet intake) has its answers treated as health data.

In the dashboard, Forms lists the account’s forms at their current versions, with their services. New form starts from a template, word for word, or a blank form. A form’s page edits:

  • its name, and whether its answers are health information;
  • its questions: type, choices, scale ends, a consent’s exact text, and whether required;
  • its services, and when it is asked;
  • how long a patch test pass lasts.

Saving a change to the questions creates a new version; Versions lists each with when it was replaced. Archive hides the form and its responses.

POST /v1/form_responses records a client’s answers against the form’s current version:

{ "form": "form_…", "customer": "cus_…", "booking": "bk_…",
"answers": { "q1": false, "q2": true }, "signature": "iVBORw0KGgo…" }

Answers are keyed by question id and checked:

  • each must be one of the form’s questions;
  • each must match its type: text, a choice from the options, true or false, YYYY-MM-DD, or a whole number on the scale;
  • every required question must be answered, and every consent answered true.

Where the form asks for a signature, signature is the drawn image, a PNG or JPEG in base64. It is stored as an image and fetched only with GET /v1/form_responses/{id}/signature, by those who can read the response, never at a public URL. A named booking must belong to the client.

A response keeps the version it answered: GET /v1/form_responses/{id} returns that version’s questions with the answers, however the form has changed since. GET /v1/form_responses?customer=cus_… lists a client’s responses (or ?booking=, ?form=).

A clinical form’s answers are sealed. They are encrypted on save and never appear in an event, a request log or the audit trail. Only an owner, admin or manager reads them, in the dashboard. For a key, and for a staff login (which reads only its own clients’ responses, naming the client), answers is null and sealed is true.

A patch test’s response carries its result (pass or fail) and expires_at: the form’s valid_for_days after it was recorded.

form_response.submitted includes the form and version, the client, the booking, whether it was signed and a patch test’s result, never the answers.

In the dashboard, a booking’s page lists the forms its services require, whether each is answered, and the answers, each read against the version it answered, with who signed it, how and from where, and the signature. Fill in completes an unanswered form at the desk, with the client present, using the booking page’s fields and signature box. A client’s page lists all their answers and patch tests (valid, lapsed or failed); Record a result adds one.

In the app, a booking’s screen lists the same Forms: each form its services require, answered or not, each answer against its version, with who signed it and how, and the signature. A staff login sees its own visits’ forms, but health form answers stay sealed. The desk can complete an unanswered form there (Fill in) with the client present: each question as written, and a box the client signs with a finger. Missing answers are flagged before sending, and the form is recorded as answered at the desk. The signature is sent like the page’s: a PNG, 600 × 240. Staff logins can’t record forms.

The page’s catalogue shows what each service requires:

  • forms: forms answered before booking. Their questions are in the catalogue’s forms, at the current version.
  • patch_test: {form, service, valid_for_days}: the patch test required, and the service that performs it.

POST /public/book/{slug}/bookings takes the answers as forms: [{form, answers, signature}]. Each is checked as at the desk, and every form the visit requires must be present, once (400 names the form and answer: forms[0].answers.q1). They are stored against the booking, signed by the client (signer.via: "page").

A service that needs a patch test can only be booked by a client with a pass still valid on the day of the visit. A failed test, or a pass that lapses first, doesn’t count. The booking is refused with patch_test_required, naming the service and the patch-test appointment: Lash lift needs a patch test, passed and still in date on the day of the visit. Book “Patch test” first. Nothing is saved, and the slot stays held. The business records the result at that appointment (POST /v1/form_responses with result).

On the page, a service that needs a patch test says so where it is chosen. After the client’s details comes a Before your visit step with each required form as written: its questions, a consent’s exact text to tick, and a box to sign with a finger, pen or mouse (or type a name, for anyone who can’t draw). Nothing is saved until they book. A client refused for lack of a patch test is told why and offered the patch-test appointment: one tap releases their held slot and picks a time for the test, keeping their details.

An on_the_day form isn’t asked on the booking page. The booking’s link asks it, under Before your visit, so the client can complete and sign it before arriving. optional forms are offered there too, marked optional. Each is answered once, stored against the booking, signed through the link (signer.via: "link") and checked as at the desk. A cancelled, declined or missed booking asks nothing more. The link’s page lists them in forms: [{form, when, answered}] on GET /public/book/{slug}/bookings/{token}/manage, and accepts each with POST …/bookings/{token}/forms ({form, answers, signature}). A second answer returns 409 invalid_state; a cancelled, declined or missed booking returns 409 invalid_status.

You are the controller of your clients’ data; Tuppence processes it for you. When a client asks what you hold about them, or asks to be forgotten, use the dashboard or the API.

Export (GET /v1/customers/{id}/export, and Export on the client’s page) answers a subject-access request with one document: the record and booking history, consent log, pets and children, your notes, every form they answered (against that version’s questions) with the signatures as files (base64, in files), their bookings and the messages sent about them, their reviews, their payments and total paid, their saved cards (including removed ones) and their credit. Every list is complete, not a first page. It uses the caller’s permissions, like the client’s page: an owner, admin or manager exports everything; a key never receives clinical notes or clinical form answers; a restricted key gets what its scopes allow. withheld names what the caller can’t read (private_notes, clinical_notes, clinical_answers, and whole sections such as form_responses, bookings, messages, reviews, payments, credit outside a restricted key’s scopes), whether or not any exists. Staff logins can’t export. The route is logged without its body: the export never enters the request log.

Deletion (POST /v1/customers/{id}/delete, and Delete on the client’s page; for owners, admins and managers, and keys that write customers) handles a request to be forgotten. It can’t be undone. It removes:

  • the record’s details: name, email, phone, address, date of birth, tags, the block and its reason, preferred staff member, consent and messaging choices;
  • your notes about them (including clinical), their pets and children, every form response and signature, their waitlist entries;
  • what they entered when booking (their note, the page’s answers, who the visit was for), your internal notes on those bookings, and cancellation reasons;
  • their review text and your replies. The stars stay, signed “A client”: your rating counts every review written, and deleting a client can’t remove one. The review link stops working;
  • where their messages and receipts were sent (the messages stay on your messaging bill, without address or content), and the IP address and browser their card agreements came from;
  • their saved cards, removed from the card processor first, and their customer record there;
  • every copy of these in your events, request log, idempotent replies and team alerts.

The money stays: every payment, refund and ledger line, unchanged, pointing at an anonymous customer. (A payment’s description and metadata are yours as written, and are kept.) Past bookings stay as visits by a deleted client. GET /v1/customers/{id} returns the customer with empty details and deleted_at set; it appears in no list and accepts no more notes, pets, forms or edits (409 invalid_state). Deleting again returns the same.

Deletion is refused (409 invalid_state) while the client has an upcoming booking or an active subscription: cancel those first, with any refund owed. The audit trail records who deleted the client and how much was removed (customer.deleted), never what. A customer.deleted event ({id, object, deleted_at}) tells integrations that keep their own copy to delete it too. The consent log (below) is deleted with them.

Clinical notes and clinical form answers (with signatures) are kept for 7 years from when they were written, and other notes and form answers for 3 years, unless you change it: POST /v1/privacy_settings with clinical_retention_years and other_retention_years, each a whole number from 1 to 30 (owners and admins, or a secret key; GET reads them). In the dashboard, it is Client records in Settings. Each night, records older than your periods are permanently deleted, and the audit trail records how many of each kind (client_records.purged), never their content. Shortening a period deletes older records at the next nightly purge. Each business is purged on its own clock, so a test-mode account with an advanced clock is purged by that clock.

Your booking page asks for marketing consent with an unticked box, in your wording (the booking page’s flow.marketing_consent_text), or the standard wording until you set yours. Reading the page returns the wording in use, so marketing_consent_text is never null; setting it to null or "" restores the standard wording. Every time a client says yes or no, the consent log (GET /v1/customers/{id}/consents, newest first) gets a line: the kind (marketing), yes or no, the wording version they saw and its exact text, where (booking_page; manage_page, their booking’s link; desk; api; or unsubscribe, the link in a marketing message, which only records no) and, when the client acted themselves, their IP address. Each wording version is stored the first time a client sees it, so what someone agreed to can always be read back, however you reword it. The client’s record shows their current state (marketing_consent, with wording_version).

Clients can opt in or out from their booking’s link at any time (POST …/bookings/{token}/messaging with marketing, alongside their reminder and text choices). At the desk or from your integration, POST /v1/customers/{id}/consents with {granted} records what a client told you: a yes is recorded against your current wording (or the standard one); a no needs none. On the client’s page, Marketing consent lists the log and records They said yes or They said no.

When the policy’s approval_mode is request, a booking is pending_approval, with its slot held, until the business approves or declines it: on the dashboard’s booking page, in the app (the push notification has Approve and Decline buttons and opens the booking), from an agent (approve_booking / decline_booking), or through the API (POST /v1/bookings/{id}/approve, …/decline with a reason). Approving confirms it and tells the client (booking.approved, with reminders); declining frees the slot and tells them why (booking.declined). A request not answered within the policy’s approval_window_hours (24 by default) expires on the account’s clock: the slot is freed, nothing is charged, and the client is told the business didn’t answer in time.

A client blocked with online: request books on your page as a request, whatever approval_mode says. The booking’s policy_snapshot carries request_reason: client_blocked, and its dashboard page shows why it is waiting.

Booking alerts use the same system as payment alerts (GET /dashboard/alerts, the inbox, email and push per member’s choices, Slack per the channel’s categories), in a bookings category, on for owners and admins by default: booking_new (made online or at the desk), booking_pending_approval (a request to approve or decline, slot held meanwhile), booking_cancelled_by_client (never the business’s own), booking_no_show (with the fee due), booking_fee_failed (the card refused a fee), waitlist_offer_expired and calendar_conflict. Exactly one alert per event, even if the event is delivered twice. Every morning at 07:00 in each location’s timezone, agenda_daily lists today’s bookings per staff member and what is unpaid, once per location per day, on the account’s clock (advancing a test-mode clock to the morning sends it).

The desk’s view. The dashboard’s Messages page has the settings above, this month’s messaging, the log, opt-outs and failed deliveries; a booking’s page lists its messages. Through the API, GET /v1/messaging_messages returns every booking’s messages, newest first, paginated. Filter with ?month=YYYY-MM (when a message was sent, or is due), ?channel=, ?status=, ?booking= and ?template=. GET /v1/messaging_usage?month=YYYY-MM is the month’s bill so far (the current month on the account’s clock if none is given): per channel (sms, whatsapp, email; email is free), the count sent and the sum of cost_minor, plus total_minor, in the account’s currency. The monthly statement (GET /v1/statements/{month}) shows the same figure as its messaging line (sms, whatsapp, total; in the CSV as messaging_sms, messaging_whatsapp, messaging_total), so the bill matches the log to the penny. GET /v1/customers?opted_out=true lists clients who opted out of reminders, texts or WhatsApp (an explicit false in their preferences).

A booking’s history. GET /v1/bookings/{id}/timeline returns its history, oldest first, one entry per kind:

  • how it was made (booked, with its source and the client’s channel);
  • each move and stage of the visit (rescheduled with the new starts_at, arrived, started, completed, no_show, cancelled with who cancelled and why);
  • every message sent or scheduled (message: the template, channel and status; a planned one appears at its due time);
  • the money: a card payment and what it paid for (purpose: deposit, balance, fee, tip), a refund, a fee charged or waived, and money taken at the desk (payment_recorded).

The booking’s events are at GET /v1/events?object=<booking id>. Filter the bookings list by ?channel= (where the client came from: instagram, qr…) as well as ?source=.

In the dashboard, Bookings is the list, filtered by day, status, staff, service, how it was made, channel, payment and client. Filters are kept in the URL, so a filtered list can be bookmarked or shared. A booking’s page shows its history, with the actions taken and by whom, its events, and the API requests about it.

GET /v1/calendar?location=&from=&to= (bookings:read) returns everything a calendar grid needs for a window in one response:

  • the location (its timezone) and its hours per day;
  • each staff member’s working spans per day (their schedule minus time off and the location’s closures, in UTC, as the availability engine sees them);
  • the bookings, with their items, the client’s first name and last initial, and flags (new_client, notes, unpaid, pending_approval, blocked if the client is blocked, and no_shows, their no-show count);
  • open holds (greyed on the grid), exceptions (time off, breaks, blocked time, a synced calendar’s busy slots) and class sessions.

from/to are dates in the location’s timezone, at most 31 days (this week by default); staff (one or more stf_…, comma-separated) limits it to those columns. Columns include everyone assigned to the location or given hours there during the window, so someone added on the Staff page with hours here appears without also being ticked against it. A staff login sees only its own column, whatever it requests. The response has an ETag; request again with If-None-Match, and an unchanged window returns 304 with no body.

Live. The dashboard’s stream (GET /dashboard/stream) emits change with type: availability.invalidated, the location as object and the day as id, on every change that could affect the grid (a booking made, moved or cancelled, a hold, time off, a schedule), so a calendar showing that day refetches at once. A change to a whole schedule names the location and no day. The dashboard’s calendar also announces bookings made elsewhere (by the page, the API or the app) with a View link. Without a stream (unsupported browser, or between reconnects) it says so and polls every 30 seconds. After a disconnect, it reconnects from the last event seen, replays what it missed and refetches the window once.

The dashboard’s calendar is built on this. Clicking an empty slot offers:

  • a booking (a client found by name, phone or email, or entered new; services with time and price; the staff member; a desk note);
  • a walk-in: one tap on a service books it now for a placeholder client, named afterwards;
  • blocked time or a break (a schedule_exception, so availability closes at once).

A card shows when its client is blocked or has missed appointments (blocked, 2× no-show). Clicking it opens the booking’s panel beside the grid: the client with the same flags, the visit, the status buttons (Arrived, Start, Complete, No-show), desk notes, money and messages. Search (/) finds a client’s next booking, or a booking by its bk_… id, and jumps to its day. The today panel shows each person’s next client, who is due in the next half hour, what is unpaid and open waitlist offers. Move a booking by dragging its card to a time, person or day, or with Move… in the panel. Either way, a dialog warns if the move is inside the policy’s window, asks whether to notify the client (yes by default) and shows the result: moved, or the engine’s reason with the card back in place. Cancelling from the panel shows M25’s outcome first (the refund, what is kept, the late fee left to charge), for a client’s or the business’s cancellation.

In the app, the Bookings tab shows a day of this calendar: an agenda with each visit’s next step, and a column per person. A staff login has only this tab and Account. It sees its own column, and visits open without the money (which is the desk’s). New books the staff member themselves, for their services. The front desk (a manager, owner or admin) can also take payment for a booking in the app.

Try it with example bookings. In test mode, the dashboard’s calendar can fill next week with example bookings from your own services, staff and hours, for example clients at example.test (POST /v1/test_helpers/bookings/seed_week), so you can see a busy calendar before a real client books. Examples are silent: no messages are scheduled, and nothing goes to a connected calendar or calendar feed. Remove example bookings (POST /v1/test_helpers/bookings/remove_seeded) removes them all, with the example clients. Anything you have used since (money recorded, a note, a form) stays, listed in the response’s kept.

A feed publishes a staff member’s bookings (or a location’s, with the staff member on each) as a calendar that Google Calendar, Apple Calendar and Outlook can subscribe to. POST /v1/ics_feeds with staff_member or location (one, belonging to the account) returns the feed with its url (https://api.tuppence.ai/public/ics/ics_….ics) and webcal_url, shown once. The link works as the feed’s password: anyone with it sees those bookings. Requesting again gives the same feed a new link and disables the old one (rotated records when). GET /v1/ics_feeds lists feeds without their links. Both need the bookings scopes.

The feed covers the past 30 days and the next 90 on the account’s clock, one entry per visit, titled with the client’s first name and the service (Sam: Full groom; in a location’s feed, followed by · Ana). It never includes a surname, phone number, email, notes or price, because feeds often end up in shared family calendars. A class is one entry with the number booked and no names. A request awaiting approval shows as tentative and ends (to approve); holds and cancelled, declined or expired bookings are excluded. Each entry keeps one UID for life and links to the booking in the dashboard. The feed asks to be refreshed hourly (REFRESH-INTERVAL). Apple follows that, Outlook checks every few hours, and Google refreshes on its own schedule (every few hours to a day), so a new booking can take that long to appear there.

To subscribe:

  • Google Calendar: on a computer, next to Other calendars, click + then From URL, and paste the link.
  • Apple Calendar: open the webcal_url on the device, or go to Settings → Calendar → Accounts → Add Account → Other → Add Subscribed Calendar (on a Mac, File → New Calendar Subscription).
  • Outlook: Calendar → Add calendar → Subscribe from web.

The dashboard’s staff and location pages create the link and show the same steps.

GET /v1/booking_reports/summary (bookings:read) reports on bookings over a range of days: ?from=&to= (YYYY-MM-DD, inclusive, in each location’s own calendar days; the last 30 days by default, at most 366) and ?location= for one location. It returns:

  • bookings: every booking for those days (excluding holds, pending requests and lapsed bookings), counted as completed, no_shows, cancelled (by the client or the business), late_cancellations (cancelled by the client inside the free-cancellation window of the policy they booked under) and upcoming.
  • no_show_rate: of visits with an outcome (completed or no-show), the share that were no-shows. Overall, and split by card on file (the client had a card saved under a booking policy when the visit began) and by reminder sent (one was sent before the visit), to show whether either makes a difference. late_cancellation_rate is split the same way, judged at the time of cancellation.
  • money: what was taken on those bookings, whenever it was taken: revenue towards their price (by card and at the desk, less refunds; the same sums as each booking’s money), deposits within it, late-cancellation and no-show fees kept, and tips. Voucher, package and membership sessions count nothing here: their money came when they were sold. The Balance remains the source of truth for money; these figures are per booking.
  • revenue_by_staff, revenue_by_service, revenue_by_category: each booking’s revenue split across its items by price, without losing a penny.
  • utilisation: per staff member, booked minutes over working minutes (their schedule minus time off, breaks and blocks, as the availability engine sees it), counting bookings that kept their slot (a class counts once, however many attended).
  • sources: bookings by where they were made (source) and where the client came from (channel), with outcomes and revenue.
  • clients (new, meaning no earlier booking, and returning), rebooking (the share of completed visits followed by another booking on a later day within eight weeks), lead_time (how far ahead bookings were made, average and median, in minutes) and busiest_hours (by weekday and hour at the location).
  • staff: per person, for everyone who worked or had bookings in the range: bookings with one of their services that kept the slot, their share of revenue, utilisation, rebooking (their completed visits followed by another within eight weeks, with anyone), no_shows (of their visits with an outcome, the no-shows) and rating (reviews naming them for visits in the range, hidden ones included: count and average to one decimal place). A visit with several people’s services counts for each of them.

Rates are percentage strings to two decimal places, and null when there is nothing to divide by. Each table downloads as CSV with the same query under /v1/booking_reports/: summary.csv (one metric,value row each), utilisation.csv, revenue.csv, sources.csv, hours.csv and staff.csv (with each person’s reviews and rating). Amounts are in minor units.

In the dashboard, Booking reports shows the same for a chosen range (7, 30 or 90 days up to a day, or any two dates) and, with several locations, for one of them: headline figures, whether a card on file or a reminder makes a difference, how each person did (each name linking to their page), utilisation by staff member, booking sources, revenue by staff member, service and category, and the busiest hours. The range is kept in the URL, and each table downloads as CSV.

This is a report, not payroll: Tuppence calculates what each person earned and pays nobody. A rule per staff member (POST /v1/commission_rules, commission:write) sets their rate on service revenue in basis points (percent_bps: 4000 is 40%), with optional tiers on the month’s revenue ({above, percent_bps}, marginal like tax bands: 30%, then 50% above £100.00$100.00€100.00, on £120.00$120.00€120.00 is £30.00$30.00€30.00 + £10.00$10.00€10.00) and service_overrides, a separate rate for a service (outside the tiers). A rule applies from effective_from (a month, 2026-11; this month by default) and is never edited in place: a new rule takes over from its month, and a finished month keeps its rules, so a rule backdated into it is refused. GET /v1/commission_rules (commission:read) returns each person’s history, newest first; a staff login reads its own.

GET /v1/commission_reports?period= (commission:read) reports on a month (2026-11, this month by default) or a day (2026-11-05). Per staff member: completed bookings; revenue (the price of the services they completed in the period, in each location’s days, before tips); commission under that month’s rule (rule names it; with no rule, zero); and tips (what they kept of each, by who it was given for). Each service has its count, revenue and commission, highest revenue first. A day’s figure is the month-to-date total at its end minus the total before it, so days add up to the month to the penny. Every rate rounds down: the business owes what the rule says, never a penny more. A staff login reads only its own line.

The report downloads as a spreadsheet, GET /v1/commission_reports/{period}.csv (a line per staff member and service, each person’s total, whose count is their bookings, and a grand total, money as 145.00), and as a printable page, …/{period}.pdf. The monthly statement’s staff_summary gives each person’s revenue, commission and tips for the month (in its CSV as staff:<id>:revenue lines, and on its PDF), for information only, never in the balance.

A staff member connects their Google Calendar from their page in the dashboard (Appendix E §7). Connect Google Calendar opens Google’s consent for the calendar.events scope and returns with the connection made. They choose the direction: busy_in (their calendar blocks ours), push_out (our bookings go to theirs) or two_way.

  • Busy time comes in as calendar_busy exceptions, which the availability engine honours. Each instance of a recurring event is its own row, events marked “free” are ignored, and all-day events block the day unless the business turns that off. Sync is incremental (Google’s sync token, with a full resync on 410), pushed by Google’s watch channels (re-watched hourly before they expire, since they can’t be renewed), with polling as a fallback.
  • Bookings go out as <first name>: <service> at the location, with no phone, notes or price (feeds often end up in shared family calendars), tagged (extendedProperties.private.tuppence_booking), moved when a booking is rescheduled and removed when it is cancelled. Connecting sends existing bookings too, not only new ones.
  • An edit made in the external calendar to one of our events raises a calendar_conflict alert and is reverted on the next sync: the booking is the source of truth. An external event disappearing never cancels a booking.
  • Sync failures back off and alert after an hour of retries (calendar_sync_failing); eight days without contact from Google shows as stale.
  • Disconnecting removes the tokens and imported busy time and, if requested, our events in their calendar.

Until the OAuth app exists (27-12), staging runs an in-process fake of Google, driven by the test helpers.

Microsoft 365 connects the same way (Entra’s consent for Calendars.ReadWrite): a Graph subscription on the person’s events, renewed by the hourly job before its three days run out (they stop silently otherwise); the validation handshake is answered; lifecycle notifications are handled (missed → a sync, subscriptionRemoved → a new subscription, reauthorizationRequired → the person is asked to reconnect); changes are read from the calendar view’s delta; and our tag is a single-value extended property.

iCloud has no OAuth or REST API. The person enters an Apple ID and an app-specific password (never their account password). The calendar home is discovered over CalDAV (caldav.icloud.com), changes arrive by sync-collection (the window is walked and expanded on the first sync), our events carry X-TUPPENCE-ID, and with no push available, it polls every five minutes. A revoked password shows the connection as reauthorise, and the page offers to reconnect.

All three share one gateway and one sync engine; fakes stand in for the providers in tests and on staging.

GET /v1/booking_page is the business’s public page, created from its name when the account is.

  • Its slug is the address (book.tuppence.ai/<slug>; /test/<slug> in test mode), unique across all accounts: 3–40 characters of a-z 0-9 -, never a reserved word. A changed slug keeps the old one redirecting for 90 days.
  • POST /v1/booking_page takes the slug, and theme, content, flow and seo as patches. The accent colour is darkened where it would read badly on white, social links and redirects must be https, and there are at most ten custom questions.
  • Pictures go through POST /v1/booking_page/gallery (at most 20; gallery on the page reorders or removes them) and …/cover.
  • Nothing is public until …/publish. …/preview_token mints a token (shown once) that opens the page before then (?preview=, noindex).
  • flow.wallets (on by default) controls whether Apple Pay and Google Pay are offered at payment. Tuppence registers the booking origin, and each business’s own domain once live, for wallets.
  • reviews controls whether the page shows reviews, and holds the business’s Google review link (see Reviews).

Every page has an accessibility statement at <page>/accessibility (/accessibility on the business’s own domain), linked from its footer: the standard it meets (WCAG 2.1 AA), known gaps, how it is tested, and the business’s phone for clients who can’t book online. An address with no published page (unused, or not yet published) returns 404 with a readable page, the same either way. A published page with nothing bookable online says so, showing the business’s phone where Book now would be.

Each staff member shown on the page has their own page at <page>/s/<their slug> (/s/<their slug> on the business’s own domain), linked from the team section: their photo and bio, their services and prices, and Book with them, which starts the flow with them selected (<page>/book?staff=<id>), just as each service’s Book preselects that service. Where the page hides staff choice (flow.staff_choice: "hidden"), those links book without selecting anyone. A person’s page carries a schema.org Person who works for the business, and published pages’ staff are in the sitemap. When the page shows reviews, a person’s page shows the reviews naming them and their rating.

bookable_online: false keeps a staff member off the page entirely: not in the team, their page returns the same 404 as an unknown address, not offered on the staff step, and never assigned when a client picks anyone (the page only offers and holds times with staff bookable online). The desk can still book them through the API and the dashboard.

A business with more than one open location shows them all under Our places. Each place has its address (or We come to you, or Online), its opening hours, directions and phone, and a Book at link.

The booking flow asks where first, and everything after belongs to that place: the services offered there, the staff who work there, and their times on the days they are there. Someone at Clifton on Mondays and Bath on Tuesdays is offered on Mondays to a client booking Clifton.

<page>/book?location=<id> starts the flow at a place without asking, as Book at does. The share panel lists each place’s link (Links to each place), and the widget takes data-location.

Places are in the business’s order: a location’s sort, then oldest first. The first is the default when a booking names no place (?location= on GET /public/book/{slug}/availability, and location on its holds).

A business can give its page a subdomain it owns, such as book.yoursalon.co.uk. POST /v1/booking_page/custom_domain with {domain} returns custom_domain_records: a CNAME from the domain to us, and a TXT record at _tuppence.<domain> that proves ownership. The status then advances automatically every ten minutes, or immediately with POST /v1/booking_page/custom_domain/check:

  • pending until the TXT record is seen;
  • verified while its certificate is issued;
  • active once it is. The page’s url is then https://<domain>, every link the dashboard gives out uses it, and the old address redirects there with 301.

If the TXT record isn’t seen within seven days, the status becomes failed, with the reason in custom_domain_error; submitting again starts over. A domain used by another page returns 409 domain_taken. An apex domain such as yoursalon.co.uk (which can’t take a CNAME), one of Tuppence’s own names, or anything that isn’t a domain returns 400. DELETE /v1/booking_page/custom_domain removes it, and the old address serves the page again at once.

The same flow on the business’s own website, with one tag: a button, a floating corner button, or the flow inline, in a frame on book.tuppence.ai that never exposes what a client types to the site. Bookings made there are source: widget. Embed the booking widget covers the tag and its attributes, the events a site receives, pinning the loader with Subresource Integrity, and Content-Security-Policy requirements.

Every booking records where its client came from, its channel, so you can see which places you share your page bring bookings, and what they are worth. Where your bookings come from is the guide: the share panel’s links, custom channels, and what the Channels page’s numbers mean.

  • From the page and the widget, always. The link a client followed carries a ch (?ch=instagram); the share panel adds one to every link it gives out. If it names a channel, that becomes the booking’s channel: one of Tuppence’s (link, qr, website, email, google_link, instagram, instagram_story, facebook, apple, whatsapp, tiktok, linktree…), matched case-insensitively, or one of your own. Links in your own marketing carry nudge. A ch that matches neither is other, with the raw value in channel_raw. With no ch, a visit carrying Reserve with Google’s rwg_token is google_reserve, and one in the widget is website. Otherwise the referring site decides: Instagram, Facebook, Google (search and Maps, not Gmail), Bing, TikTok, Linktree, WhatsApp, Apple Maps, or a link in an email. Any other site is other, and a visit with no referrer (typed, bookmarked, or from an app that sends none) is link. The referrer’s host is stored in referrer (l.instagram.com), never its path or query.
  • From the desk and the API, the channel you set (walk_in, phone, instagram…), as written, or none: a booking you make has no channel unless you set one.

A booking’s channel is set once, when it is made, and never changes.

GET /v1/booking_channels (bookings:read) shows where a range of days’ bookings came from (?from=&to= and ?location=, as in the reports). channels lists every channel (yours, places you could share your link but haven’t, as suggested with id: null, and any other a booking in the range names), each with stats for the range: bookings (counted as in the reports, excluding holds, pending requests and lapsed bookings), how many were completed, no_shows and cancelled, the revenue taken (by card and at the desk, less refunds; the same sums as each booking’s money and the reports’ sources) and the no_show_rate. A channel’s status is set_up once its link has been given out, live once a booking has come through it (last_booking_at is the latest), and error when a partner integration needs attention. without_channel counts bookings with no channel (the bookings list’s ?channel=none), and totals covers the whole range, matching the bookings list.

POST /v1/booking_channels sets up a channel: one of Tuppence’s by its code, or your own with a kind (link, qr, widget, google_link, google_reserve, instagram, facebook, apple, whatsapp, tiktok, bing, linktree, email, agent or other) and a label. After that, ?ch=<your code> on a link counts towards it. Sending the same code again updates the same channel’s label. A short link with your own ch also sets up that channel.

In the dashboard, Channels shows the same for a chosen range (7, 30 or 90 days up to a day, or any two dates, kept in the URL): bookings, money taken and no-shows, then each channel with its status (every count links to its bookings in the bookings list), bookings with no channel, and places you haven’t used yet, each with setup steps and a link to it on Share. Giving out a link on Share sets up its channel. For a Google channel, the page shows the name, address and phone Google matches your listing by, and flags any that are missing.

Reserve with Google. A booking from Google’s redirect keeps Google’s rwg_token, for reporting the conversion to Google once the integration is live. It is captured on the booking page only, never in the widget, and is never shown or logged. Google matches your business to its listing by name, address and phone number: keep your location’s details exactly as on your Google Business Profile, or Google shows no Book button, or someone else’s.

The MCP server has the same as tools: list_services, create_service, find_availability, create_booking, get_booking, list_bookings, reschedule_booking, cancel_booking, approve_booking, decline_booking and add_to_waitlist. Bookings made by an agent have source: agent, and create_booking needs an idempotency_key, as a payment does. These tools act for the business, with its key, so their bookings are confirmed like the desk’s. Bookings for AI agents walks through them, with a key scoped to just these.

A client’s AI assistant has no key. It books on the business’s page through the same public API the page uses, and identifies itself. Everything it needs is at GET /public/book/{slug}/agent.json (/public/book/test/{slug}/… in test mode): services and options with prices and durations, the policy the client accepts, the three calls (GET …/availability, POST …/holds, POST …/bookings), the headers, the confirmation rule and the limits. GET …/llms.txt has the same as prose for a model.

  • It identifies itself. Every call carries Tuppence-Agent: <name>/<version> (+<contact URL>), for example Pocket Assistant/2.1 (+https://assistant.example/about). A malformed header returns 400 with param: Tuppence-Agent. An agent that identifies itself skips the page’s bot check.
  • Its own limits. A burst of ten requests per name and IP address, then one every five seconds. An agent with a Tuppence agent identity signs each call instead (Tuppence-Agent-Proof) and gets sixty at once, two a second, wherever it runs; its bookings carry verified_agent: true and its agent_id.
  • The client confirms. POST …/bookings returns next_step: "confirm" with a confirm_by, and no link for the agent. The booking is held, and the client is sent their own link (by email, and by text or WhatsApp if chosen) to confirm within thirty minutes (POST /public/book/{slug}/bookings/{token}/confirm, or Confirm it on the page). Until then, the business isn’t notified and the client gets no confirmation; both happen on confirmation, as for any booking. If unconfirmed, the hold is released; its link returns 410 confirmation_expired and the page says the booking lapsed.
  • Returning clients. A business can let a returning client’s agent (a completed visit under the same email) book outright: flow.agents_instant_for_returning on the page, or Let a client’s AI assistant book outright if they’ve been before in the page’s settings.
  • The client pays. An agent never pays for its client. If the policy takes a deposit or requires a card, the booking waits for the client, and their link asks for it: confirm on the manage view carries deposit_amount (with currency) or keep_card, and a bare POST …/confirm returns 409 deposit_required or 409 card_required. The deposit is paid on the payment’s page (POST /public/book/{slug}/bookings/{token}/deposit returns its url, with the secret in the fragment); a card is saved through POST …/card, as on any booking’s page. Either confirms the booking, and the client gets at least the policy’s hold time to finish, however little of the half hour is left. On such a page, a returning client’s agent can’t book outright.

These bookings have source: agent, and agent records who made them ({name, contact, agent_id}). In test mode, POST /v1/test_helpers/bookings/{id}/confirm_link mints a fresh link for a booking awaiting its client (the client’s own link stops working).

Docs assistant

Open as a page
Loading the assistant…