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 receptionist, or the API
sends an instant confirmation: email whenever an address is known, SMS
whenever a mobile is. 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.
Reminders and follow-ups
Reminders and the post-visit follow-up live in the comms
block of the booking settings (the dashboard's Messages
panel). Up to three reminder rules, each a number of hours before the
appointment, and one follow-up a number of hours after it. Each reminder
rule chooses its own channel: a text, an email, or a phone call placed
by the receptionist. The follow-up is a text or an email; it cannot be a
call in this version.
| Field | Values | Default |
|---|---|---|
remindersEnabled |
true / false |
false |
reminders[].hoursBefore |
1 to 336 (14 days); one rule per offset, at most three | |
reminders[].channel |
"sms", "email" or "call" |
"sms", so existing apps are unchanged |
reminderTemplate |
up to 480 characters, merge tags below | the text shown above |
followUpEnabled |
true / false |
false |
followUpHoursAfter |
1 to 336 | 24 |
followUpChannel |
"sms" or "email" |
"sms" |
followUpTemplate |
up to 480 characters | the text shown above |
quietStartHour, quietEndHour |
0 to 23, venue timezone; texts and emails wait until quiet hours end | 21, 8 |
Merge tags in templates and call scripts: %firstname%,
%fullname%, %offering%,
%business%, %date%, %time%, and
%manage_link% (texts and emails only; it becomes the
customer's signed manage link). Email reminders and follow-ups go out as
branded emails in your greeting style with the manage link included.
Text and email rules send once per booking and skip the quiet hours; a
send that is more than six hours overdue is dropped rather than sent
absurdly late.
Reminders by phone call
Set a reminder rule's channel to "call" and
the receptionist rings the customer instead. The call confirms
attendance, can offer to move the appointment, and can take a
cancellation if you allow it. It never takes money, never searches your
knowledge base, never transfers, and never asks the customer to spell
their name or leave a callback number: it already knows who it is
calling. The comms.calls block says what the call may do,
what it says, and when it may ring. It is saved whenever you send it or
whenever any rule is a call, always in its fully defaulted form.
| Field | Values | Default |
|---|---|---|
intents.confirm |
always true; the call exists to confirm attendance |
true |
intents.offerMove |
true / false: may offer to reschedule on
the call |
true |
intents.allowCancel |
true / false: may cancel on the call |
false |
scripts.opening |
what the customer hears once they confirm who they are; up to 300 characters | shown above |
scripts.voicemail |
left once on an answering machine; up to 300 characters.
%phone% is the app's own number, read out as a receptionist
would say it |
shown above |
scripts.thirdParty |
said to anyone who is not the customer; never carries the date, time or service; up to 300 characters | shown above |
scripts.screener |
said to a call-screening service; up to 300 characters | shown above |
voicemailSpeaksTime |
true / false: whether the voicemail line
speaks the appointment time |
true |
windowStartHour |
0 to 23, venue timezone | 9 |
windowEndHour |
1 to 24, must be after the start (otherwise both reset to 9 and 19) | 19 |
weekdaysOnly |
true / false |
false |
maxAttempts |
1 or 2 |
2 |
retryGapMinutes |
15 to 240 | 45 |
fallback |
"sms", "email" or "none":
what goes out when the call does not reach the customer |
"sms" |
identityCheck |
"name" (asks for the customer by first name) or
"name-dob" (also asks for their date of birth and checks it
silently against the record) |
"name" |
| persona.mode | "inherit" or
"custom". An outbound call never uses the inbound
receptionist prompt: it always speaks with the platform's outbound
manner (who it is, why it is calling, ask for the customer by name, one
sentence at a time). inherit keeps the app's inbound voice,
language and recording notice; custom lets you set them for
outbound calls | "inherit" | | persona.voice |
custom only: a standard voice id (the same list as the app
voice attribute) | null | |
persona.pipelineConfig | custom only: a Cartesia voice as
{ "mode": "modular", "tts": { "provider": "cartesia", "voice": "<id>" } };
wins over voice | null | |
persona.prompt | custom only: your own words for the
opening sections of the outbound manner (who it is, why it is calling,
how it speaks); up to 2000 characters. The identity check, the spoken
lines and the rules are always the platform's | "" | |
persona.startingLanguage | custom only: ISO code the call
opens in; "" = the app's inbound starting language |
"" | | persona.eagerness |
"patient" (waits longer, never talks over the customer),
"balanced" or "quick": how soon it takes its
turn after the customer pauses | "patient" | |
persona.disclosure | "off" (no notice, the
default), "inherit" (the app's recording notice) or
"custom" (persona.disclosureText, up to 300
characters). Spoken once, after the customer has confirmed who they are
| "off" |
Hear it before anyone else does. Two calls read and
ring with the same plan a real reminder would carry, using a sample
booking (the app's first appointment type, tomorrow at ten). Pass
calls to try an unsaved calls block from the panel; leave
it out to use what is saved.
The preview returns plan: spoken (the
identity line, the opening, the voicemail line, the third-party line and
the screener line exactly as they will be said), persona
(voice, language, turn-taking), outcomes,
tools, instructions and sample.
The test call returns placed, roomName and
answered; it records nothing against any booking, and the
receptionist's hooks answer it as they would a real call. Quiet hours
for texts and emails are comms.quietStartHour and
comms.quietEndHour (defaults 21 and 8); the call window is
the calls block's own.
Scripts left blank fall back to the defaults shown; whitespace is
collapsed. Anything outside a range falls back to its default rather
than failing the save. When persona.mode is
inherit, the custom fields read back as null
or empty.
How a call reminder runs. A call rule only rings
inside the call window and only while the appointment is more than one
hour away; if the rule falls due too late to ring, the fallback channel
is used straight away. Calls are placed from your own phone number when
the app has one. Each attempt is checked before dialling: the app must
not be paused, the booking must be confirmed or pending approval, the
number must be a valid international mobile, the customer must not have
declined reminder calls, and the credit balance must cover a four-minute
call. An answered call is billed as ordinary voice usage; unanswered
attempts cost nothing. The receptionist waits for the person to speak,
tells voicemail, call screeners and phone menus apart, and states the
date, time and service only once the customer has confirmed who they
are. A retryable outcome tries again after retryGapMinutes,
up to maxAttempts; when attempts run out, or the outcome
makes another try pointless, one send goes out on the
fallback channel and the rule closes.
Call outcomes, as recorded on the booking and in the send log:
- Reached:
confirmed,reschedule-requested,rescheduled,cancelled,reminded,voicemail-left. - Retryable:
no-answer,busy,no-response,voicemail-no-message,screened,stale(a call that never reported back within twenty minutes). - Terminal, fall back at once:
wrong-person,wrong-number,declined-calls,failed.
A customer who says "text me instead" or "don't call me" on a
reminder call is recorded as having declined: their customer record gets
consents.reminders.call: false (with
callDeclinedAt), the outcome is
declined-calls, and every later call rule for them routes
to the fallback channel without dialling. The consent is read on the
customer record (GET /parties/{partyId}) and is written by
the receptionist, not through the customer update route.
The message trail
Every booking carries a commsSent map, one entry per
rule, keyed reminder-<hours> or
followup-<hours>. It is visible on the booking rows
of a customer's history (GET /parties/{partyId}). Each
entry is an object:
channel is sms, email or
call; attempt counts call attempts;
status is sent or failed for
texts and emails, placing while a call is being dialled, a
call outcome from the list above, or
skipped:<reason>. fallbackSent: true
means the one fallback send has gone out for a call rule. Entries
written by a reminder call may also carry
customerConfirmedAt, chatId (the call's
conversation), durationSeconds and notes (the
receptionist's short note on the outcome). Bookings from before this
release hold a plain ISO timestamp per rule instead; read that as a text
sent on that date.
The skipped: reasons are no-phone,
no-email and contact-mismatch (the booking's
number and the customer record's number disagree, so the receptionist
never dials on a guess) from the scheduler, and no-app,
paused, outside-window,
no-booking, booking-not-live,
too-close, not-e164,
declined-calls, credit and
no-trunk from the pre-dial checks. Every skip is written to
the marker and to the send log, so nothing goes quiet without a
reason.
The send log behind the report pack keeps one row
per send attempt: the booking, the rule key, channel
(sms, email or call), the body as
sent (for a call, the attempt number and outcome), status
(sent, failed, the call outcome, or
skipped:<reason>) and the time.
{
"booking": {
"comms": {
"remindersEnabled": true,
"reminders": [
{ "hoursBefore": 48, "channel": "email" },
{ "hoursBefore": 24, "channel": "call" },
{ "hoursBefore": 2, "channel": "sms" }
],
"reminderTemplate": "Hi %firstname%, a reminder of your %offering% with %business% on %date% at %time%. Manage it here: %manage_link%",
"followUpEnabled": true,
"followUpHoursAfter": 24,
"followUpChannel": "sms",
"followUpTemplate": "Hi %firstname%, thanks for visiting %business%. If you need anything else, just call or book again any time.",
"quietStartHour": 21,
"quietEndHour": 8,
"calls": { /* see below */ }
}
}
}{
"calls": {
"intents": { "confirm": true, "offerMove": true, "allowCancel": false },
"scripts": {
"opening": "Hi %firstname%, this is %business% calling about your %offering% on %date% at %time%. Are you still able to make it?",
"voicemail": "Hello, this is %business% with a reminder of your appointment on %date% at %time%. If you need to change it, please call us on %phone%.",
"thirdParty": "Sorry to trouble you. Would it be possible to leave a message for %firstname%? Could you ask them to call %business% on %phone% about their appointment?",
"screener": "%business% calling for %firstname% about an appointment."
},
"voicemailSpeaksTime": true,
"windowStartHour": 9,
"windowEndHour": 19,
"weekdaysOnly": false,
"maxAttempts": 2,
"retryGapMinutes": 45,
"fallback": "sms",
"identityCheck": "name",
"persona": {
"mode": "inherit",
"voice": null,
"pipelineConfig": null,
"prompt": "",
"startingLanguage": "",
"eagerness": "patient",
"disclosure": "off",
"disclosureText": ""
}
}
}{
"reminder-24": { "at": "2026-09-08T10:12:04.000Z", "channel": "call", "attempt": 2, "status": "no-answer", "fallbackSent": true },
"reminder-2": { "at": "2026-09-09T08:00:11.000Z", "channel": "sms", "attempt": 1, "status": "sent" }
}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.
A customer record carries a read-only consents object:
marketing (per channel, captured on the booking page) and
reminders. consents.reminders.call === false
means the customer has declined reminder phone calls; the receptionist
writes it when they ask on a call, and reminders for them then go out on
the fallback channel instead. It cannot be set through the update
route.
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.
The record does not hold the booking's reminder history. Reminders,
follow-ups and reminder-call outcomes are kept on the booking itself as
the commsSent map (see The message trail
above), one entry per rule.
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.
The comms block reads back exactly as the server keeps
it: every reminder rule with its channel,
followUpChannel, the quiet hours, and, whenever any rule is
a phone call or the block was ever sent, the full calls
block with every default filled in (see Reminders by phone
call). Fields you never sent still appear with their
defaults.
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." }