Skip to content

API reference / Appointments

Bookings, conflicts, and availability

The appointments resource covers bookings, chair-blocking entries, and a derived availability endpoint that computes free slots for a chair on a given day. This page lists the object shape, the conflict-detection rule, and every endpoint's fields and errors.

Who can do what

Every endpoint requires auth and is scoped to the caller's practice. Unlike chairs and practice settings, appointments are not owner-only: any practice member, owner or staff (assistant in the API), can create, update, and delete them. appointmentId path segments must be canonical UUIDs; a malformed one returns not_found (404) without reaching the service layer.

The appointment object and its type

An appointment carries chairId, practitionerId, patientId, type, status, title, startAt, endAt, color, serviceRef, notes (of these title, color, serviceRef and notes are nullable, as are chairId, patientId and practitionerId; startAt and endAt never are), seriesId (nullable; set when it was booked as one session of a course), createdBy, and metadata, plus id, practiceId, createdAt, updatedAt. type is one of appointment, reminder, or unavailable. An unavailable entry blocks a chair the same way a real appointment does for conflict purposes; a reminder entry never occupies a chair and is exempt from both the overlap check and the availability calculation, even if it has a chairId set.

status is one of requested, confirmed, declined, cancelled or expired. Only requested and confirmed occupy a chair, which is the rule every overlap and availability answer follows: a pending request holds its slot so two patients are never told the same time is theirs, while declining, cancelling or letting a request lapse gives the time back. A booking made by staff is confirmed; one made through the public booking page arrives requested.

On list rows the patient is inlined as patient: { id, firstName, lastName, contactUnchecked } | null in addition to patientId, so a calendar view does not need a second call per row.

Overlap (double-booking) detection

Creating or updating an appointment is rejected with conflict (409) when it overlaps something that occupies the time: an appointment that is requested or confirmed, or an unavailable block, on the same chair or for the same practitioner, or practice-wide blocked time (an unavailable entry with neither a chair nor a person, such as a holiday). An appointment that is declined, cancelled or expired never conflicts with anything. Practice-wide blocked time does not freeze the day: leave and chair blocks can still be recorded over it, and a booking already in the diary stays editable as long as its own start and end do not move, so its chair, person and notes can be changed. A practitionerId from another practice is refused with validation_failed (400), not a conflict.

The 409 carries a machine-readable reason in details[].code: chair_booked when the named chair is taken, practitioner_booked when the named person is busy anywhere, and practice_blocked when the whole practice is closed for that time. Match on those rather than on the message.

Overlap uses strict inequality (existing.startAt < newEnd AND existing.endAt > newStart), so two entries that touch exactly at a boundary are not considered overlapping. Different chairs never conflict with each other, and on PATCH the appointment being updated excludes itself from its own conflict check. A reminder entry is exempt entirely.

Reminders as a side effect

Creating, updating, or deleting an appointment-type entry automatically schedules, reschedules, or cancels a background appointment-reminder task, and there is no separate API call to manage it. A reminder is only scheduled when the entry is confirmed, has a patient, and the practice's lead time is above zero: a booking still waiting to be answered does not remind anyone, because the visit may not happen. The delivery mechanics (channels, degraded-mode behavior) live in the notifications documentation rather than here.

See the error reference for what every code and status means, and the API reference hub for the other resources.

Endpoints

GET/api/v1/appointments

Lists appointments starting inside a time window, ordered by start time.

The window is half-open: an appointment is included when its startAt falls in [from, to). An appointment that started before from and is still running is not returned.

`to` must be after `from`, and the window may span at most 370 days; outside that it is 400 `validation_failed`. Send both with a `Z` or an explicit offset, since a bare local time is ambiguous. Appointments of every status are returned, so filter client-side if you only want the confirmed ones.

Query parameters

FieldTypeDefaultDescription
fromRequiredISO datetime—Start of the window, inclusive.
toRequiredISO datetime—End of the window, exclusive.
chairIduuid—Restricts the result to a single chair. A chair the practice does not have is 400 `validation_failed`.

