Checking your session…

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:

  1. 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.
  2. A connected calendar (Google today, Microsoft 365 supported) — the assistant books straight into a calendar you already run.
  3. 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).

PUT/api/v1/apps/{appId}/bookingmanage_bookings
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} }
        }
      }
    }
  }'
Sign in to run this against your account.

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.

GET/api/v1/public/book/{appId}/config# services, hours, branding, terms
GET/api/v1/public/book/{appId}/slots# open slots for a service + date range
POST/api/v1/public/book/{appId}/hold# lock a slot briefly while details are entered
POST/api/v1/public/book/{appId}/confirm# turn a hold into a booking
POST/api/v1/public/book/{appId}/release# let a hold go
POST/api/v1/public/book/{appId}/manage# customer self-service via a signed manage link
POST/api/v1/public/book/{appId}/manage-cancel
POST/api/v1/public/book/{appId}/manage-reschedule
POST/api/v1/public/book/{appId}/review# leave a review via a post-visit link
GET/api/v1/public/book/{appId}/reviews# published reviews for the page
POST/api/v1/public/book/{appId}/voucher-checkout# buy a gift voucher (Stripe checkout)
POST/api/v1/public/book/{appId}/voucher-check# is this voucher code usable, and for how much
POST/api/v1/public/book/{appId}/promo-check# is this promo code valid
curl 'https://api.everycallhandled.com/api/v1/public/book/{appId}/slots?type=consultation&days=7'
Sign in to run this against your account.

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, gender and address are each "optional" or "required" (absent = not asked); note ("optional") adds a free-text note for the practitioner; marketingConsent: true adds a consent tick-box whose answer is stored on the customer record with source and timestamp. marketingConsentText lets 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.

JSON
{
  "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.

GET/api/v1/apps/{appId}/bookings/diary?start={unix}&end={unix}# the diary window: lanes + bookings + private blocks
POST/api/v1/apps/{appId}/bookings/create# manual booking or private block
POST/api/v1/apps/{appId}/bookings/{bookingId}/cancel
POST/api/v1/apps/{appId}/bookings/{bookingId}/reschedule
POST/api/v1/apps/{appId}/bookings/{bookingId}/notes
POST/api/v1/apps/{appId}/bookings/close-dates# close a whole date range in one call
GET/api/v1/apps/{appId}/booking-reports# the report pack behind the workspace Overview
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"}'
Sign in to run this against your account.

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.

GET/api/v1/apps/{appId}/parties# the customer listmanage_bookings
POST/api/v1/apps/{appId}/parties# create a customer recordmanage_bookings
GET/api/v1/apps/{appId}/parties/{partyId}# one customer with their bookingsmanage_bookings
POST/api/v1/apps/{appId}/parties/{partyId}# updatemanage_bookings
POST/api/v1/apps/{appId}/parties/{partyId}/merge# duplicate merge: pick the survivor, history moves acrossmanage_bookings

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.

GET/api/v1/apps/{appId}/bookings/{bookingId}/record# the full record: checklist, work details, notes
POST/api/v1/apps/{appId}/bookings/{bookingId}/record/delivery# save the work-details lines (upsert)
POST/api/v1/apps/{appId}/bookings/{bookingId}/record/note# append a permanent note
POST/api/v1/apps/{appId}/bookings/{bookingId}/record/artefact# mark a checklist item completed or waived

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.

GET/api/v1/apps/{appId}/bookingmanage_bookings
curl 'https://api.everycallhandled.com/api/v1/apps/{appId}/booking' \
  -H 'Authorization: Bearer YOUR_TOKEN'
Sign in to run this against your account.
JSON
{
  "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.

GET/api/v1/apps/{appId}/booking/authorize?provider=googlemanage_bookings
curl 'https://api.everycallhandled.com/api/v1/apps/{appId}/booking/authorize' \
  -H 'Authorization: Bearer YOUR_TOKEN'
Sign in to run this against your account.
JSON
{ "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):

GET/api/v1/apps/{appId}/bookings?days=7manage_bookings
curl 'https://api.everycallhandled.com/api/v1/apps/{appId}/bookings' \
  -H 'Authorization: Bearer YOUR_TOKEN'
Sign in to run this against your account.
JSON
{
  "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)

  • types must be an object; each type key must match ^[a-z0-9_]+$, and duplicate keys are rejected.
  • duration and slotAlignment, if present, must be positive numbers.
  • leadTimeMins >= 0; position >= 1; nextAvailableScanDays a whole number 1–70.
  • allowedDays must be an array of whole numbers 0–6.
  • startHour/endHour (and every dayWindows entry) must satisfy 0 <= startHour < endHour <= 23.
  • The Semble new-patient flow requires a newPatientFormUrl unless 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:

  1. 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.
  2. The type's config rules — the assistant then filters those slots by allowedDays (or per-day dayWindows), business hours (startHour/endHour), leadTimeMins, and the nextAvailableScanDays horizon. This is the layer you control here.
  3. 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.


GET/api/v1/apps/{appId}/booking-configmanage_booking_config
curl https://api.everycallhandled.com/api/v1/apps/$APP_ID/booking-config \
  -H 'Authorization: Bearer YOUR_TOKEN'
Sign in to run this against your account.
JSON
{
  "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
}
PUT/api/v1/apps/{appId}/booking-configmanage_booking_config
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
        }
      }
    }
  }'
Sign in to run this against your account.
GET/api/v1/apps/{appId}/booking-config/semble-catalogmanage_booking_config
curl 'https://api.everycallhandled.com/api/v1/apps/{appId}/booking-config/semble-catalog' \
  -H 'Authorization: Bearer YOUR_TOKEN'
Sign in to run this against your account.
JSON
{
  "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" }
  ]
}
JSON
{ "products": [], "practitioners": [], "locations": [], "error": "Semble integration is not configured for this app." }