Skip to content
Booked Help centre

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.

Build every step, even the ones you cannot see today

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
1REQUIRED — Nothing hardcoded. No service names, prices, durations, addresses, staff or opening times in your code.
2REQUIRED — Times come from /public/availability. Never generate or guess them.
3REQUIRED — Screening questions, when a service has them. Build it blind.
4REQUIRED — Document upload, when a service demands identification. Build it blind.
5REQUIRED — Handle every documented failure distinctly. A clinical refusal is not an error.
6REQUIRED — Empty states. No services, no free times, no locations.
7REQUIRED — Log the real error to the console. Never let a render bug look like a network failure.
8Cap long time lists, and drop times already past on today.
9Card 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.

CallPurpose
GET /public/catalogServices, locations, categories, staff, currency, company
GET /public/availabilityFree times: ?service_id=&location_id=&date=YYYY-MM-DD
POST /public/intake-uploadStore one identity document, returns a token
POST /public/bookingsCreate the appointment
JavaScript
// 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 fieldNote
id name short_descriptionWhat to show
durationMinutes
priceMajor units. 85 means £85.00. Never divide by 100.
depositAn object — { required, amount, label }. Never a number.
location_idsBranches offering it. Filter on this.
category_idIts category, when categories are used
intakePresent only when the service screens patients. See below.
is_videoHeld 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.

JavaScript
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.

JavaScript
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.

Build dates from local parts, not toISOString()

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.

JavaScript
// 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

typeRender as
yesnoTwo choices, values exactly "yes" and "no"
selectOne choice from options[] — { value, label }
multiselectSeveral from options[]; submit an array
textSingle-line input
numberNumeric input
dateDate input

Each question also has label, required, and often help — show the help text, it is clinical guidance. Submit answers keyed by question id:

JavaScript
intake: {
  "q_gylo4k": "1990-05-14",        // date
  "q_2u3h":   "Kenya",             // text
  "q_pnihi2": "no",                // yesno
  "q_preg":   "breastfeeding"      // select — the value, not the label
}
These are clinical questions

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.

JavaScript
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, … ]
ConstraintValue
Acceptedjpg, jpeg, png, webp, heic, heif, pdf
Maximum12 MB per file
Per requestOne 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.

JavaScript
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.

StatusCodeMeansWhat to do
400gb_invalidMissing details, answers or documentsPoint at the field. The message names what is missing.
409gb_error (ref GB-SLOT), gb_slot_taken, gb_bundle_unavailable, gb_course_unschedulableThe slot, a course visit or a same-visit extra was taken while they filled the formReload 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.
422gb_ineligibleA screening rule refused it on clinical groundsShow the pharmacy's message verbatim, offer a phone number. Not an error.
429gb_rate_limitedToo many attempts from this address"Please wait a few minutes." Ten attempts per address per ten minutes, and uploads share this limiter.
500—Something brokeGeneric apology, and log the real error.
JavaScript
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 );
  }
}
Never swallow the real error

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.

Never build your own card inputs

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:

CSS
.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 intake object to a service in Booked and click through it.
  • Confirm document upload is implemented — same test, with require_docs on.
  • 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.