Build a custom booking form, step by step
A complete build-along: your own HTML and CSS, Booked's engine underneath. Follow it yourself or hand it to an AI.
A complete, tested build-along for a booking form that looks nothing like Booked and is driven entirely by it. Written to be followed by a person or handed to an AI assistant. Every fact here was verified against a live install, including the parts that only bite in production.
A catalogue with no screened services today will have one the moment the pharmacy adds a flu clinic or a weight-management review. A form that omits screening because the API happened not to return any looks finished and is broken — it fails only for the service that needed it most. Sections marked REQUIRED below must be implemented whether or not your test site exercises them.
The build contract
Nine things. A form missing any of them is incomplete, however good it looks.
| Requirement | |
|---|---|
| 1 | REQUIRED — Nothing hardcoded. No service names, prices, durations, addresses, staff or opening times in your code. |
| 2 | REQUIRED — Times come from /public/availability. Never generate or guess them. |
| 3 | REQUIRED — Screening questions, when a service has them. Build it blind. |
| 4 | REQUIRED — Document upload, when a service demands identification. Build it blind. |
| 5 | REQUIRED — Handle every documented failure distinctly. A clinical refusal is not an error. |
| 6 | REQUIRED — Empty states. No services, no free times, no locations. |
| 7 | REQUIRED — Log the real error to the console. Never let a render bug look like a network failure. |
| 8 | Cap long time lists, and drop times already past on today. |
| 9 | Card fields must be Booked Payments' hosted fields, never inputs of your own. |
The endpoints
All unauthenticated — these are what Booked's own widget uses, and your customers are anonymous.
| Call | Purpose |
|---|---|
GET /public/catalog | Services, locations, categories, staff, currency, company |
GET /public/availability | Free times: ?service_id=&location_id=&date=YYYY-MM-DD |
POST /public/intake-upload | Store one identity document, returns a token |
POST /public/bookings | Create the appointment |
// Root install: '/wp-json/get-booked/v1/'
// Subdirectory install: '/yoursite/wp-json/get-booked/v1/'
const API = '/wp-json/get-booked/v1/';
async function api( path, opts ) {
const res = await fetch( API + path, opts );
const body = await res.json().catch( () => ( {} ) );
if ( ! res.ok ) {
const e = new Error( body.message || ( 'HTTP ' + res.status ) );
e.status = res.status;
e.code = body.code;
throw e;
}
return body;
}Reading the catalogue
| Service field | Note |
|---|---|
id name short_description | What to show |
duration | Minutes |
price | Major units. 85 means £85.00. Never divide by 100. |
deposit | An object — { required, amount, label }. Never a number. |
location_ids | Branches offering it. Filter on this. |
category_id | Its category, when categories are used |
intake | Present only when the service screens patients. See below. |
is_video | Held online, so branch does not constrain it |
Currency lives at catalog.currency.symbol, and the business name at catalog.company.name — use them rather than writing "£" or the pharmacy's name into your markup.
const sym = catalog.currency.symbol;
const money = ( n ) => sym + Number( n || 0 ).toFixed( 2 );
// What the customer actually pays today.
function dueToday( s ) {
if ( ! Number( s.price ) ) return { amount: 0, isDeposit: false };
const dep = s.deposit && s.deposit.required ? Number( s.deposit.amount ) : 0;
return dep > 0 ? { amount: dep, isDeposit: true }
: { amount: Number( s.price ), isDeposit: false };
}Availability
Ask whenever the service, branch or date changes. Each slot is an object — { time, employee_id, iso }. Keep the employee_id: send it with the booking, or send 0 to let Booked choose.
const res = await api(
'public/availability?service_id=' + service.id +
'&location_id=' + location.id +
'&date=' + date // YYYY-MM-DD
);
const slots = res.slots || []; // [ { time: '09:20', employee_id: 3, iso: '…' }, … ]Those times already account for opening hours, staff rotas, holidays, existing bookings, buffers and lead time. Do not filter them further — except to drop times already past when the date is today.
new Date().toISOString().slice(0,10) is UTC and silently reports the wrong day for anyone west of Greenwich late in the evening. Build YYYY-MM-DD from getFullYear/getMonth/getDate.
Screening questions — REQUIRED
A service that screens patients carries an intake object. A booking without answers is rejected. Implement this even if no service on your test site has one.
// Only 'booking' mode is collected by you. 'link' mode is completed later
// by the customer via a secure link Booked sends; 'admin' is staff-only.
// 'after_payment' placement is answered after the booking exists, by token,
// through /public/intake-submit — not sent with the booking.
function intakeFor( service ) {
const d = service.intake;
if ( ! d || ( d.mode || 'booking' ) !== 'booking' ) return null;
if ( d.placement === 'after_payment' ) return null;
return ( ( d.questions || [] ).length || d.require_docs ) ? d : null;
}The object gives you title, intro, questions, optional consent text, placement, and the document fields: require_docs, min_docs and documents — a list of { id, label, required }, one per document asked for (a passport, a GP letter). Older definitions may only carry doc_label.
A question can carry show_if: it is only shown, and only required, when an earlier answer matches. Honour it — render the question when its condition is met and leave it out otherwise.
Question types you must render
type | Render as |
|---|---|
yesno | Two choices, values exactly "yes" and "no" |
select | One choice from options[] — { value, label } |
multiselect | Several from options[]; submit an array |
text | Single-line input |
number | Numeric input |
date | Date input |
Each question also has label, required, and often help — show the help text, it is clinical guidance. Submit answers keyed by question id:
intake: {
"q_gylo4k": "1990-05-14", // date
"q_2u3h": "Kenya", // text
"q_pnihi2": "no", // yesno
"q_preg": "breastfeeding" // select — the value, not the label
}Rules attached to them can block a booking outright. Never hide a question to smooth the flow, never pre-answer one, and never guess a default. If you will not render them, exclude those services from your form entirely.
Consent
When intake.consent is a non-empty string, show it beside a checkbox and require it before continuing. It is a statement the customer is agreeing to, so print it verbatim.
Required documents — REQUIRED
When intake.require_docs is true, the customer must supply at least min_docs files. Upload each one, keep the token, send the tokens with the booking.
const fd = new FormData();
fd.append( 'file', file ); // field name is exactly "file"
const body = await api( 'public/intake-upload', { method: 'POST', body: fd } );
// { ok: true, token: "X3Zzwlm…", name: "passport.png" }
// later, on the booking:
doc_tokens: [ body.token, … ]| Constraint | Value |
|---|---|
| Accepted | jpg, jpeg, png, webp, heic, heif, pdf |
| Maximum | 12 MB per file |
| Per request | One file, field name file |
Do not set a Content-Type header — the browser must set the multipart boundary. Validate type and size before uploading, because the file is encrypted on arrival and never served back to you. Block the customer from continuing while an upload is still in flight.
Creating the booking
The appointment is created before any card is taken.
const booking = await api( 'public/bookings', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify( {
service_id: service.id,
location_id: location.id,
employee_id: staffId, // 0 = let Booked choose
date: date, // YYYY-MM-DD
time: time, // HH:MM
first_name: first,
last_name: last,
email: email,
phone: phone,
website: '', // honeypot — must exist and stay empty
intake: answers, // only when the service screens
doc_tokens: tokens // only when it requires documents
} )
} );
// { ok: true, id: 318, reference: "GB-00318", status: "pending", payment: {…} }Failures — REQUIRED
Five distinct outcomes. Treating them as one generic error is the difference between a form people can use and one they abandon.
| Status | Code | Means | What to do |
|---|---|---|---|
400 | gb_invalid | Missing details, answers or documents | Point at the field. The message names what is missing. |
409 | gb_error (ref GB-SLOT), gb_slot_taken, gb_bundle_unavailable, gb_course_unschedulable | The slot, a course visit or a same-visit extra was taken while they filled the form | Reload availability and ask them to pick again. Do not lose their details. For the main slot the reference is in body.data.ref and at the end of the message. |
422 | gb_ineligible | A screening rule refused it on clinical grounds | Show the pharmacy's message verbatim, offer a phone number. Not an error. |
429 | gb_rate_limited | Too many attempts from this address | "Please wait a few minutes." Ten attempts per address per ten minutes, and uploads share this limiter. |
500 | — | Something broke | Generic apology, and log the real error. |
try {
const booking = await createBooking();
showConfirmation( booking );
} catch ( err ) {
console.error( '[booking]', err ); // always — see below
if ( err.status === 422 || err.code === 'gb_ineligible' ) {
showCannotBookOnline( err.message ); // clinical decision
} else if ( err.status === 409 ) {
reloadTimes(); showNotice( err.message );
} else if ( err.status === 429 ) {
showNotice( 'Too many attempts — please wait a moment.' );
} else {
showError( err.message );
}
}A catch that reports everything as "couldn't reach the booking service" makes a thrown render bug indistinguishable from a network failure. This costs hours. Always console.error the original.
Payment
If dueToday() is more than zero, money is owed. Label the button with the real figure — "Pay £17.00 and confirm" — so nobody is surprised, and hide the payment block entirely when the service is free.
Card fields must be Booked Payments' hosted fields or you take on PCI scope. The {{card_number}} / {{card_expiry}} / {{card_cvc}} mount points in Build your own booking form markup only exist inside Booked's own booking form — a standalone form cannot use them. For services that take payment, the simplest safe route is a Booked booking form with your own templates, including the Checkout layout.
The booking's payment object tells you what is owed: required, amount (minor units), amount_display and currency. When it carries a redirect_url, send the customer there to pay. Once a card payment succeeds, /public/confirm-payment with the booking id moves the appointment to confirmed — and the payment is recorded on the server even if that call never arrives.
Styling
Scope your palette to your own root element so it cannot leak into the rest of the page:
.myform {
--brand: #2c5f5a;
--cta: #e8734a;
}If paid services go through a Booked booking form, set that form's colours on its Style tab — the hosted card fields take their accent, text and placeholder colours from there.
Before you call it done
- Search your code for hardcoded strings. Any service name, price, duration, address or opening time you typed is a bug.
- Confirm screening is implemented — even if your test catalogue has none. Add an
intakeobject to a service in Booked and click through it. - Confirm document upload is implemented — same test, with
require_docson. - Force each failure. Answer a blocking question "yes" for the 422. Book the same slot twice for the 409. Submit more than ten times in ten minutes for the 429.
- Check the empty states. A Sunday with no hours; a category with no services.
- Test in the afternoon. Bugs in past-time filtering only appear when the day is half gone.
Related
- Build your own booking form markup — if you only want to restyle the cards inside Booked's own flow, template slots are far less work than this.
- The public booking API — full endpoint reference, including group bookings and courses.
- Screening questions and eligibility — how the rules, outcomes and collection modes are configured.