Bookings
Answering questions is good; taking bookings is better. Give your assistant a diary and it can offer real availability and book appointments during the conversation — on the phone, in web chat, and on a hosted booking page customers use directly.
There are three ways to run bookings, and this chapter covers all of them:
- The built-in diary — the platform hosts the whole thing: your services, your weekly hours, one or more diary lanes, a public booking page with its own link and QR, and an operator diary in the dashboard. No external calendar needed. This is the richest option and the rest of this section documents it first.
- A connected calendar (Google today, Microsoft 365 supported) — the assistant books straight into a calendar you already run.
- A supported third-party diary system — see the Semble integration at the end of this chapter for the first of these.
Set the booking provider to the built-in diary and configure your
offerings in one place. Each service (a type) carries its
display name, duration, price, optional variants, and its own weekly
hours: dayWindows is both the day whitelist and the per-day
hours map, so "Mon/Tue 9–8, Wed evenings only, Sun off" is one small
object. Types can share one diary lane or declare their own
resources (multiple lanes render side by side in the diary
and are booked independently).
Requires manage_bookings. The same endpoint reads back
with GET. Everything here is also editable in the
dashboard's Bookings tab, which for a built-in-diary app opens as a
workspace: Overview, Diary, Customers, Messages,
Reviews and Settings in a left rail. The diary supports drag-to-create
(a booking or a private block), drag-to-reschedule, and a Close
dates action for holidays and closures (documented below).
curl -X PUT 'https://api.everycallhandled.com/api/v1/apps/{appId}/booking' \
-H 'Authorization: Bearer YOUR_TOKEN' \
-H 'Content-Type: application/json' \
-d '{
"booking": {
"enabled": true,
"provider": "ech-native",
"timezone": "Europe/London",
"types": {
"consultation": {
"displayName": "New client consultation",
"durationMins": 30,
"priceLabel": "£50",
"dayWindows": { "1": {"startHour": 9, "endHour": 20}, "2": {"startHour": 9, "endHour": 20}, "6": {"startHour": 9, "endHour": 13} }
}
}
}
}'Every built-in-diary app gets a public booking page at
/book/{appId} on the platform's public site — brandable
(accent colour, logo, welcome text, photos), with service search,
category grouping, and a persistent web-chat bubble so a visitor can ask
questions mid-booking. The Bookings settings section gives you the
share link, a printable QR, and two embed snippets
(fixed and auto-resizing iframe) for your own website. Share the link
anywhere customers are: texts, email signatures, your Google Business
profile.
The hosted page runs on a small unauthenticated API you can also
drive directly — for a custom front end, a kiosk, or your own site. All
routes live under /api/v1/public/book/{appId}/… and need no
token; they only ever expose what the public page itself shows.
Holds are short-lived by design: confirm promptly or the slot returns
to the pool. Manage links are signed and expiring — customers can move
or cancel their own booking within the policy window you set; the
operator endpoints below are never policy-gated, because the business is
the business. The manage view also carries your configured
cancellationPolicy wording, shown to the customer before
and after they cancel.
config returns everything the page renders: services
(with per-service depositDescription when your deposit
wording is a customer-facing sentence), branding (including a choosable
backgroundColor), terms, detailFields,
newClientGate, giftVouchers,
promoCodesEnabled, and voiceDefaults — the
vertical-correct default wording used wherever you haven't set your own
(see Your voice below).
confirm accepts more than name/phone/email. Depending on
your configuration it also takes the detail fields (dob,
gender, addr1, addr2,
county, postcode,
practitionerNote, marketingConsent), the
new-client answer (newClient), a gift-voucher code
(voucherCode) and a promo code (promoCode).
Server-side validation always matches what the page enforces.
curl 'https://api.everycallhandled.com/api/v1/public/book/{appId}/slots?type=consultation&days=7'Every booking made through the page, the assistant, or the API sends
an instant confirmation — email whenever an address is known, SMS
whenever a mobile is — and cancellations and reschedules send notices
from any direction. The wording is honest by construction: while a
deposit link is outstanding the message says the booking is
reserved and the deposit secures it; "confirmed" is
only ever said once nothing is owed. Your per-service deposit wording
(see Your voice) rides along wherever the deposit is
mentioned. Switch the whole thing off with
"confirmationComms": false in the booking settings if you
send your own.
Two settings shape the details step, both editable in the dashboard
and via the booking settings PUT:
detailFields— per-app because a barber should never ask for a date of birth:dob,genderandaddressare each"optional"or"required"(absent = not asked);note("optional") adds a free-text note for the practitioner;marketingConsent: trueadds a consent tick-box whose answer is stored on the customer record with source and timestamp.marketingConsentTextlets you phrase the consent ask in your own legal voice.newClientGate— the "have you visited us before?" rule:{"enabled": true, "consultationKey": "consultation", "message": "…"}steers first-timers to a consultation before any treatment, and the server enforces it — the page asks, but the API can't be talked around it either.
Enable giftVouchers and the hosted page grows a voucher
storefront: brand-toned gift cards for each option, paid through your
own connected Stripe, the code emailed to the buyer the moment payment
completes. Codes are redeemable at booking: a voucher that covers the
deposit pays it outright (no payment link at all); a smaller one is
noted on the booking for the till, with your configured
redemptionNote telling the customer exactly what happens to
it.
promoCodes are validated at checkout (kind
is percent or fixed, with optional expiry and
an active toggle) and recorded on the booking for the till;
promoNote phrases what the customer is told when a code
applies. The public voucher-check and
promo-check routes power the page's Apply buttons and
answer honestly — an exhausted voucher says it has been used in full, an
expired one says it has expired.
{
"giftVouchers": {
"enabled": true,
"validityMonths": 12,
"options": [ {"amountGBP": 25, "title": "A special treat for you"}, {"amountGBP": 50, "title": "Happy Christmas"} ],
"redemptionNote": "It will be deducted from your treatment cost at your appointment."
},
"promoCodes": [ {"code": "WINTER10", "kind": "percent", "value": 10, "description": "Winter offer"} ]
}Everything your customers read is your wording, never ours — each line is configuration with a sensible neutral default (the dashboard gathers them all in one Your voice panel):
| Field | What it phrases |
|---|---|
greetingStyle |
"friendly" ("Hi Jason,") or "formal"
("Dear Mr Edge,") in every booking email — formal keeps the title the
customer gave |
types.{key}.paymentDescription |
Your deposit policy, spoken wherever that service's deposit is mentioned: the catalogue, the confirmed screen, the email, the SMS. Write a sentence ("This deposit is deducted from your treatment cost.") and it speaks everywhere; a label form ("Deposit: …") only names the Stripe payment line |
giftVouchers.redemptionNote |
What happens to a voucher that doesn't cover the deposit, and the line in the voucher email |
promoNote |
What customers are told when a promo code applies |
detailFields.marketingConsentText |
The consent ask beside the tick-box |
cancellationPolicy |
Shown before and after a customer cancels |
terms.text |
The acceptance statement, recorded verbatim on every booking |
newClientGate.message |
What first-timers see when steered to a consultation |
vocabulary |
"clinic", "salon", "trades"
or "hospitality" — relabels the workspace (Patients,
Clients, Guests, jobs) AND picks the vertical-correct default wording
for every line you leave blank |
All of it rides the same booking settings PUT; leave any
field blank and the customer sees the neutral default for your
vocabulary.
Everything the dashboard diary does is plain API, all requiring
manage_bookings:
Create takes
{"kind": "booking", "startUnix": …, "endUnix": …, "offeringKey": "consultation", "partyName": "Jane Doe"}
for a manual booking, or
{"kind": "block", "startUnix": …, "endUnix": …, "label": "Lunch"}
for a private block — blocks make the time unbookable but are never
shown to customers or callers.
Close dates writes a full-day private block per day per lane across an inclusive range — one call for "closed 20–28 August":
Days already in the past are skipped, today is clamped to now, and existing bookings on closed days are deliberately left in place — the response lists what was created, skipped and (rarely) failed, so you can deal with clashing bookings personally rather than have them silently cancelled.
curl -X POST 'https://api.everycallhandled.com/api/v1/apps/{appId}/bookings/close-dates' \
-H 'Authorization: Bearer YOUR_TOKEN' \
-H 'Content-Type: application/json' \
-d '{"fromDate": "2026-08-20", "toDate": "2026-08-28", "resourceId": "all", "label": "Summer closure"}'The diary quietly builds a customer directory: every booking's party (name, phone, and their booking history) is queryable and editable, with duplicate-merge built in.
Requires manage_bookings. In the dashboard this is the
workspace's Customers section, with segmentation filters and export.
Every booking carries a record — the professional
evidence trail behind the appointment: what was done and used, permanent
notes, and a completeness checklist. In the dashboard it opens from the
diary or a customer's history ("Open record"); over the API it is four
routes, all requiring manage_bookings:
GET record returns the profile in force (today the
built-in generic-v1 delivery-record profile), the booking's
headline facts, the checklist (each item's
kind, label and status of
completed / waived / missing),
the ordered notes, and the delivery
work-details block with who recorded it and when.
Work details (record/delivery) takes
{"lines": [...]} — up to 20 lines, each
{"item": "Annual boiler service", "product"?: "Worcester Greenstar 30i", "batch"?: "…", "expiry"?: "…", "quantity"?: 1, "priceGBP"?: 95, "comment"?: "…"}.
item is required per line; the batch/expiry fields exist so
a clinic can record exactly what was administered — product, batch and
expiry per treatment, the pharmacovigilance shape. Saving again replaces
the lines (it is a statement of what happened, not a log), and every
save is stamped with the practitioner and time.
Notes (record/note) takes
{"content": "…", "correctsNoteId"?: "…"} and is
append-only by construction: a saved note is stamped
with author name and time and can never be edited or deleted through any
API. A correction is a new note carrying correctsNoteId, so
the full history always survives. Content is capped at 2,000 characters;
the route returns the new noteId.
Checklist (record/artefact) takes
{"kind": "delivery-details" | "notes", "status": "completed" | "waived", "waiveReason"?: "…"}.
Waiving requires a written reason (422 without one),
and the reason is kept on the record with who waived and when.
Nothing in the record is ever shown to customers — it is the operator's own evidence trail, attached to the booking permanently. The current generic profile deliberately excludes special-category clinical data (photos, consent capture, sign-off locks); richer regulated profiles arrive as separate reviewed documents.
Post-visit review requests feed a moderated review store: list,
publish/unpublish and remove via
GET /api/v1/apps/{appId}/reviews,
PATCH /api/v1/apps/{appId}/reviews/{reviewId} and
DELETE. Published reviews render on the hosted booking
page.
Requires manage_bookings.
Response (200): the app's booking settings plus the connection state:
status is connected or
disconnected. Apps using the Semble integration always
report connected — their diary lives in Semble itself.
curl 'https://api.everycallhandled.com/api/v1/apps/{appId}/booking' \
-H 'Authorization: Bearer YOUR_TOKEN'{
"booking": { /* schedule, appointment types, ... */ },
"connection": { "status": "connected", "connectedEmail": "owner@example.com" }
}Requires manage_bookings. Returns a signed
hosted-consent URL the calendar owner opens in their browser to grant
access. provider is google (default) or
microsoft. Nothing connects until they complete the consent
screen; they land back on the app's Bookings tab afterwards.
Response (200):
{ "configured": false, "url": null } means calendar
booking is not enabled for your account — contact support.
curl 'https://api.everycallhandled.com/api/v1/apps/{appId}/booking/authorize' \
-H 'Authorization: Bearer YOUR_TOKEN'{ "configured": true, "provider": "google", "url": "https://api.eu.nylas.com/v3/connect/auth?..." }Requires manage_bookings. Lists every upcoming
appointment across the app's connected calendar(s) for the next
days days (1-31, default 7) — the business-owner view of
what the assistant has booked. Currently supported for
calendar-connected apps; apps on the Semble integration return
supported: false with a message pointing at the Semble
diary, since their appointments live there.
Response (200):
curl 'https://api.everycallhandled.com/api/v1/apps/{appId}/bookings' \
-H 'Authorization: Bearer YOUR_TOKEN'{
"provider": "nylas",
"supported": true,
"windowDays": 7,
"count": 2,
"bookings": [
{ "eventId": "...", "date": "2026-07-18", "time": "10:30", "label": "Check-up", "title": "Mrs Hughes — Check-up" }
]
}What is Semble? Semble is a practice management system widely used by private healthcare clinics in the UK — it holds the clinic's diary, its appointment types and its records. If your business doesn't use Semble, skip this whole section: the generic calendar booking above is the path for you. Semble is the first of a growing set of third-party system integrations; others will follow the same pattern — a special-case configuration surface layered on top of the generic booking experience.
For a Semble-connected app, appointment booking is driven by a declarative booking config — a set of named appointment types, each binding a Semble product/location to the rules the assistant follows when it offers and books slots (practitioner binding, new-patient flow, scan horizon, per-day hours). The config is applied to the live assistant immediately on write.
Every route in this section Requires
manage_booking_config — an opt-in only
permission: it is not part of any role baseline, not even
Owner. It's granted per user with an explicit
+manage_booking_config override from the Team page in your
dashboard. Don't assume an Owner token can hit these endpoints — verify
the grant.
Read the current config
Response (200):
hasSemble tells an editor whether the Semble integration
is wired up for this app (and therefore whether the catalog picker below
will return anything).
Write the config
The body must carry a top-level appointmentBooking
object with a types map. The write replaces only
the appointmentBooking sub-key and preserves every
sibling verbatim; live calls pick the change up immediately.
A successful PUT returns the same shape as the
GET (re-read after write). Omit
appointmentBooking and you get
400 appointmentBooking is required.
The per-type schema
Each entry under types is keyed by a type
key — the machine identifier, which must match
^[a-z0-9_]+$ (e.g. initial_consult). Fields on
the type object:
| Field | Type | Notes |
|---|---|---|
displayName |
string | The label the assistant speaks to the caller. Trimmed on write. |
sembleProductId |
string | The Semble appointment-type/product this maps to. |
locationId |
string | The Semble location the booking is created against. |
practitionerId / practitionerName |
string | Optional Semble practitioner binding. |
duration |
number | Appointment length in minutes. Must be positive. |
slotAlignment |
number | Slot grid granularity in minutes (e.g. 15). Must be positive. |
allowedDays |
int[] | Bookable weekdays, 0=Sun … 6=Sat. Deduped
and sorted on write; each entry must be a whole number 0–6. Empty/absent
= no day restriction. |
leadTimeMins |
number | Minimum notice before a slot can be booked. Must be >= 0. |
nextAvailableScanDays |
int | How many days ahead the assistant scans for the next free slot. Whole number 1–70. |
position |
int | Optional presentation order (see below). Must be >= 1. |
startHour / endHour |
number | Optional default business hours for the type. When either is set,
both must satisfy
0 <= startHour < endHour <= 23. |
dayWindows |
object | Optional per-day hours override,
{ "1": { "startHour": 9, "endHour": 15 } }. Keys 0–6; same
hour rule. Takes precedence over the flat
startHour/endHour, and doubles as the day
whitelist where hours differ by day. |
newPatientFlow |
bool | Marks this as a Semble new-patient type (triggers the intake/questionnaire flow). |
newPatientFormUrl |
string | External questionnaire URL. Required when the Semble new-patient flow is on — unless the app's hosted intake form is active (see Intake Forms), which replaces it. |
Unknown keys on a type are preserved (forward-compat), so a newer field survives a round-trip through an older editor.
Position ordering. position is
1-based (1 = first). It is the
order the assistant offers types in — it feeds the booking tool's
options and the assistant's coaching, and drives card lists in
chat/voice. So "always offer the consultation type first" is
configuration, not model judgement. Types without a
position fall after all positioned types, in
stored-object order. Positioned types sort ascending by
position, ties broken by stored order.
Semble catalog (live pickers)
Returns live Semble products, practitioners, and locations so an editor can render real pickers instead of asking people to hand-type IDs.
This call reaches out to Semble live. If the app has no Semble
credential configured, or Semble can't be reached, you get empty arrays
plus an error string explaining why — the endpoint never
hard-fails:
Validation rules (enforced on PUT)
typesmust be an object; each type key must match^[a-z0-9_]+$, and duplicate keys are rejected.durationandslotAlignment, if present, must be positive numbers.leadTimeMins>= 0;position>= 1;nextAvailableScanDaysa whole number 1–70.allowedDaysmust be an array of whole numbers 0–6.startHour/endHour(and everydayWindowsentry) must satisfy0 <= startHour < endHour <= 23.- The Semble new-patient flow requires a
newPatientFormUrlunless the hosted intake form is active on the app.
All errors are collected and returned together as a 400
with a ;-joined message.
How availability is actually decided
Availability for a Semble-connected app is the product of three independent layers, and the config is only one of them:
- Semble's own schedule — Semble returns the raw set of open slots for the product/practitioner/location. If Semble says a slot doesn't exist, no config can conjure it.
- The type's config rules — the assistant then
filters those slots by
allowedDays(or per-daydayWindows), business hours (startHour/endHour),leadTimeMins, and thenextAvailableScanDayshorizon. This is the layer you control here. - The assistant's prompt — how types are offered,
sequenced (via
position) and spoken about on the call.
A slot is only offered when all three agree. So if a Wednesday slot
never comes up, check in order: does Semble show it open, do
allowedDays/dayWindows include Wednesday
within hours, and does the prompt surface that type at all.
curl https://api.everycallhandled.com/api/v1/apps/$APP_ID/booking-config \
-H 'Authorization: Bearer YOUR_TOKEN'{
"appointmentBooking": {
"types": {
"initial_consult": {
"displayName": "New Patient Consultation",
"sembleProductId": "prod_8812",
"locationId": "loc_04",
"practitionerId": "usr_231",
"practitionerName": "Dr Amara Okafor",
"duration": 30,
"slotAlignment": 15,
"leadTimeMins": 120,
"nextAvailableScanDays": 21,
"allowedDays": [1, 3, 5],
"startHour": 9,
"endHour": 17,
"position": 1,
"newPatientFlow": true,
"newPatientFormUrl": "https://forms.gle/zTEZEZv9example"
},
"follow_up": {
"displayName": "Follow-up",
"sembleProductId": "prod_8815",
"locationId": "loc_04",
"duration": 15,
"slotAlignment": 15,
"leadTimeMins": 60,
"nextAvailableScanDays": 14,
"allowedDays": [1, 2, 3, 4, 5],
"position": 2
}
}
},
"hasSemble": true,
"editable": true
}curl -X PUT https://api.everycallhandled.com/api/v1/apps/$APP_ID/booking-config \
-H 'Authorization: Bearer YOUR_TOKEN' \
-H 'Content-Type: application/json' \
-d '{
"appointmentBooking": {
"types": {
"initial_consult": {
"displayName": "New Patient Consultation",
"sembleProductId": "prod_8812",
"locationId": "loc_04",
"duration": 30,
"slotAlignment": 15,
"leadTimeMins": 120,
"nextAvailableScanDays": 21,
"allowedDays": [1, 3, 5],
"position": 1
}
}
}
}'curl 'https://api.everycallhandled.com/api/v1/apps/{appId}/booking-config/semble-catalog' \
-H 'Authorization: Bearer YOUR_TOKEN'{
"products": [
{ "id": "prod_8812", "name": "New Patient Consultation", "duration": 30, "locationId": null }
],
"practitioners": [
{ "id": "usr_231", "name": "Dr Amara Okafor" }
],
"locations": [
{ "id": "loc_04", "name": "Harley Street — Main" }
]
}{ "products": [], "practitioners": [], "locations": [], "error": "Semble integration is not configured for this app." }