Response · 200

{
  "data": {
    "appointments": [
      {
        "id": "a7c30e51-....",
        "type": "appointment",
        "startAt": "2026-07-08T10:00:00.000Z",
        "endAt": "2026-07-08T11:00:00.000Z"
      }
    ]
  },
  "requestId": "req_5f2c1e40-...."
}

Errors

CodeStatusWhen
validation_failed400from or to is missing or not a valid datetime, or chairId is malformed.
missing_authorization401No Authorization header was sent.

POST/api/v1/appointments/series

Books one appointment and its repeats in a single transaction: a course of sessions.

Takes the create body without `type` and `status` (a course is an appointment, and the practice booking it makes it confirmed), plus `repeat`: `everyWeeks` 1 to 52, optional `weekdays` 0 (Sunday) to 6, and exactly one of `count` (2 to 52, counting the first) or `until` (YYYY-MM-DD, inclusive).

The rule is expanded on the practice's own clock, so a course at 09:00 stays at 09:00 after the clocks change and its stored instants differ by an hour across a daylight-saving boundary.

A date already taken is skipped, not refused: the answer is `201` with `appointments` for what was booked and `skipped` for the dates that were not, so a course may come back shorter than the rule asked for. Every session shares the chair, practitioner, service, length and patient of the first, and carries the same `seriesId`.

A session is moved, cancelled or deleted on its own afterwards through the ordinary appointment routes. Booking a course sends the patient one email listing every session, with one calendar invitation covering them all.

Body

FieldTypeDescription
startAtRequiredstringThe first session's start, ISO-8601 with an offset or `Z`.
endAtRequiredstringThe first session's end. Every session gets the same length.
repeatRequiredobject`everyWeeks` (1-52), optional `weekdays` (0-6), and exactly one of `count` (2-52) or `until` (YYYY-MM-DD).
chairId, practitionerId, patientIdstringAs on a single appointment, and the course needs at least one of the chair and the practitioner: it is what the write locks on. The chair is settled once, against the earliest session, and every session gets that same chair.
title, color, serviceRef, notesstringAs on a single appointment. Every session gets the same values.

Example request

{
  "startAt": "2026-07-01T09:00:00.000Z",
  "endAt": "2026-07-01T09:30:00.000Z",
  "chairId": "3f1b8a20-....",
  "patientId": "7d2c9e11-....",
  "practitionerId": "c81a4b33-....",
  "serviceRef": "Physiotherapy session",
  "repeat": { "everyWeeks": 1, "count": 10 }
}

Response · 201

{
  "data": {
    "seriesId": "9b4d2f10-....",
    "appointments": [{ "id": "a7c30e51-....", "startAt": "2026-07-08T09:00:00.000Z" }],
    "skipped": [{ "startAt": "2026-07-15T09:00:00.000Z", "endAt": "2026-07-15T09:30:00.000Z", "reason": "clash" }]
  },
  "requestId": "req_5f2c1e40-...."
}

Errors

CodeStatusWhen
validation_failed400the rule gives neither or both of count and until, the rule reaches only one date, `endAt` is not after `startAt`, the course has neither a chair nor a practitioner, a course would reach more than 1,096 days ahead, or two of its own sessions overlap each other.
conflict409not one date in the course could be booked. If an existing series already accounts for every date, for the same patient at the same times, the message says the course is already booked.

POST/api/v1/appointments

Creates an appointment, a reminder, or a chair-blocking unavailable entry.

Any practice member can do this; there is no owner-only gate on appointments, unlike chairs and practice settings.

A chairId or patientId belonging to another practice is rejected as validation_failed, not surfaced as a database error.

Body

