> ## Documentation Index
> Fetch the complete documentation index at: https://build.practicehub.io/llms.txt
> Use this file to discover all available pages before exploring further.

# Online booking widget

> A website booking flow in TypeScript — reference data, free slots, find-or-create the patient, book safely, handle clashes, cancel or move

**Goal:** a "Book now" widget on the practice's website. The visitor picks a location and appointment type, sees free times, enters their details and books. The practice sees the booking in its calendar exactly as if the front desk had made it, and the patient gets the normal confirmation messages.

**Key:** read & write. **Resources used:** `locations`, `appointment_types`, `practitioners`, `availability`, `patients`, `appointments`.

## The flow

```mermaid theme={null}
sequenceDiagram
  participant W as Widget (browser)
  participant S as Your server
  participant P as PracticeHub API
  W->>S: open widget
  S->>P: GET /locations, /appointment_types  (cached)
  W->>S: location + type + week
  S->>P: GET /availability?location_id&appointment_type_id&from&to
  P-->>S: slots + unavailable_dates
  W->>S: chosen slot + visitor details
  S->>P: GET /patients?email=eq:…  (find)
  alt not found
    S->>P: POST /patients  (create, with metadata.source)
  end
  S->>P: POST /appointments  (Idempotency-Key)
  alt 409 slot_unavailable
    S-->>W: "that time has just gone" → re-query
  else 201
    S-->>W: confirmation
  end
```

<Warning>
  Never call the API from the browser. The key is a server secret; the widget talks to **your** server, which talks to PracticeHub. Everything below runs server-side (Node 18+, no dependencies beyond `fetch`).
</Warning>

## A tiny client

Every example on this site uses the same shape: one function that adds the base URL and bearer token, parses the `{ message, code, errors }` error body, and retries `429`/`5xx` with backoff.

```ts theme={null}
// practicehub.ts
const BASE = process.env.PRACTICEHUB_BASE_URL!;   // https://your-clinic.your-region.practicehub.io/v3/api
const KEY  = process.env.PRACTICEHUB_API_KEY!;

export class ApiError extends Error {
  constructor(public status: number, public code: string, message: string, public errors?: Record<string, string[]>) {
    super(message);
  }
}

export async function api<T>(method: string, path: string, body?: unknown, headers: Record<string, string> = {}): Promise<T> {
  for (let attempt = 0; ; attempt++) {
    const res = await fetch(`${BASE}${path}`, {
      method,
      headers: { Authorization: `Bearer ${KEY}`, Accept: 'application/json', 'Content-Type': 'application/json', ...headers },
      body: body === undefined ? undefined : JSON.stringify(body),
    });

    if (res.status === 204) return undefined as T;
    if (res.ok) return (await res.json()) as T;

    const retryable = res.status === 429 || res.status >= 500;
    if (retryable && attempt < 4) {
      const retryAfter = Number(res.headers.get('Retry-After') ?? 0);
      await new Promise(r => setTimeout(r, (retryAfter || 2 ** attempt) * 1000));
      continue;
    }

    const err = (await res.json().catch(() => ({}))) as { message?: string; code?: string; errors?: Record<string, string[]> };
    throw new ApiError(res.status, err.code ?? 'unknown', err.message ?? res.statusText, err.errors);
  }
}
```

## 1. Reference data (cache it)

Locations, appointment types and practitioners change rarely. Load them at startup and refresh every few minutes (or on the `locations.updated` / `appointment_types.updated` webhooks).

```ts theme={null}
type Row<T> = { data: T[] };
type Location = { id: number; name: string; online_booking: boolean };
type AppointmentType = { id: number; name: string; duration: number; online_booking: boolean; active: boolean };

const [locations, types] = await Promise.all([
  api<Row<Location>>('GET', '/locations?online_booking=eq:1'),
  api<Row<AppointmentType>>('GET', '/appointment_types?active=eq:1&online_booking=eq:1'),
]);
```

Only offer locations and types with `online_booking: true` — that is the practice's own "bookable online" switch, and `/availability` in the default `visibility=online` view refuses anything else with `422 not_online_bookable`.

## 2. Free slots for a week

```ts theme={null}
type Slot = {
  start: string; end: string;                // clinic-local "YYYY-MM-DD HH:MM:SS"
  practitioner_id: number; practitioner_name: string;
  location_id: number; appointment_type_id: number; resource_id: number | null;
  type: 'individual' | 'group'; group_master_id: number | null; online_fee: number | null;
};
type Availability = { data: Slot[]; meta: { timezone: string; from: string; to: string; visibility: string; unavailable_dates: string[] } };

const q = new URLSearchParams({ location_id: '81', appointment_type_id: '1', from: '2026-09-01', to: '2026-09-07' });
const { data: slots, meta } = await api<Availability>('GET', `/availability?${q}`);
```

Render `slots` grouped by day; grey out `meta.unavailable_dates`. Slots are already restricted to what the practice allows online, at most 50 per day, in `meta.timezone`. Add `practitioner_id=109` to the query if the visitor chose a practitioner. Windows are capped at 31 days — page week by week.

## 3. Find or create the patient

