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.
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 booking | Set up, then book · Start from a template |
| Bring data from another system | Importing from a spreadsheet |
| Holds, statuses, moving and cancelling | Holds · The booking’s life · Requests to book |
| Classes, repeating bookings, the waitlist | Classes, series and the waitlist |
| Terms and money | The policy · Money for a booking |
| Prepaid products | Gift vouchers · Session packages · Memberships |
| The client | Messages · Their own page · Reviews · Marketing · The client record · Forms · Data export and deletion |
| Running the business | The business’s side · The calendar · Reports · External calendars |
| Being found and booked | The booking page · The booking widget · Channels · For agents |
Every account starts with one location, with its own name and timezone:
curl -sS https://api.tuppence.ai/v1/locations \ -H "Authorization: Bearer $TUPPENCE_SECRET_KEY"Set up, then book
Section titled “Set up, then book”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 4500import osfrom datetime import date, timedeltafrom tuppence import Tuppence
tuppence = Tuppence(os.environ["TUPPENCE_SECRET_KEY"])
# What you offer, and who does it, at the account's location.location = tuppence.locations.list().data[0]cut = tuppence.services.create(name="Cut & finish", duration_minutes=45, amount=4500)ana = tuppence.staff.create(name="Ana", locations=[location.id])tuppence.staff.create_schedule( ana.id, location=location.id, pattern=[{str(day): [["09:00", "17:00"]] for day in range(1, 8)}], # ISO weekdays, 1 = Monday effective_from=date.today().isoformat(),)
# When could a client come? Every start on offer over the next week.tomorrow = date.today() + timedelta(days=1)free = tuppence.availability.query( service=cut.id, location=location.id, from_=tomorrow.isoformat(), to=(tomorrow + timedelta(days=6)).isoformat(),)first = free.slots[0]
# Book it. A booking needs an idempotency key, like a payment; the SDK sends one.booking = tuppence.bookings.create( location=location.id, items=[{"service": cut.id}], starts_at=first.starts_at, client={"name": "Sam Patel", "email": "sam@example.test"},)print(booking.status, booking.total_amount, booking.items[0].staff_member == ana.id)# confirmed 4500 TrueWhat each part does:
POST /v1/services | A 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}/schedules | A 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/bookings | The 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.
A menu drafted from your website
Section titled “A menu drafted from your website”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).
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
discardedproposal. 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 (droppedcounts 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 read400 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 returns429 ai_setup_limit_reachedwithRetry-After. A retry with the sameIdempotency-Keyreturns the same proposal.
When the hours change
Section titled “When the hours change”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.
Time off, asked for
Section titled “Time off, asked for”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.
Start from a template
Section titled “Start from a template”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.
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:
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: truewrites nothing and returns what applying would do. Each change names anobject(service,booking_policy, …) and anaction:create,exists,set(thefieldsstill at their defaults) orkeep(thefieldsyou had already chosen).sectionsapplies only some parts:services,forms,location,policy,pageandreminders. Each needs its own API’s scope: a key withoutaccount:writecan 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 carriesmetadata.booking_template.
A template adds no staff. Add the people who do the work (POST /v1/staff) and their hours, and
clients can book.
Importing from a spreadsheet
Section titled “Importing from a spreadsheet”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:
- Upload.
POST /v1/importswith thekindand thefileas CSV text (UTF-8; comma, semicolon or tab; up to 5,000 rows). Columns are matched to the kind’sfieldsby header, or by their values where the header is unclear (a column of email addresses is the email). Or send your ownmapping,{field: column}. - Dry run. Once enough is mapped, the import is
readyanddry_runshows 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 withPOST /v1/imports/{id}/dry_run. - Run.
POST /v1/imports/{id}/runcreates all the rows at once, through the same checks as the API.resultreports the outcome. Everything it creates carries itsimportid. - Undo. Within 24 hours,
POST /v1/imports/{id}/undoremoves exactly what it created. Anything used since stays and is listed inundo.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 returns410 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.
| Kind | Fields |
|---|---|
services | name, 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 |
clients | name (or first_name and last_name), email, phone, notes, date_of_birth, address_line1, address_line2, city, state, postal_code, country, tags |
staff | name (or first_name and last_name), services (their names, separated by semicolons), locations (names; your first location when empty), bio, colour, online_bookable |
time_off | staff (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 |
bookings | starts_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’sfirst_booking_sourceisimport, 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_offexceptions. 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),
confirmedregardless of your approval setting, withsource: importand 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 inexisting), or hours the person does not work. Move a booking or change someone’s hours, then dry-run again.dry_run.conflictscounts them. - Dates are read day-first or month-first based on the file’s own dates (
13/02can only be day-first), otherwise as your country writes them.date_ordershows which, and you can change it.
import osfrom tuppence import Tuppence
tuppence = Tuppence(os.environ["TUPPENCE_SECRET_KEY"])
spreadsheet = """First name,Last name,Email,Mobile,Accepts marketingJo,Bloggs,jo@example.com,07700 900123,yesSam,Patel,sam@example.com,07700 900124,noKim,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 refusedfor 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 backimport osfrom tuppence import Tuppence
tuppence = Tuppence(os.environ["TUPPENCE_SECRET_KEY"])
spreadsheet = """First name,Last name,Email,Mobile,Accepts marketingJo,Bloggs,jo@example.com,(201) 555-0123,yesSam,Patel,sam@example.com,(201) 555-0124,noKim,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 refusedfor 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 backImports need the imports scope. Staff logins cannot run them.
In the dashboard
Section titled “In the dashboard”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.
From another booking system
Section titled “From another booking system”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.
The booking’s life
Section titled “The booking’s life”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}/reschedulewith a newstarts_at(andstaff_member, orany), 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, sendconsented: 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: falsemoves it silently, and reminders still follow the new time. -
Cancelling:
POST /v1/bookings/{id}/cancel.by: "client"applies the policy, and the booking’scancellationandfeeshow 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_appliedis 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.refundedbecomestrue, with therefunds. Any part Tuppence can’t refund itself (paid in cash, or on a disputed payment) iscancellation.unrefunded, for you to settle with the client. -
Before you cancel,
GET /v1/bookings/{id}/cancellation?by=client(orbusiness) returns the outcome (refund_amount,deposit_kept, thefee_dueleft to charge) without changing anything. Show it to whoever is about to confirm. -
Events:
booking.created,.confirmed,.rescheduled,.cancelled,.no_showand the rest reach your webhooks, with the client as{id, first_name}. Their email and phone stay on the customer.
Classes, series and the waitlist
Section titled “Classes, series and the waitlist”- A service with
max_group_sizeabove 1 is a class.POST /v1/class_sessionsschedules one (a teacher, a start, acapacity); availability lists sessions with seats left, and a booking takesclass_sessionandgroup_sizeseats. POST /v1/booking_seriesis 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}/skipskips one date,…/{id}/endstops it.- When nothing is free,
POST /v1/waitlistrecords 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}/acceptbooks it.
The policy
Section titled “The policy”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.
Money for a booking
Section titled “Money for a booking”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:
kind | What it takes, unless you send amount |
|---|---|
deposit | The booking’s deposit_amount, under its policy. |
balance | What is left to pay (amount_due), plus tip_amount if the client adds a tip. |
full | The whole price, before anything is paid. |
tip_only | A tip alone, after the visit. |
late_cancel_fee · no_show_fee | The fee due in the booking’s fee. You can charge less, never more, within 14 days of the appointment (410 fee_window_closed after). |
amountis 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), withsource: "booking", thebookingand itskind.GET /v1/payments?booking=bk_…lists a booking’s payments. - The booking’s
payment_statusandamount_duefollow its payments; you never set them:unpaid→deposit_paid(part paid) →paid(nothing left). A refund makes itpartially_refundedorrefunded; a charged fee makes itfee_charged. You receivebooking.deposit_paid,booking.paidandbooking.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.
Deposits on the booking page
Section titled “Deposits on the booking page”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 freshhold_minutesto pay in, and creates the deposit: akind: depositpayment 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, asconfirmed(orpending_approval):booking.created,booking.confirmedandbooking.deposit_paidfire 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.
A card on file
Section titled “A card on file”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
heldand returnsnext_step: setupandsetup: {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.createdandbooking.confirmedfollow. - 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.
What is left: pay links and money taken at the desk
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(amountoptional, up to what is due) returns{payment, amount, url}: abalancepayment 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 returns409 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_recordswithmethod(cash,other_terminal,bank_transfer,other),amount, and optionaltip_amountandnote. No money moves through us and there is no fee. It counts towardsamount_duelike a payment, never more than is left (tips aside).GET …/payment_recordslists them. - Void a mistaken record with
POST …/payment_records/{record}/void(reasonoptional). It is kept, markedvoidedwith who, when and why, and no longer counts. - Checked out at the till:
POST /v1/bookings/{id}/order(registeroptional; 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 abooking_deposittender (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 ispaidand the visit is markedcompleted. Its money’stillshows what the till took, andorderslists the order with its tenders (kind: balanceafter a deposit,fullotherwise). A booking is checked out once:409 already_checked_outwhile its order stands. Voiding the order releases it. - The whole picture:
GET /v1/bookings/{id}/moneyreturns 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/renderwithdraft.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-afterin the audit log). The booking’sbalance_chargeshows the outcome:succeeded,failed(the bank refused it),authentication_required(the bank wanted the client) orno_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.
- If ticked, the booking’s snapshot keeps
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 intip({base_amount, amount, presets, max_amount, staff, changeable}), ornullif it takes none. Send the payer’s choice astip_amountonPOST /public/payments/{id}/confirm. It can change until a card has been tried; after that the same tip goes through, and a different one returns409 tip_locked. - Set by you:
tip_amountonPOST /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 itstips, andGET /v1/reports/summary(and its CSV) shows thetipsin 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:
curl https://api.tuppence.ai/v1/bookings/bk_…/charge_fee \ -H "Authorization: Bearer $TUPPENCE_SECRET_KEY" \ -H "Idempotency-Key: fee-bk_…" \ -d '{}'const payment = await tuppence.request("POST", "/v1/bookings/bk_…/charge_fee", { body: {}, idempotencyKey: "fee-bk_…",});payment = tuppence.request( "POST", "/v1/bookings/bk_…/charge_fee", body={}, idempotency_key="fee-bk_…")- What is charged: the policy’s fee, less what the client already paid towards it (their
deposit,
deposit_applied), or less if you sendamount; never more. Once, and within 14 days of the appointment (410 fee_window_closedafter). A second charge returns409, as does one made while the first is in progress. - The response is always the payment. Check its
status:succeeded: the fee ischarged, the booking’spayment_statusisfee_charged, andbooking.fee_chargedfires. The client’s receipt quotes the policy sentence it was charged under.requires_payment_methodwithlast_error.code: authentication_required: the client’s bank needs them to authenticate. The fee ispending_authenticationand the client is emailed a link to confirm the same payment with their bank. Once they do, it ischarged.- Refused by the bank: the fee is
failed, with the bank’s reason infee.failure, andbooking.fee_failedfires. Nothing is retried: take the fee with a pay link (POST /v1/bookings/{id}/paymentswithkind: 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(reasonoptional). Who and why are kept on the fee, andbooking.fee_waivedfires. A waived fee can still be charged within the 14 days. - Refund it like any payment:
POST /v1/refundswith the fee’s payment. The fee is thenrefundedand 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_feerefuses it. - Find fees with
GET /v1/bookings?fee_status=due(orpending_authentication,failed,charged,waived,refunded). Each fee carriesclause: 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.
Gift vouchers
Section titled “Gift vouchers”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.
Session packages
Section titled “Session packages”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.
Memberships
Section titled “Memberships”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.
Messages to the client
Section titled “Messages to the client”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 client’s own page
Section titled “The client’s own 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 …/reschedulewith a slot from availability; reminders are rescheduled). Past the window, the business can still move it. - Cancel it, with the outcome shown first (
GET …/cancellationshows the late fee due, the deposit kept and the refund;POST …/cancelcancels 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 (
reviewshows 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).
Reviews
Section titled “Reviews”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
Section titled “Marketing”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.
The client record
Section titled “The client record”A booking’s client is a customer (/v1/customers), the same object the payments API uses. The
record holds:
phoneis kept as E.164 however it is typed (07700 900123,7700900123and+44 (0)7700 900123are all+447700900123(201) 555-0123,2015550123and+1 201 555 0123are all+12015550123for a business in Ireland,087 123 4567,871234567and+353 87 123 4567are all+353871234567, read in your account’s country; other countries’ numbers need their+or00), 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;stateis a US state’s two letters, such asTX),date_of_birth(YYYY-MM-DD),tags(your labels, up to 20, kept in lower case) andpreferred_staff_member(a preference, not a rule). Sendnull, or[]for tags, to clear one.- A block:
POST /v1/customers/{id}/blockwith areason(an internal note, never shown to the client) andonline, what happens to their online bookings:refuse(the default) orrequest. Unblock withPOST …/unblock. The record showsblocked,blocked_reason,blocked_atandblocked_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, whosemessageis your policy’sblocked_message(your own text, up to 500 characters, set withPOST /v1/booking_policy; ours by default). The slot they held is freed at once. Withonline: 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) andfirst_booking(which booking, itssourceandchannel, and when). marketing_consentas 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.
Who or what a visit is for
Section titled “Who or what a visit is for”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:
| Kind | Fields |
|---|---|
pet | species, breed, size (small, medium, large, giant), weight_kg, date_of_birth, vaccinated_on, temperament, notes |
child | age_band (0-2, 3-5, 6-11, 12-15, 16-17), relationship (parent, guardian, other), notes |
vehicle | make_model, mot_due, notes (the registration is its name) |
other | notes |
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_daysrequires 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.
Forms, consents and patch tests
Section titled “Forms, consents and patch tests”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.
Responses
Section titled “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,
trueorfalse,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.
On the booking page
Section titled “On the booking page”The page’s catalogue shows what each service requires:
forms: forms answered before booking. Their questions are in the catalogue’sforms, 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.
On the day, from the booking’s link
Section titled “On the day, from the booking’s link”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.
A client’s data: export and deletion
Section titled “A client’s data: export and deletion”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.
How long records are kept
Section titled “How long records are kept”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.
Marketing consent
Section titled “Marketing consent”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.
Requests to book
Section titled “Requests to book”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.
The business’s side
Section titled “The business’s side”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 itssourceand the client’schannel); - each move and stage of the visit (
rescheduledwith the newstarts_at,arrived,started,completed,no_show,cancelledwith who cancelled and why); - every message sent or scheduled (
message: thetemplate,channelandstatus; aplannedone appears at its due time); - the money: a card
paymentand what it paid for (purpose: deposit, balance, fee, tip), arefund, 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.
The calendar
Section titled “The calendar”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,blockedif the client is blocked, andno_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.
Calendar feeds
Section titled “Calendar feeds”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_urlon 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.
Reports
Section titled “Reports”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 ascompleted,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) andupcoming.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_rateis split the same way, judged at the time of cancellation.money: what was taken on those bookings, whenever it was taken:revenuetowards their price (by card and at the desk, less refunds; the same sums as each booking’s money),depositswithin it, late-cancellation and no-showfeeskept, andtips. 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) andbusiest_hours(by weekday and hour at the location).staff: per person, for everyone who worked or had bookings in the range:bookingswith one of their services that kept the slot, their share ofrevenue,utilisation,rebooking(their completed visits followed by another within eight weeks, with anyone),no_shows(of their visits with an outcome, the no-shows) andrating(reviews naming them for visits in the range, hidden ones included:countandaverageto 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.
Commission and tips
Section titled “Commission and tips”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.
External calendars
Section titled “External calendars”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_busyexceptions, 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 on410), 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_conflictalert 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 asstale. - 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.
The booking page
Section titled “The booking page”GET /v1/booking_page is the business’s public page, created from its name when the account is.
- Its
slugis the address (book.tuppence.ai/<slug>;/test/<slug>in test mode), unique across all accounts: 3–40 characters ofa-z 0-9 -, never a reserved word. A changed slug keeps the old one redirecting for 90 days. POST /v1/booking_pagetakes theslug, andtheme,content,flowandseoas 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;galleryon the page reorders or removes them) and…/cover. - Nothing is public until
…/publish.…/preview_tokenmints 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.reviewscontrols 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.
The team on the page
Section titled “The team on the page”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.
More than one place
Section titled “More than one place”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).
Your own domain
Section titled “Your own domain”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:
pendinguntil the TXT record is seen;verifiedwhile its certificate is issued;activeonce it is. The page’surlis thenhttps://<domain>, every link the dashboard gives out uses it, and the old address redirects there with301.
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 booking widget
Section titled “The booking widget”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.
Channels
Section titled “Channels”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’schannel: 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 carrynudge. Achthat matches neither isother, with the raw value inchannel_raw. With noch, a visit carrying Reserve with Google’srwg_tokenisgoogle_reserve, and one in the widget iswebsite. 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 isother, and a visit with no referrer (typed, bookmarked, or from an app that sends none) islink. The referrer’s host is stored inreferrer(l.instagram.com), never its path or query. - From the desk and the API, the
channelyou 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.
For agents
Section titled “For agents”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 own agent
Section titled “A client’s own agent”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 examplePocket Assistant/2.1 (+https://assistant.example/about). A malformed header returns400withparam: 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 carryverified_agent: trueand itsagent_id. - The client confirms.
POST …/bookingsreturnsnext_step: "confirm"with aconfirm_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 returns410 confirmation_expiredand 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_returningon 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:
confirmon the manage view carriesdeposit_amount(withcurrency) orkeep_card, and a barePOST …/confirmreturns409 deposit_requiredor409 card_required. The deposit is paid on the payment’s page (POST /public/book/{slug}/bookings/{token}/depositreturns itsurl, with the secret in the fragment); a card is saved throughPOST …/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).