FieldTypeDescription
typeRequiredstringOne of appointment, reminder, unavailable.
startAtRequiredISO datetimeWhen it starts. Send a `Z` or an explicit offset.
endAtRequiredISO datetimeMust be strictly after startAt. Send a `Z` or an explicit offset.
chairIduuid | nullMust belong to your practice. With exactly one chair it is filled in for you. With several, it takes the practitioner's usual chair if that chair is free at the time; otherwise it is 400 `validation_failed`, naming the chairs to choose from. A `reminder` or `unavailable` entry needs none.
practitionerIduuid | nullA member of your practice. Checked for overlap the same way the chair is, so booking someone who is already busy is a 409 with `practitioner_booked`.
statusstring`requested` or `confirmed`; defaults to `confirmed`. The other statuses are reached through the status endpoint below, not here.
patientIduuid | nullMust belong to your practice. Setting it on a confirmed appointment schedules the reminder.
titlestring | nullTrimmed; must not be empty if present.
colorstring | nullDisplay colour in the calendar.
serviceRefstring | nullReference to the service being performed.
notesstring | nullFree text.

Example request

{
  "type": "appointment",
  "startAt": "2026-07-08T10:00:00.000Z",
  "endAt": "2026-07-08T11:00:00.000Z",
  "chairId": "5c2f9b74-....",
  "title": "Checkup"
}

Response · 201

{
  "data": {
    "id": "a7c30e51-....",
    "type": "appointment",
    "startAt": "2026-07-08T10:00:00.000Z",
    "endAt": "2026-07-08T11:00:00.000Z"
  },
  "requestId": "req_5f2c1e40-...."
}

Errors

CodeStatusWhen
validation_failed400endAt is not after startAt, a chairId or patientId is unknown, a practitionerId belongs to another practice, or an unrecognized field was sent.
conflict409The entry overlaps something on the same chair or the same practitioner, or time the whole practice has blocked.

GET/api/v1/appointments/:appointmentId

Fetches one appointment.

Path parameters

FieldTypeDescription
appointmentIdRequireduuidCanonical UUID.

Response · 200

{
  "data": { "id": "a7c30e51-....", "type": "appointment" },
  "requestId": "req_5f2c1e40-...."
}

Errors

CodeStatusWhen
not_found404No such appointment in this practice.

PATCH/api/v1/appointments/:appointmentId

Updates an appointment. Every field from the create body except status, all optional.

endAt > startAt is re-checked whenever either changes, against the existing value of the other if only one is sent. The conflict check re-runs against the post-update chair and time range, excluding this appointment from its own check.

`status` is **not** accepted here. Answering a booking request goes through the status endpoint below, so a transition is always checked against the one it is coming from.

Setting `chairId` to null on a booking is refused: a booking occupies a chair by definition. A 409 can also mean the request expired while you were editing it, or the row changed underneath the edit.

Path parameters

FieldTypeDescription
appointmentIdRequireduuidCanonical UUID.

Example request

{
  "startAt": "2026-07-08T10:30:00.000Z",
  "endAt": "2026-07-08T11:30:00.000Z"
}

Response · 200

{
  "data": {
    "id": "a7c30e51-....",
    "startAt": "2026-07-08T10:30:00.000Z",
    "endAt": "2026-07-08T11:30:00.000Z"
  },
  "requestId": "req_5f2c1e40-...."
}

Errors

CodeStatusWhen
validation_failed400An invalid field, an unknown chairId or patientId, or an unrecognized field.
conflict409The new time overlaps a different entry on the same chair or the same practitioner, or time the whole practice has blocked.
not_found404No such appointment in this practice.

POST/api/v1/appointments/:appointmentId/status

Answers or withdraws a booking request, and cancels a confirmed appointment.

This is the only way a client changes a status, and the transitions are fixed: a `requested` appointment may be confirmed, declined or cancelled (withdrawn), a `confirmed` one may be cancelled, and `declined`, `cancelled` and `expired` are final. Every transition is checked against the status the appointment is coming from, which is why `PATCH` will not take a `status` field. The one status a client never sets is `expired`: the booking-request expiry task sets that on its own.

Confirming re-runs the overlap check, so a request can be refused at the moment of confirmation if the chair or the practitioner filled up while it waited. Confirming without a `chairId` takes the practitioner's usual chair when it is free at that time, otherwise the chair the request already holds. If both are taken it comes back as a 400 `chair_booked` listing the chairs that are still free, so you can pick one and confirm again, and as a 409 `chair_booked` when no chair is free at all.