Booking needs a `patient_id`. Match on email first (the practice's records may already have this person), and tag anything you create so it is traceable:

```ts theme={null}
type Patient = { id: number; first_name: string; last_name: string | null; email: string | null; metadata: Record<string, unknown> };

async function findOrCreatePatient(v: { firstName: string; lastName: string; email: string; mobile: string }): Promise<number> {
  const found = await api<Row<Patient>>('GET', `/patients?email=eq:${encodeURIComponent(v.email)}&page_size=1`);
  if (found.data.length) return found.data[0].id;

  const created = await api<{ data: Patient }>('POST', '/patients', {
    first_name: v.firstName,
    last_name: v.lastName,
    email: v.email,
    numbers: [{ number: v.mobile, country_code: '44', iso2: 'GB', type: 'mobile' }],
    metadata: { source: 'website-widget' },
  });
  return created.data.id;
}
```

Phone numbers are normalised on the way in (`intl_number`, country code) exactly as when staff type them. A deleted patient cannot be booked (`422 patient_not_found`) — show a "please call the practice" message rather than retrying. `patients.blocked` is the practice's *online booking* block: the API does not enforce it (staff can still book), so a public widget should check it on the found patient and route blocked patients to the front desk.

## 4. Book — safely

```ts theme={null}
type Appointment = { id: number; status: string; start: string; end: string; practitioner_id: number; patient_id: number };

async function book(patientId: number, slot: Slot, bookingRef: string) {
  const body = slot.type === 'group'
    ? { patient_id: patientId, appointment_type_id: slot.appointment_type_id, group_master_id: slot.group_master_id, note: 'Booked from the website' }
    : {
        patient_id: patientId,
        location_id: slot.location_id,
        appointment_type_id: slot.appointment_type_id,
        practitioner_id: slot.practitioner_id,
        resource_id: slot.resource_id,
        start: slot.start,
        end: slot.end,
        note: 'Booked from the website',
        metadata: { widget_ref: bookingRef },
      };

  try {
    const { data } = await api<{ data: Appointment }>('POST', '/appointments', body, { 'Idempotency-Key': `widget-${bookingRef}` });
    return { ok: true as const, appointment: data };
  } catch (e) {
    if (e instanceof ApiError && (e.code === 'slot_unavailable' || e.code === 'group_full')) {
      return { ok: false as const, reason: 'taken' };          // re-query availability, offer the next slot
    }
    if (e instanceof ApiError && e.status === 422) {
      return { ok: false as const, reason: 'invalid', errors: e.errors };
    }
    throw e;
  }
}
```

Three things make this safe:

* **Copy the slot verbatim.** `start`, `end`, `practitioner_id`, `location_id`, `appointment_type_id`, `resource_id` come straight from `/availability`; don't recompute `end` from a duration you think you know.
* **`Idempotency-Key`** = your own booking reference. If the visitor's connection drops after PracticeHub booked but before your server heard, a retry with the same key returns the original `201` (with `Idempotent-Replayed: true`) instead of a double booking. Retrying a *different* slot is a new key.
* **A clash is normal.** Two visitors can pick the same slot; the second gets `409 slot_unavailable`. Re-query and offer the next one — don't send `allow_clash: true` from a public widget.

The booking is validated as in the calendar (active patient/location/practitioner, resource at that location, group capacity), lands as `status: "pending"`, appears in the practice's calendar and board immediately, triggers confirmation/reminder messaging, and the appointment log reads *"Created via API: Booking widget"*.

## 5. Manage the booking

Give the visitor a "manage my booking" link that maps to your `bookingRef` → `appointment.id`.

```ts theme={null}
// Cancel (a real cancellation: same side effects as staff cancelling)
await api('PATCH', `/appointments/${id}`, { status: 'cancelled', cancel_note: 'Cancelled by patient via website' });

// Move: re-run availability with rescheduled_appointment_id so the current booking's own time
// is not counted as busy, then PATCH the new start/end (and practitioner if it changed)
const q = new URLSearchParams({ location_id, appointment_type_id, from, to, rescheduled_appointment_id: String(id) });
const { data: slots } = await api<Availability>('GET', `/availability?${q}`);
await api('PATCH', `/appointments/${id}`, { start: slot.start, end: slot.end, practitioner_id: slot.practitioner_id });
```

A `PATCH` on a cancelled, missed or processed (checked-out) appointment is refused with `409 appointment_rule` and a message saying why — show it; the practice's own rules apply to you as they do to staff.

## Gotchas

| Symptom                                                      | Cause                                                           | Fix                                                                                                         |
| ------------------------------------------------------------ | --------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------- |
| `422 not_online_bookable` on `/availability`                 | Type, location or practitioner isn't enabled for online booking | Only offer `appointment_types` with `online_booking: true`; let the practice enable the rest in PracticeHub |
| `422 availability_not_configured`                            | No rota/service configured for that location + type             | Nothing to book here; hide the combination                                                                  |
| Slots look one hour off                                      | You converted clinic-local times to UTC                         | Times on `/availability` and `POST /appointments` are clinic-local; only `created`/`updated` are UTC        |
| `409 slot_unavailable` right after `/availability` said free | Someone booked in between (or a group filled)                   | Expected — re-query; never retry the same time in a loop                                                    |
| Duplicate bookings after a timeout                           | No `Idempotency-Key`                                            | Always send one, keyed on your booking reference                                                            |
| `403 key_read_only`                                          | Widget key was created read-only                                | Create a read & write key for the widget                                                                    |

## Going further

* Subscribe to `appointments.cancelled` / `appointments.rescheduled` [webhooks](/guides/webhooks) to keep your "manage my booking" page in step when staff change things.
* Store your reference in `metadata.widget_ref` (done above) so support can find a booking from either side: `GET /appointments?metadata[widget_ref]=eq:…`.
* Show `online_fee` from the slot if the practice charges for online bookings; taking payment is a separate flow (`payments` + `payment_allocations`) — see the [accounting example](/examples/accounting-export) for the shapes.
