# Booked — developer documentation ## Start here # Architecture and integration map > What runs where, which extension points to use, and the behaviours that surprise developers: reminders, cron, and bookings that exist before the money. Source: https://trybooked.co/help/#dev/architecture Booked is a single WordPress plugin with a React admin, a React booking widget, and a licensing and payments platform it talks to over HTTPS. If you are integrating with it, this page is the map: what runs where, what you are allowed to change, and what will bite you. ## The shape of it | Piece | What it is | | --- | --- | | Admin app | A React single-page app on one WordPress admin screen. All navigation happens inside it — there are no WordPress submenus. | | Booking widget | A separate React bundle rendered by shortcode on the public site. | | REST API | About 200 routes under get-booked/v1. The booking form's and customer account's endpoints are open; the rest need a team member's session or an API key. | | Database | Several dozen custom tables, prefixed getbooked_. Bookings are not posts. | | Platform | Licensing, managed payments, AI and usage metering, reached over outbound HTTPS. | > **Note — Bookings are custom tables, not a post type.** There is no WP_Query for appointments, no post meta, and no admin list table to filter. Read through the REST API or query the tables directly, and expect neither to be a stable public contract. ## Where to hook in Pick the lightest tool that does the job — in this order. - **Booking form settings** — Colours, layout, steps and per-form CSS/JS are all configurable without code. Exhaust these first. - **Shortcode attributes** — Pre-selecting a service or branch, or rendering a specific styled form, is an attribute rather than a build. - **Hooks and filters** — A small set of actions and filters cover reacting to bookings and status changes, sending texts, printing tracking markup and supplying TURN servers. - **The public API** — Only when you are replacing the booking front end wholesale. ## What you must not do - **Do not edit plugin files. Booked checks its licensing files against a signed manifest; a changed file disables the booking form on the live site, and every update overwrites your edits anyway. Do not collect card details yourself. The card fields at the card step come from Booked Payments' hosted script, for PCI reasons. Do not run one licence on two hostnames. The licence is domain-locked and a clone will fight production for it. Do not poll the admin REST API for changes. Hook get_booked_booking_event instead. Two behaviours that surprise people Reminders depend on cron Appointment reminders are on by default — one the day before, and the merchant can change or add more under Messages. A background sweep checks every fifteen minutes for bookings entering a reminder's window. On top of the seven lifecycle events — pending, approved, cancelled, rescheduled, changed, refunded, paid — each reminder is its own event (reminder, reminder_2 and so on), and intake_chase chases unanswered questions. If reminders arrive late or not at all, WordPress cron is the first thing to check. Texts go nowhere by default The SMS provider setting defaults to none. Booked still counts segments the moment it wants to send, so an unconfigured site accrues usage while delivering nothing. See Sending SMS through your own gateway. Booking lifecycle Worth internalising before you build anything against it — the appointment exists before the money does. The booking is created** — POST /public/bookings. No card involved yet. - **Payment is confirmed separately** — POST /public/confirm-payment verifies the charge against the existing booking. - **Status moves** — To confirmed, or to pending if the merchant approves bookings by hand. - **The event fires** — get_booked_booking_event — your integration point. The practical consequence: a customer who abandons at the card step leaves a real, unpaid appointment behind. Any integration that assumes "booking exists therefore paid" is wrong. ## Environment expectations - **Outbound HTTPS to the platform is required.** There is no offline mode; the licence lapses without contact. - **WordPress cron drives scheduled work**, so it only runs when someone visits. Use a real cron on quiet sites. - **Aggressive asset optimisation breaks the widget.** Exclude Booked's script handles from combining and deferring. Each of these is covered properly in **Staging, migrations and environments**. --- # Staging, migrations and environments > The domain-locked licence, cloning safely, cron, and what deleting the plugin destroys. Source: https://trybooked.co/help/#dev/environments Booked's licence is locked to one domain, and the signed permission it holds expires if the site cannot reach the platform. That combination makes staging sites, migrations and firewalled hosts the most common way a developer breaks a working install. This article covers what actually happens and how to work without tripping it. ## How the licence binds to a site On activation the licence records the site's domain, with `www.` stripped, and the site receives a signed grant it re-checks periodically. Two rules follow: - **The domain must match. A copy of the database on a different hostname is not licensed, whatever the key says. The site must be able to reach the platform. The grant expires after fourteen days without contact. A site behind a firewall that blocks outbound HTTPS will eventually stop working even though nothing about it changed. > **Warning — What the merchant sees.** The booking form stops rendering. Visitors see nothing where it was; only a logged-in administrator sees a notice explaining why. Bookings already in the database are untouched — this is a rendering and API gate, not data loss — but the site cannot take new bookings until the licence is healthy. Cloning to staging The failure mode to avoid is a clone that activates itself and steals the domain lock from production. Work in this order: Clone as normal** — Copy files and database to the staging hostname. - **Deactivate the licence on the clone** — Under Settings › Your plan, remove the key from the Licence key card. The staging site keeps all its data and simply will not render the booking form. - **Develop against the rest of the plugin** — Everything that is not gated — the admin screens, your hooks, your theme integration — still works. - **Never point staging at live payments** — Managed payments belong to the licensed production domain. Do not attempt to take real cards from a clone. If you need a fully working staging site including the booking form, that is a second licence rather than a trick — ask the merchant to raise it with Booked rather than trying to run one key on two hostnames. ## Moving a site to a new domain - **Change the domain on the Booked account first** — The lock is held by the platform, so it has to be released there before the new hostname can activate. - **Move the site** — Files, database, and WordPress's own Site Address settings. - **Re-activate under Settings › Your plan** — Enter the key again on the new domain. - **Check the booking form renders** — Load a page carrying the shortcode as a logged-out visitor. ## Firewalled and air-gapped hosts The site makes outbound HTTPS calls to the platform for licence checks, managed payments, AI features and usage metering. If the host restricts outbound traffic, the licence will lapse on its own schedule. There is no offline mode — allowlist outbound HTTPS to the platform, or the install is not viable. ## Background jobs Booked leans on WordPress cron, which only runs when someone visits the site. On a quiet site scheduled work drifts or is missed for the day — most visibly appointment reminders, which are checked every fifteen minutes and can arrive late or not at all. Follow-ups, waiting-list offers and the team digest drift the same way. On any site where timing matters, disable WordPress's visitor-driven cron and drive it from the system instead. ```php // wp-config.php define( 'DISABLE_WP_CRON', true ); ``` ```shell # every five minutes */5 * * * * cd /var/www/site && wp cron event run --due-now >/dev/null 2>&1 ``` ## Updating the plugin Booked checks its licensing files against a signed manifest. Editing one of them, or a deployment that rewrites them, trips the tamper check and disables the booking form. Edits anywhere else are simply lost on the next update. Everything a developer needs is available through hooks, shortcodes and the code fields on a booking form — build there, and updates stay uneventful. ## Uninstalling > **Warning — Deleting the plugin destroys the data.** Deactivating is safe and reversible. Deleting runs the uninstaller, which drops the core tables — every booking, customer, note, service, member of staff, location and message template — and removes the Booked role and capabilities. Take a database backup before removing it from any site you did not personally build yesterday.It is not a clean sweep. Newer tables (packages, API keys, follow-ups, sent messages, secure documents, the waiting list, unfinished bookings) and almost every get_booked_ option are left in the database. Drop those by hand if you need the site truly clean. --- ## Building on Booked # The public booking API > The unauthenticated endpoints the booking widget runs on — build your own front end against these. Source: https://trybooked.co/help/#dev/public-api Booked ships one genuinely open API: the endpoints its own booking widget runs on. They need no authentication, no key and no nonce, because the people using them are anonymous customers. If you want to build your own booking front end — a React app, a native app, a bespoke page — this is the surface to build against. > **Note — This is an unauthenticated write path.** Management routes need a team member's session or an API key. /public/bookings creates a real appointment from an anonymous request. Booked throttles it — ten booking attempts per IP address per ten minutes, shared with document uploads — and checks a honeypot field, but add your own spam protection on any busy custom front end. ## Base URL and endpoints Everything sits under the get-booked/v1 namespace, in the public group. ```base https://yoursite.co.uk/wp-json/get-booked/v1/public/ ``` | Endpoint | Method | What it does | | --- | --- | --- | | /public/catalog | GET | Everything bookable: services, categories, locations, employees, plus branding and formatting. | | /public/availability | GET | Free start times for one service on one date. | | /public/availability-range | GET | The same across a span of days, in one call. | | /public/bundle-availability | POST | Slots where a set of back-to-back services all fit. | | /public/bookings | POST | Creates the appointment. | | /public/confirm-payment | POST | Confirms a card payment against a booking. | | /public/intake | GET | Fetches a screening form by its token. | | /public/intake-check | POST | Evaluates answers against the eligibility rules. | | /public/intake-submit | POST | Stores completed answers. | | /public/intake-upload | POST | Uploads an ID or document, returns a token. | | /public/triage | POST | Answers the routing questions one step at a time, when the form asks them first. | | /public/waitlist | POST | Joins the waiting list for a full day. | | /public/waitlist-offer | POST | Reads a waiting-list offer. | | /public/waitlist-claim | POST | Takes the offered slot. | | /public/progress | POST | Records how far a visitor got, for the Unfinished list. Write-only. | | /public/conversion | POST | Claims a booking's conversion payload for tracking. Only one caller ever gets it. | Packages have their own open routes (`/public/packages` and friends), and so does the customer account (`/public/account/…`) — see **Customer portal**. ## Reading the catalog One call returns the whole bookable world. Pass `variant` to get a specific booking form's configuration, or omit it for the default. ```http GET /wp-json/get-booked/v1/public/catalog GET /wp-json/get-booked/v1/public/catalog?variant=v_8k3ma2qd ``` The response carries `services`, `categories`, `locations` and `employees`, alongside store-level values you will need to render prices and times correctly — `currency` (an object: `code` and `symbol`), `time_format`, `timezone`, `brand_color`, the `company` block and the booking form's `form` settings. `bundles`, `waitlist` and `video` are on/off flags for those features, not lists. Each service carries the fields that decide how it can be booked: `duration`, `price`, `deposit` (an object: `required`, `amount`, `label`), `group`, `course`, `is_video`, `intake`, plus `location_ids` and `employee_ids` telling you where and with whom it can happen. > **Note — Respect the link fields.** location_ids and employee_ids are the whole reason a service is or is not offered somewhere. Filter on them before you ask for availability, or you will request slots that can never exist. ## Asking for times `/public/availability` takes the service, the date, and optionally a staff member and a party size. A branch (`location_id`) is required for in-person services and ignored for video ones. Leave `employee_id` off and Booked considers everyone qualified. ```http GET /public/availability ?service_id=12 &location_id=3 &date=2026-09-14 &party=1 // 200 { "date": "2026-09-14", "slots": [ { "time": "09:00", "employee_id": 3, "iso": "2026-09-14T09:00:00+01:00" }, { "time": "09:20", "employee_id": 5, "iso": "2026-09-14T09:20:00+01:00" } ], "timezone": { /* … */ }, "earliest": "…" } ``` Each slot is an object. Send its `employee_id` with the booking, or `0` to let Booked choose. When a day comes back empty, `notice_message` explains whether it is simply too soon to book. For a date picker, `/public/availability-range` answers for many days at once rather than making you loop. ## Creating a booking A POST to `/public/bookings` creates the appointment. It is created before any card is taken — payment is confirmed separately, against the booking that already exists. ```javascript const res = await fetch( '/wp-json/get-booked/v1/public/bookings', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify( { service_id: 12, location_id: 3, employee_id: 0, // 0 = let Booked choose date: '2026-09-14', time: '09:20', first_name: 'Oliver', last_name: 'Ball', email: 'oliver@example.co.uk', phone: '07700 900145', party: 1, notes: '', website: '' // honeypot — must stay empty } ) } ); ``` A success returns the booking, its human reference and what is owed: ```json { "ok": true, "id": 318, "reference": "GB-00318", "status": "pending", "payment": { /* amount due, if any */ } } ``` | Field | Notes | | --- | --- | | employee_id | Send 0 to let Booked assign anyone qualified and free. | | party | Only meaningful on a service with group bookings switched on. | | website | A honeypot. Leave it empty; a filled value is treated as spam. | | course_visits | Chosen dates for a course, when the service runs over several visits. | | bundle_services | Extra services to run back-to-back in the same visit. | | intake / doc_tokens | Screening answers, and tokens returned by /public/intake-upload. | ## Taking payment If the service asks for money, the booking's `payment` object has `required: true`, the `amount` in minor units, a formatted `amount_display`, the `currency`, and whether the whole price is due (`full`). It also carries the values the card step needs to mount Booked Payments' hosted fields. - **If `redirect_url` is set,** send the customer there instead of showing card fields. - **Otherwise,** mount the hosted card fields, confirm the payment, then call `/public/confirm-payment` with the booking `id` and the returned payment reference (`payment_intent`). Booked verifies it and moves the appointment to confirmed. - **The server records the money anyway.** If the browser never calls back, payment is still recorded independently. > **Warning — Card fields are not yours to build.** Card details must never touch your own markup or your server. The card fields are supplied by Booked Payments' hosted script for PCI reasons — see Custom CSS, HTML and JavaScript for the content-security implications of blocking it. ## What is not open Everything for managing a business — bookings, customers, services, staff, settings, payments — needs either a signed-in team member (see **System › Team**) or a scoped API key. For server-to-server access, issue a key under **Settings › API keys** — see **API keys and the MCP server** and **Capabilities, roles and the admin API**. --- # Hooks and filters > Four actions and five filters, with signatures and working examples. Source: https://trybooked.co/help/#dev/hooks-filters Booked exposes four actions and five filters for developers. It is a deliberately small surface — enough to react to a booking, wire in a text gateway or tune video and group messages, without inviting people to rewrite the booking engine. Other `get_booked_` hooks exist in the code, but they are internal to Booked's own platform and can change without notice. Every hook name is prefixed `get_booked_`. ## Actions | Action | Arguments | Fires when | | --- | --- | --- | | get_booked_booking_event | $event, $booking_id | Any notification event: pending, approved, cancelled, rescheduled, changed, refunded, paid, each reminder (reminder, reminder_2…) and intake_chase. | | get_booked_booking_status | $from, $to, $booking_id | A team member changes a booking's status in the admin. Gives you the old and new status side by side. | | get_booked_send_sms | $to, $message | Booked wants a text sent. Booked SMS handles it when switched on; hook it to send through a gateway of your own. | | get_booked_pixels_body | — | Right after opens, where tracking markup can be printed. | ### Reacting to a booking `get_booked_booking_event` is the one to reach for when another system needs to know. It fires for every notification event, reminders included, so switch on the event rather than assuming. ```php add_action( 'get_booked_booking_event', function ( $event, $booking_id ) { if ( 'approved' !== $event ) { return; } // Push the confirmed appointment into your own system. my_crm_sync( $booking_id ); }, 10, 2 ); ``` > **Note — Reminders are events too.** Booked sends appointment reminders itself, and each one fires get_booked_booking_event with its own key. If you only care about real changes to a booking, ignore keys that start with reminder and intake_chase. ## Filters | Filter | Receives | Controls | | --- | --- | --- | | get_booked_ice_servers | $servers (array) | The STUN/TURN servers used by video consultations. | | get_booked_room_permissions_policy | $policy (string) | The Permissions-Policy header the video room sends. Return an empty string to send none. | | get_booked_append_party_block | $append (bool), $channel, $booking_id | Whether a group booking's email or text gets the list of attendees added to the end. | | get_booked_client_ip | $ip (string) | The address used to work out a visitor's country. Useful behind a proxy that hides it. | | get_booked_visitor_country | $code (string) | A two-letter country code, for visitors Booked could not place itself. Feeds the Live view. | ### Changing the brand colour This is a setting, not a hook. The brand colour lives under **Messages › Design › Brand colour** and is used by branded emails and the video room. The booking form takes its colours from its own Style tab under **Set up › Booking form**. ### Leaving the attendee list off group messages If your message templates already name everyone, stop Booked adding the list a second time. ```php add_filter( 'get_booked_append_party_block', function ( $append, $channel, $booking_id ) { return 'sms' === $channel ? false : $append; }, 10, 3 ); ``` ### TURN servers for video Video consultations connect browser-to-browser. On restrictive corporate networks that fails without a TURN relay of your own. ```php add_filter( 'get_booked_ice_servers', function ( $servers ) { $servers[] = [ 'urls' => 'turn:turn.example.co.uk:3478', 'username' => 'booked', 'credential' => '…', ]; return $servers; } ); ``` ## Where to put this code A small site plugin is the right home — not the theme's `functions.php`, which stops running the moment someone switches theme, and not the Booked plugin itself, which is replaced on every update. ```php // wp-content/plugins/acme-booked-tweaks/acme-booked-tweaks.php /** * Plugin Name: Acme — Booked tweaks */ defined( 'ABSPATH' ) || exit; add_action( 'get_booked_booking_event', 'acme_on_booking', 10, 2 ); ``` > **Warning — Do not edit the plugin.** Booked checks its licensing files against a signed manifest. Editing them trips the tamper check and disables the booking form on the live site. Everything here is designed so you never have to. --- # Shortcode reference > All seven shortcodes, every attribute, and the script handles to leave alone. Source: https://trybooked.co/help/#dev/shortcodes Booked gives a merchant site eight shortcodes: four booking forms, a custom-built form, the customer account, the packages shop and the confirmation card. While the licence is inactive none of the forms, the account or the shop renders — visitors see nothing, and only a logged-in administrator sees a notice saying why. ## The eight you can use | Shortcode | Renders | | --- | --- | | [get_booked] | The full booking form, all steps. | | [get_booked_compact] | A condensed form for a sidebar or a narrow column. | | [get_booked_short] | A shortened flow, pinned to a service and branch. | | [get_booked_button] | A button that opens the form in a pop-up. | | [get_booked_custom] | A whole form built with the AI or custom form builder, by its id. | | [get_booked_account] | The customer account: appointments, payments, rescheduling. Needs Settings › Customer accounts switched on. | | [get_booked_packages] | The packages shop. Shows nothing until a package is on sale under Set up › Packages. | | [get_booked_confirmation] | The booking a customer has just made, drawn on a confirmation page of the site's own — the whole card, or one value with field. It reads the reference and signed key from the page address, which Settings › Booking rules › After they book (or a form's confirmation attribute) puts there. Nothing in the address, nothing drawn. See Your own confirmation page. | ## Attributes | Attribute | On | Default | Effect | | --- | --- | --- | --- | | service | the four forms | empty | Pre-selects a service and skips the service step. | | location | [get_booked_short] | empty | Pre-selects a branch. | | staff | [get_booked_short] | empty | Pins the appointment to one member of staff. | | next | [get_booked_short] | false | Offers a one-tap "next available" time. | | hide_price | [get_booked_short] | false | Hides the price. | | prequal | the four forms | empty | off skips the routing questions on this page. on cannot switch them on if routing is off for the site. Empty follows the site setting. On a variant form, routing always follows the Default form. | | variant | [get_booked] | empty | Renders a specific booking form by its id. | | full | [get_booked] | true | false narrows the layout. | | label | [get_booked_button] | Book now | The button's text. | | demo | the four forms, [get_booked_account] | false | Renders sample content instead of live data. | | id | [get_booked_custom] | empty | The custom form's id, like cf_ab12cd34. | | title | [get_booked_account], [get_booked_packages] | empty | A heading above the account or shop. | | service | [get_booked_packages] | empty | Shows only packages for that service. | | confirmation | the four forms, including variant | empty | Where this embed sends people once the booking, and any payment, is done — instead of the site-wide page. A path (/thank-you/) or a full address on this site; anything elsewhere is dropped. | | title, message | [get_booked_confirmation] | empty | Replace the heading and the line under it. {first_name} is substituted in both. | | show | [get_booked_confirmation] | empty (everything) | Comma-separated parts to draw: reference, service, staff, where, when, people, price, visits, video, questions, extras, account. | | fallback | [get_booked_confirmation] | empty | What a visitor with no booking in the address sees. Empty renders nothing. | | field, default | [get_booked_confirmation] | empty | One value instead of the card: first_name, name, email, reference, service, staff, where, address, date, time, end_time, duration, price, paid, due, people, visits, video_link, questions_link, manage_link. The three links render as links; default is printed when the value is empty. | | class | [get_booked_confirmation] | empty | An extra class on the .gb-conf wrapper. | `demo` accepts `true`, `1` or `yes`; `next` and `hide_price` also accept `on`. `full` is stricter: only `true`, `1` or an empty value keep the full layout, so `full="yes"` narrows it. ## Examples ```shortcode // The whole form, everything selectable. [get_booked] // A landing page for one service. [get_booked service="12"] // A specific styled form, by its id. [get_booked variant="v_8k3ma2qd"] // One service at one branch, shortest flow. [get_booked_short service="12" location="3"] // Sidebar widget. [get_booked_compact service="12"] // A call to action that opens the form over the page. [get_booked_button service="12" label="Book your flu jab"] // The customer account page. [get_booked_account title="Your appointments"] // A form that finishes on its own page. [get_booked confirmation="/thank-you-travel/"] // On that page: the card, trimmed, with its own heading. [get_booked_confirmation show="reference,when,where" title="See you soon, {first_name}"] // One detail inside your own sentence. Thanks [get_booked_confirmation field="first_name" default="so much"] — see you soon. ``` ## Where the ids come from Service and location ids are the numeric ids from Set up › Services and Set up › Locations. A form's `variant` id is shown with its shortcode under Set up › Booking form, and looks like `v_8k3ma2qd`. A custom form's shortcode is shown in the same place once the form is saved. Duplicating a form produces a new id, so a copied form needs its shortcode updating. ## Rendering in PHP To place a form inside a template rather than in page content: ```php echo do_shortcode( '[get_booked service="12"]' ); ``` > **Note — Keep it to one form per page.** Each shortcode mounts on its own, so two will both work. But two full forms on one page confuse customers and double your tracking events — a button alongside a form is usually what you want. ## Scripts and styles Assets load only on pages where one of the booking shortcodes actually renders, so an aggressive optimisation plugin that strips "unused" CSS can break the form. If you are deferring or combining scripts, exclude Booked's handles. A confirmation page carrying `[get_booked_confirmation]` loads the stylesheet and fonts but not the app script. | Handle | What it is | | --- | --- | | get-booked | The booking app itself — both the script and its stylesheet use this handle. | | get-booked-fonts | The widget's font stylesheet. | | get-booked-conversion | Fires conversion events into your pixels, when conversion reporting is on. | | the card script handle | Booked Payments' hosted card script, registered only when the site can take card payments. Read the exact handle from the page source — excluding it from script optimisation is what keeps checkout working. | The app also receives a `GetBookedPublic` object carrying `restUrl`, a REST `nonce` and the pixel configuration. If your caching layer serves a page with a stale nonce the booking request will still work — the public endpoints do not require one — but anything authenticated will not. --- # API keys and the MCP server > Issue a scoped key, call the API with it, and connect an AI assistant. Source: https://trybooked.co/help/#dev/api-keys An API key lets your own code — a script, an integration, or an AI assistant — read and manage a Booked site without a browser session. Keys are issued by the merchant, scoped to exactly what you need, and revocable in one click. ## Issuing a key - **Open Settings → API keys** — You need edit access to Settings, given under System › Team. An API key can never open this screen, so a key cannot mint or list other keys. - **Click New key and name it** — Name it after the thing using it — "Claude assistant", "Nightly export" — so it is obvious what breaks when you revoke it. - **Tick only what it needs** — Permissions are per area, split into Read and Write. A key with no Write anywhere cannot change a thing. - **Copy the key** — It is shown once and never again. Lose it and you make a new secret for the same key from the same screen; the old one stops working at that moment. > **Warning — Write access books real appointments.** A key with bookings:write can create, change and cancel appointments that staff and customers will act on, and a key with full control can rewrite services, questions, templates and settings. Issue read-only keys unless you specifically need to write, and grant only the areas the integration touches. ## Using it Send the key as a bearer token. Some hosts strip `Authorization` before PHP sees it, so Booked also accepts `X-Booked-Key` — sending both is the reliable move. ```shell curl 'https://yoursite.co.uk/wp-json/get-booked/v1/bookings' \ -H 'Authorization: Bearer gbk_8wscer4i_…' \ -H 'X-Booked-Key: gbk_8wscer4i_…' ``` ## Scopes A key's access comes from its scopes alone. The access of the person who created it is not lent to the key. A key made by an administrator with only `bookings:read` can read bookings and nothing else — it cannot reach settings, and it cannot write. Every area has a read and a write scope, and **Settings › API keys** offers two presets: **Read everything** and **Full control**. | Area | Scopes | Covers | | --- | --- | --- | | Appointments | bookings:read / :write | Bookings, availability, the waiting list, unfinished bookings, booking links, notes, documents, stats and insights. | | Customers | customers:read / :write | Customer records, their history and answers, and the block list. | | Services | services:read / :write | Services and their consultation questions, categories, packages, and the form's look. | | Locations | locations:read / :write | Branches and their hours. | | Staff | employees:read / :write | Staff, their hours, breaks and time off. Never their logins. | | Booking form | forms:read / :write | The form's steps, eligibility and routing questions, bundles and upsells, slot templates and custom code. | | Emails & texts | messages:read / :write | Every email and text template, reminders, follow-ups, and the log of what was sent. | | Settings | settings:read / :write | Company details, booking rules, features, import and export, and the activity log. | Tick every box and the key can do what the owner can do in Booked, with four exceptions that no scope reaches: it cannot list, make or revoke keys; it cannot invite, change or remove anyone on the team or touch a login; it cannot change the plan, billing or licence; and it cannot spend the assistant's own credits. Those routes answer `403` to any key, whatever it was issued with. Two rules follow from that, and both are enforced on the server: - **Reads need a `:read` scope, writes need `:write`.** A GET with only write scope is refused, and vice versa. - **An unmapped area is denied.** A key can never reach a part of the API its scopes do not name, and the four areas above are mapped to nothing. The refusal arrives before the route runs, as a `403` naming the permission to add. ## What you get back | Status | Means | | --- | --- | | 200 | Fine. | | 401 | No key, a malformed key, or one that has been revoked. | | 403 | The key is valid but lacks the scope for this area or method. | | 429 | Rate limited — 120 requests per key per minute. | ## Connecting an AI assistant Booked is an MCP server itself, so an assistant that speaks the Model Context Protocol can work with a site directly. There is nothing to install: point the assistant at the site's MCP address with a key, and it gets a set of tools rather than raw HTTP. **Settings › API keys** shows the exact address and step-by-step setup for Claude, Claude Code, ChatGPT, Cursor and Gemini CLI. ```json { "mcpServers": { "booked": { "url": "https://yoursite.co.uk/wp-json/get-booked/v1/mcp", "headers": { "Authorization": "Bearer gbk_8wscer4i_…" } } } } ``` ```shell # Claude Code claude mcp add --transport http booked https://yoursite.co.uk/wp-json/get-booked/v1/mcp \ --header "Authorization: Bearer gbk_8wscer4i_…" ``` Apps that only take a server address, with no headers, can carry the key in the address instead: `…/mcp?key=gbk_…`. Anyone who sees that address holds the key, so use a read-only key there. | Tool | Needs | | --- | --- | | list_services, list_locations, list_employees | the matching :read | | check_availability, list_bookings, get_booking | bookings:read | | create_booking, update_booking, cancel_booking | bookings:write | | list_customers | customers:read | | update_customer | customers:write | | get_service | services:read | | create_service, update_service — including a service's consultation questions (intake_form) | services:write | | get_booking_form | forms:read | | update_booking_form — including the eligibility and routing questions (triage) | forms:write | | list_message_templates | messages:read | | update_message_template | messages:write | | api — any management route, by method and path | that route's area, :read for GET and :write for the rest | | api_reference — the routes, what each takes, and the record shapes | nothing | **Everything else goes through `api`.** Staff and their hours, branches, categories, packages, follow-ups, booking links, the waiting list, settings, features: the assistant calls `api_reference` once to learn the routes and the shape of each record, then `api` with a method and a path such as `PUT employees/4`. A `PUT` or `PATCH` on a record changes only the fields it sends and the rest of the record is kept; `POST` creates; `DELETE` is permanent. The tool refuses anything outside the key's areas with the permission to add, and anything no key may reach with a sentence saying so. Each tool asks for its own scope, and the `api` tool asks for the scope of the route it is given. Every change lands in **Settings › Activity log** under the person who issued the key, named as made via API key "…", so a rewritten template or a moved appointment is never mistaken for the owner's own doing. The scopes do the enforcing, not the assistant's good behaviour: a tool the key is not scoped for returns a permission error however the model is prompted. Give an assistant a read-only key and it can answer questions about the diary but cannot touch it; give it full control and it can set the whole of Booked up for you, short of the team, other keys and the plan. > **Note — Check availability before booking.** The assistant should call check_availability and offer real times rather than inventing one. A time that is not returned there cannot be booked, and the attempt will simply fail. ## Looking after keys - **One key per integration.** Then revoking one does not break the others. - **Edit rather than reissue.** A key can be renamed or given more or less to do at any time; the change applies from its next request and nothing that holds the key has to change. Making a new secret keeps the key's id, name and permissions and invalidates the old secret at once. - **Check Last used.** A key that has never been used, or has not been used in months, should probably be revoked. - **Revoking is instant** and cannot be undone — the row stays so the audit trail survives, but the key stops working on the next request. - **Keys are stored hashed.** Nobody, including Booked, can read one back out of the database. That is also why we cannot show it to you twice. --- # Team permissions and the admin API > Team permissions, how the admin REST routes use them, and how scoped API keys sit on top. Source: https://trybooked.co/help/#dev/capabilities-rest Access to Booked is decided by team permissions, not WordPress capabilities. A WordPress administrator can do everything; anyone else has to be added under System › Team, with a level for each section. This article covers how that works, how the admin REST routes use it, where API keys fit, and — importantly — what you cannot do with it. ## Team permissions Each section of the app — the ones in the sidebar — can be set to **none**, **view** or **edit** for each team member. A scope can narrow that further: a branch manager sees only their own branches, a practitioner only their own appointments. Scope only ever narrows; it never grants. | Group | Sections | | --- | --- | | Overview | Dashboard, Diary, Live | | Appointments | /bookings, /availability, /waitlist, /abandoned, /booking-links, /notes, /documents, /stats, /analytics | Bookings, Diary, Unfinished, Booking links, Insights | | Customers | /customers, /blocklist | Customers | | Services | /services, /categories, /packages, /form-variants, /form-colors | Services, Categories, Packages | | Locations | /locations | Locations | | Staff | /employees | Staff (logins need Team) | | Booking form | /form-config, /form-custom | Booking form | | Emails & texts | /notifications, /messages, /flows | Messages, Follow-ups | | Settings | /settings, /features, /migration, /activity | Settings, Features, Payments | | Not open to keys | /api-keys, /team, /license, /account | Settings, Team — a person signed in, never a key | > **Note — Being on the team is the front door.** The Booked menu appears for WordPress administrators and for anyone added under System › Team. Nobody else can open Booked, whatever capabilities their role carries. Booked still adds five `gb_manage_*` capabilities and a `gb_employee` role on activation. Neither the menu nor the REST routes check them any more, so adding them to a role with `add_cap()` grants nothing. To give a shop manager the diary but not settings, add them under **System › Team** and set Diary and Bookings to edit, Settings to none. ## The admin REST routes A merchant site registers about 200 routes under `get-booked/v1`. Most are management routes; the open ones are covered in **The public booking API** and **Customer portal**. Each management route belongs to one section. Reading (`GET`) needs view on that section; anything else needs edit. **PUT replaces, PATCH changes.** A `PUT` on a record writes the whole record, so send every field: anything missing is written as its default, relations included. A `PATCH` changes only the fields you send and keeps the rest — the staff, hours and length of a service survive a rename. Prefer `PATCH` for any change that is not the whole record. (Before 2.23.2 the two behaved the same, and that was a bug.) | Area | Route group | Team section | | --- | --- | --- | | Bookings | /bookings, /notes, /documents | Bookings | | Customers | /customers | Customers | | Services | /services, /variants | Services | | Categories | /categories | Categories | | Locations | /locations | Locations | | Staff | /employees | Staff | | Messages | /notifications, /messages | Messages | | Activity | /activity | Bookings or Customers | | Settings, API keys | /settings, /api-keys | Settings | ## Authenticating a script Two ways in, and they are not equivalent. **API keys are the supported route.** A merchant issues one under Settings › API keys, scoped per area and per read/write. Its access comes from its scopes alone — the issuing user's team permissions are not lent to it. That is the mechanism to build against — see **API keys and the MCP server**. ```shell curl 'https://yoursite.co.uk/wp-json/get-booked/v1/bookings' \ -H 'Authorization: Bearer gbk_8wscer4i_…' ``` **WordPress Application Passwords** also work, because these are ordinary WordPress REST routes. They authenticate as the whole user, with every team permission that user holds — and the user must be an administrator or on the team — so they are the blunter instrument. | | API key | Application password | | --- | --- | --- | | Scope | Per area, per read/write | Everything the user's team permissions allow | | Beyond Booked | Cannot touch WordPress itself | Full REST access, posts and all | | Rate limited | Yes, per key | No | | Visible usage | Last used shown in the UI | Last used shown in the user profile | > **Note — Prefer an API key.** Reach for an application password only when you need something outside Booked in the same call. If you do use one, create a dedicated user, add them under System › Team with only the sections they need — never an administrator. Either way, these routes exist to serve Booked's own interface. They are not a versioned public contract and can change between releases; the **public booking API** is the stable surface. ## Reacting to changes instead If what you actually need is to know when something happens, polling the REST API is the wrong tool. Hook `get_booked_booking_event` and push the change outward — it fires in-process, needs no credentials, and cannot drift out of date. See **Hooks and filters**. ## Errors and reference codes Failures return a WordPress REST error with a machine code, and the customer-facing message carries a short reference such as `(ref: GB-SLOT)`. The underlying cause is written to Settings › Activity log under the Errors filter — when a merchant sends you a screenshot, that code is the lookup key. --- # The customer account portal > Give customers their own account — or build your own portal on the same API. Source: https://trybooked.co/help/#dev/customer-portal Your customers get an account: what they have booked, what they have paid, and — where you allow it — the ability to move or cancel an appointment themselves. Drop in a shortcode and it works. Or ignore the shortcode entirely and build the whole thing yourself against the API below, which is what the built-in one uses. > **Note — Nothing here works until it is switched on..** Customer accounts are off by default, and every capability on top is a separate switch. The API enforces both, independently of whatever your page decides to render — so a hidden button is a tidiness decision, never a security one. ## The five-second version ```shortcode [get_booked_account] ``` That renders the built-in portal — sidebar, spend totals, appointment history, rescheduling, cancellation — styled with your booking-form colours. It remembers which page it is on, so sign-in emails come back to the right place. Everything below is for when you want your own. ## How signing in works There is no password. A customer types the email address they booked with and gets a link that signs them in. | | What happens | | --- | --- | | 1 | They enter an email. You get the same answer whether or not it is on file. | | 2 | If it is known, a link is emailed. Single use, and dead after 15 minutes. | | 3 | The link returns to your page as ?gb_account=. You exchange it for a session. | | 4 | The session lasts 30 days. It arrives as an httpOnly cookie and is also returned, so you can hold it yourself. | **Never reveal whether an address was found.**The API answers identically either way and your page must too. “No account with that email” turns a sign-in box into a way of asking which of your neighbours uses the pharmacy. On a page about medical appointments that is the whole problem, not a detail. ## Every endpoint All under `/wp-json/get-booked/v1/public/account/`. All `POST` and JSON except the one document download, and no nonce. | Call | Body | Needs | | --- | --- | --- | | request-link | { email } | — | | verify | { token } | — | | me | {} | session | | slot-range | { booking_id, from } | session + reschedule | | slots | { booking_id, date } | session + reschedule | | reschedule | { booking_id, date, time } | session + reschedule | | cancel | { booking_id, reason?, note? } | session + cancel | | details | { first_name, last_name, phone } | session + details | | sign-out | {} | session | | cards | {} | session — lists saved cards, or enabled: false when saving cards is off | | card-forget | { card_id } | session | | documents | {} | session — documents the clinic has shared with them | | document/{id} (GET) | — | session — downloads one shared document | ## Carrying the session Two ways. Pick by where your front end lives. ```javascript const BASE = '/wp-json/get-booked/v1/'; // Same origin: the cookie is set for you. Just send credentials. async function post( path, body ) { const r = await fetch( BASE + path, { method: 'POST', headers: { 'Content-Type': 'application/json' }, credentials: 'same-origin', body: JSON.stringify( body || {} ) } ); return r.json(); } // A separate app or a different domain: hold the token and pass it. // Treat it like a password — never in a URL, never anywhere a script // you did not write can read it. async function post( path, body ) { const r = await fetch( BASE + path, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify( { ...body, session } ) } ); return r.json(); } ``` ## Signing someone in, end to end ```javascript // 1. They ask for a link. Show what comes back, verbatim. const res = await post( 'public/account/request-link', { email } ); show( res.message ); // never "we found you" / "no such account" // 2. Your page loads with ?gb_account=… — swap it for a session. const token = new URLSearchParams( location.search ).get( 'gb_account' ); if ( token ) { const out = await post( 'public/account/verify', { token } ); if ( out.ok ) { // Strip it from the address bar. It is single use, but it has no // business sitting in history or leaking through a referrer. history.replaceState( {}, '', location.pathname ); session = out.session; // only if you are holding it yourself } else { show( out.error ); // expired or already used } } // 3. Load the account. This is also your "are they signed in?" check — // there is no separate endpoint for that. const me = await post( 'public/account/me' ); if ( ! me.ok ) showSignInForm(); ``` ## What `me` gives you ```json { "ok": true, "customer": { "first_name": "Pat", "last_name": "Shah", "email": "pat@example.com", "phone": "07700900123", "since": "2024-11-02" }, "summary": { "total_spent": "£240.00", "payments": 6, "appointments": 9, "attended": 7, "credits": 3, "next": { … the first upcoming booking … } }, "upcoming": [ { "id": 318, "reference": "GB-00318", "service": "Yellow fever vaccination", "service_id": 4, "date": "2026-09-14", "time": "10:15", "status": "confirmed", "price": "£85.00", "paid": "paid", "amount_paid": "£17.00", "amount_paid_pence": 1700, "location": "Ewell — Chessington Road", "intake_status": "pending", "intake_link": "https://…/intake/X3Zzwlm…" } ], "past": [ … same shape … ], "passes": [ … packages they hold, and what is left on each … ], "features": { "bookings": true, "rebook": true, "reschedule": false, "cancel": false, "invoices": true, "packages": true, "details": true, "documents": true }, "reasons": { "schedule": "Something came up", "unwell": "I am unwell", … } } ``` > **Note — Render from features, never from your own config..** The clinic changes these whenever they like, and you are not told. Read the object and show what it says. Hard-code a Cancel button and it will still be sitting there after they turn cancelling off, failing on every click. `packages` controls the passes they hold: `passes` lists them and `summary.credits` is the visits left across all of them, both empty when it is off. `documents` is about questionnaires still to answer, shown through each booking's `intake_link`. > **Note — Use amount_paid_pence for arithmetic, the formatted strings for display..** price, amount_paid and total_spent arrive already formatted in the clinic's currency, symbol and all. Parsing them back into numbers will break the first time somebody sells in euros. ## Moving an appointment Three steps, and the middle one is the part most people skip. ```javascript // 1. Which days have anything at all? Use this to grey out the rest — // offering a day and then refusing it is the worst version of this. const range = await post( 'public/account/slot-range', { booking_id: 318, from: '2026-09-01' } ); // range.days -> [ { date: "2026-09-01", slots: [ … ] }, … ] up to 42 days const countByDate = {}; range.days.forEach( ( d ) => { countByDate[ d.date ] = d.slots.length; } ); // 2. Times for the day they picked. const day = await post( 'public/account/slots', { booking_id: 318, date: '2026-09-20' } ); // day.available -> [ { time: "09:00", employee_id: 3 }, … ] // 3. Confirm, THEN move. Never move on the tap that picked the time. const out = await post( 'public/account/reschedule', { booking_id: 318, date: '2026-09-20', time: '09:15' } ); ``` **A slot can go between showing it and taking it.**The server re-checks the time is genuinely free before it moves anything, so `reschedule` can refuse a slot you displayed two seconds ago. Treat that as an ordinary outcome — reload the day and let them pick again — not as an error worth apologising for. > **Note — Show a confirmation before you move..** The built-in portal deliberately makes picking a time and moving the appointment two separate actions, showing the old time beside the new one. A single tap that silently relocates someone's vaccination appointment is how you generate phone calls. ## Cancelling — and why A cancellation with no reason is a number nobody can act on. `me` hands you the list to offer; both fields are optional. ```javascript // reasons came from me.reasons — render them, do not hard-code them, // they can change without you. await post( 'public/account/cancel', { booking_id: 318, reason: 'cost', // a key from me.reasons, or omit note: 'Cheaper down the road' // free text, max 500, or omit } ); ``` An unrecognised `reason` is discarded rather than stored, so you cannot pollute the clinic's reporting by inventing keys. Reasons and notes show on the booking in the admin and feed the cancellation breakdown in Insights. > **Note — Let them skip it..** A required reason gets answered dishonestly, which is worse than no data. Ask, make it one tap, and move on. ## A complete, working portal Everything above, in one file. No framework, no build step. Paste it into a page template and it runs. ```htmljavascript ``` ## Failures worth handling separately | code | Means | Do | | --- | --- | --- | | gb_signed_out | No session, an expired one, or a booking that is not theirs. | Back to the sign-in box. | | gb_not_available | That feature is switched off for this clinic. | Hide it. Do not retry. | | gb_rate_limited | Too many sign-in attempts from one address (HTTP 429). | Ask them to wait a few minutes. | | no code | ok:false with an error — slot gone, appointment already passed. | Show the message as written. | > **Note — Someone else's booking answers gb_signed_out, not “not found”..** Deliberate. Answering “no such booking” for one id and “not yours” for another would let anyone with an account map out which booking ids exist. ## What the API will not let you do - **Change the email address.** It is the credential. Moving it would hand the account to whoever currently holds the session, so it is read-only here and staff have to do it. - **Touch another customer.** Every booking is re-checked against the session's own customer, server-side, on every call. - **Cancel or move something in the past.** Refused regardless of the switches. - **Enumerate anything.** No customer list, no confirmation that an address exists, no booking-id probing. - **Skip the availability check.** `reschedule` re-verifies the slot; a time you displayed is not a time you are owed. ## Before you call it done - **Turn each feature off in turn** and confirm your page follows. This is the single most likely thing to be quietly broken, because with everything on it looks finished. - **Use a sign-in link twice.** The second must fail, and your page must say so plainly rather than hanging. - **Leave a session past its expiry** and confirm you drop back to the sign-in box, not an empty account. - **Ask for a link with an address that does not exist.** Your wording must be identical to the one that does. - **Try a booking id that is not yours.** Expect `gb_signed_out`, and handle it as a sign-out rather than a crash. - **Check the token is gone from the address bar** after signing in. - **Reschedule into a slot you have just filled from another browser.** Expect the refusal, and make sure it reads like a normal outcome. - **Cancel without choosing a reason.** It must still work. ## Related - **Build a custom booking form, step by step** — the same idea for the booking flow itself, with the full public booking API. - **The public booking API** — endpoint reference for catalogue, availability and creating bookings. --- ## Front end and tracking # 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. Source: https://trybooked.co/help/#dev/build-a-custom-form 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. > **Warning — 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 | | --- | --- | | 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 | ```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 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. ```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. > **Note — 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 | 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: ```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 } ``` > **Warning — 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, … ] ``` | 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. ```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. | 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. | ```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 ); } } ``` > **Warning — 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. > **Warning — 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. --- # Build your own booking form markup > Replace Booked's HTML with your own — service cards, locations, even the checkout — and keep the engine. Source: https://trybooked.co/help/#dev/templates You can replace Booked's markup with your own. Write the HTML for a service card, a location, the checkout — Booked keeps doing the hard part underneath: availability, validation, the step order, and taking the money. Leave a slot blank and the built-in design is used, so you can take over one piece without rebuilding the lot. ## How it works Each slot is a small HTML template with `{{placeholders}}`, edited under Set up › Booking form › Code › **Your own markup**. Templates are per booking form, so one variant can be bespoke while others stay standard. One attribute is the whole contract: ```html ## {{name}} {{price}} · {{duration_label}} ``` `data-gb-select` marks the clickable element. Booked wires selection, the selected state and what happens next — you never write a click handler. > **Note — Values are escaped for you.** A service description containing