Sending the status an appointment already has is a no-op `200` returning the row unchanged, so a retried answer is safe. It is a 409 only when the retry also sends a different chair or practitioner, because reporting the first answer's booking as yours would be false.

Path parameters

FieldTypeDescription
appointmentIdRequireduuidCanonical UUID.

Body

FieldTypeDescription
statusRequiredstring`confirmed`, `declined` or `cancelled`.
chairIduuid | nullOnly when confirming: the chair to put it in.
practitionerIduuid | nullOnly when confirming: the person to book it with.

Response · 200

{
  "data": { "appointment": { "id": "0f1c7a2e-....", "status": "confirmed" } },
  "requestId": "req_5f2c1e40-...."
}

Errors

CodeStatusWhen
validation_failed400The status is not one of the three, or a chair or practitioner was sent while declining or cancelling.
not_found404No such appointment in this practice.
conflict409The transition is not allowed from the current status, the chair or the practitioner is booked, no chair is free at all, the practice is blocked for that time, the request has expired, or a retry sent a different chair or practitioner.

DELETE/api/v1/appointments/:appointmentId

Deletes an appointment, cancelling any reminder scheduled for it.

Path parameters

FieldTypeDescription
appointmentIdRequireduuidCanonical UUID.

Response · 200

{
  "data": { "deleted": true },
  "requestId": "req_5f2c1e40-...."
}

Errors

CodeStatusWhen
not_found404No such appointment in this practice.

GET/api/v1/appointments/availability

Computes the free slots on one chair for one calendar day.

Slots are bounded by the practice's working hours and slot length, falling back to defaults for any unset setting. A day the practice is closed returns no slots at all rather than an error.

Busy means an appointment that is `requested` or `confirmed`, an `unavailable` entry on that chair, or practice-wide blocked time. A `declined`, `cancelled` or `expired` appointment does not make a slot busy, and `reminder` entries are ignored entirely because they do not occupy a chair. A slot that has already started is left out, so asking about today returns only what can still be booked.

Query parameters

FieldTypeDefaultDescription
dateRequiredstring—YYYY-MM-DD, interpreted in the practice's timezone.
chairIdRequireduuid—Must belong to your practice.
durationMinutesRequiredinteger—Length of slot to look for, 1 to 1440 minutes.

Response · 200

{
  "data": {
    "slots": [
      { "startAt": "2026-07-08T06:00:00.000Z", "endAt": "2026-07-08T07:00:00.000Z" }
    ]
  },
  "requestId": "req_5f2c1e40-...."
}

Errors

CodeStatusWhen
validation_failed400date is missing or malformed, chairId is not a UUID or belongs to another practice, or durationMinutes is out of range.

Common questions

Patient records are encrypted, both where they are stored and on their way to your screen. Files are private, and a link to one stops working after a short time. Changes to patient records are recorded, with who made them and when. There is no shared practice password: everyone has their own account. On the Complete plan, the practice owner can also require two-step sign-in for the whole team.

Any authenticated practice member can create, update, and delete appointments. Owner and staff roles both have full access, with no role gate on those actions. This is different from chairs and practice settings, which are owner-only, so it's worth checking per resource rather than assuming one rule applies everywhere.

Two entries overlapping on the same chair or the same practitioner, or inside practice-wide blocked time, counting only requested and confirmed bookings and unavailable entries. Overlap is a strict inequality, so touching at a boundary does not count, and reminders are exempt entirely. A conflict is a 409 carrying chair_booked, practitioner_booked or practice_blocked in details[].code.

No. Creating, updating, or deleting a confirmed, patient-linked appointment automatically schedules, reschedules, or cancels its reminder in the background, based on the practice's configured lead time. A booking still waiting to be answered schedules nothing. There is no separate endpoint to call for this; it happens as a side effect of the normal appointment write endpoints.