Skip to content

API reference / Patients

Patients and patient notes

The patient record, per-patient clinical notes with revision history, and tag toggling. Every field, the core endpoints, and the exact errors each one returns.

Status is not a fixed enum

Valid patient status values come from the practice's current pack, not from a list fixed by the API. Read them from GET /api/v1/me rather than hardcoding them, or a practice on a different pack will reject writes your client believes are valid.

Scoping and identifiers

Every endpoint here requires authentication and operates scoped to the caller's own practice: a request never supplies a practice id, and cannot reach another practice's records by asking.

Every endpoint can also return the authentication errors, 403 mfa_required or 403 subscription_read_only, and 429. The tables below list only what is specific to each one. The error reference has the full set.

Path segments (patientId, noteId, tagId) must be canonical UUIDs. A malformed id returns not_found (404) without ever reaching the data layer, so a malformed id and a valid-but-nonexistent one are indistinguishable to the caller. That is deliberate: it stops the API confirming which ids exist.

The patient object

{
  "id": "0f1c7a2e-....",
  "practiceId": "8b3d1f60-....",
  "firstName": "Jo",
  "lastName": "Doe",
  "dob": "1990-05-14",
  "gender": null,
  "email": null,
  "phoneMobile": null,
  "address": null,
  "postcode": null,
  "country": null,
  "socialSecurityNumber": null,
  "taxId": null,
  "taxOffice": null,
  "status": "active",
  "statusChangedAt": "2026-01-01T00:00:00.000Z",
  "tagIds": [],
  "notifyByEmail": false,
  "notifyBySms": false,
  "metadata": {},
  "createdAt": "2026-01-01T00:00:00.000Z",
  "updatedAt": "2026-01-01T00:00:00.000Z"
}

statusChangedAt, createdAt and updatedAt are ISO 8601 timestamps; null stays null. dob is a YYYY-MM-DD date on write and on read, not a timestamp: it is a calendar date, and giving it a time would invent a timezone for it.

List rows omit two of these. GET /api/v1/patients returns every field above except socialSecurityNumber and taxId; only the single-patient read returns them.

The note object

{
  "id": "c41a8e93-....",
  "patientId": "0f1c7a2e-....",
  "practiceId": "8b3d1f60-....",
  "title": null,
  "body": "Called patient to confirm appointment.",
  "revisions": [],
  "createdBy": "3d9b2c15-....",
  "createdAt": "2026-01-01T00:00:00.000Z",
  "updatedAt": "2026-01-01T00:00:00.000Z"
}

revisions accumulates prior versions of the note every time it is edited, an audit trail rather than an opt-in feature, and keeps the 20 most recent versions. createdBy is the id of the user who wrote it. title is at most 200 characters and body at most 20,000.

Endpoints

GET/api/v1/patients

Lists patients in the caller's practice, newest first, with optional search and filters.

An out-of-range pagination value is clamped rather than rejected: limit=5000 returns 200 results, it does not fail. A value that is **not plain digits** is a different matter and returns 400 `validation_failed`: `abc`, `1.5`, `1e2`, `0x10`, `+5` and `-3` all fail rather than falling back to the default. The response echoes the effective limit and offset actually used, so a client can detect that clamping happened.

Query parameters

FieldTypeDefaultDescription
limitinteger50Clamped to 1 to 200. Anything but plain digits is a 400.
offsetinteger0Clamped to 0 to 100,000. Anything but plain digits is a 400.
searchstring—Matched against first name, last name, full name, email and mobile number; a phone-shaped query is also matched in E.164 form. Wildcards in the input are escaped, not interpreted.
statusstring—Filters by a status id defined by the practice's pack.
tagIdstring—Filters to patients carrying this tag. A malformed id is 400 `validation_failed`, not 404.
formatstring—`csv` returns the whole matching list as a CSV file instead of JSON, up to 10,000 rows, ignoring limit and offset. Owner only (403 `forbidden`) and the caller's email must be confirmed (403 `email_not_verified`).

Response · 200

{
  "data": {
    "patients": [
      { "id": "0f1c7a2e-....", "firstName": "Jo", "lastName": "Doe" }
    ],
    "limit": 50,
    "offset": 0
  },
  "requestId": "req_5f2c1e40-...."
}

Errors

CodeStatusWhen
missing_authorization401No Authorization header was sent.
validation_failed400A filter is malformed, for example a `tagId` that is not a UUID.
forbidden403`format=csv` was asked for by a member rather than the owner.
email_not_verified403`format=csv` was asked for before the caller confirmed their email address.

POST/api/v1/patients

Creates a patient.

The body is strict: an unrecognized field is rejected rather than ignored, so a typo in a field name fails loudly instead of silently dropping data.

Body

FieldTypeDescription
firstNameRequiredstringTrimmed; must not be empty, and at most 100 characters.
lastNameRequiredstringTrimmed; must not be empty, and at most 100 characters.
dobstring | nullYYYY-MM-DD, a real calendar date, and between 1900-01-01 and today.
genderstring | nullFree text, at most 50 characters.
emailstring | nullMust be a valid address if present, at most 320 characters.
phoneMobilestring | nullAt most 50 characters. Normalised to E.164 when written through the API; a number taken by the public booking page is stored as the patient typed it.
addressstring | nullFree text, at most 500 characters.
postcodestring | nullFree text, at most 20 characters.
countrystring | nullFree text, at most 100 characters.
socialSecurityNumberstring | nullTrimmed; blank becomes null, at most 32 characters. No format check is applied, so a number from any country is accepted.
taxIdstring | nullAt most 32 characters. Needed before a tax invoice can be issued for this patient.
taxOfficestring | nullFree text, at most 80 characters.
statusstringMust be a status id from the practice's pack. Validated against the pack, not a fixed list. Defaults to `active`.
notifyByEmailbooleanWhether this patient may be emailed. Defaults to `false`.
notifyBySmsbooleanWhether this patient may be texted. Defaults to `false`.

Example request

{
  "firstName": "Jo",
  "lastName": "Doe",
  "dob": "1990-05-14",
  "status": "active"
}

Response · 201

{
  "data": {
    "id": "0f1c7a2e-....",
    "firstName": "Jo",
    "lastName": "Doe",
    "dob": "1990-05-14",
    "status": "active",
    "createdAt": "2026-01-01T00:00:00.000Z",
    "updatedAt": "2026-01-01T00:00:00.000Z"
  },
  "requestId": "req_5f2c1e40-...."
}

Errors

CodeStatusWhen
validation_failed400A required field is missing, dob or email is malformed, or the body carries an unrecognized field. details names the offending path.
validation_failed400status is not one of the practice pack's values.

GET/api/v1/patients/:patientId

Fetches one patient.

Path parameters

FieldTypeDescription
patientIdRequireduuidCanonical UUID.

Response · 200

{
  "data": { "id": "0f1c7a2e-....", "firstName": "Jo", "lastName": "Doe" },
  "requestId": "req_5f2c1e40-...."
}

Errors

CodeStatusWhen
not_found404The id is not a UUID, does not exist, or belongs to another practice; all three are indistinguishable.

PATCH/api/v1/patients/:patientId

Updates a patient. Every field from the create body, all optional.

Path parameters

FieldTypeDescription
patientIdRequireduuidCanonical UUID.

Example request

{ "firstName": "Joanna" }

Response · 200

{
  "data": { "id": "0f1c7a2e-....", "firstName": "Joanna" },
  "requestId": "req_5f2c1e40-...."
}

Errors

CodeStatusWhen
validation_failed400Same rules as creating a patient.
not_found404No such patient in this practice.

DELETE/api/v1/patients/:patientId

Deletes a patient.

Deletion is permanent, and any member of the practice may do it. Receipts and invoices already issued keep the patient's details on them, because an issued document may not change.

Path parameters

FieldTypeDescription
patientIdRequireduuidCanonical UUID.

Response · 200

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

Errors

CodeStatusWhen
not_found404No such patient in this practice.
service_unavailable503The patient's files could not be erased, or the erase took too long. Nothing was deleted; retry.

POST/api/v1/patients/:patientId/tags/:tagId

Toggles a tag on a patient: adds it if absent, removes it if present.

There is no request body, and no separate remove endpoint: the tag id comes from the path and the call flips its state. Sending the same request twice returns the patient to where it started.

Path parameters

FieldTypeDescription
patientIdRequireduuidCanonical UUID.
tagIdRequireduuidCanonical UUID of a tag in this practice.

Response · 200

{
  "data": { "id": "0f1c7a2e-....", "tagIds": ["b7e42d18-...."] },
  "requestId": "req_5f2c1e40-...."
}

Errors

CodeStatusWhen
validation_failed400A well-formed `tagId` that no tag in this practice uses. It is a 400 on the field, not a 404.
not_found404The patient does not exist in this practice.

GET/api/v1/patients/:patientId/notes

Lists a patient's notes.

Path parameters

FieldTypeDescription
patientIdRequireduuidCanonical UUID.

Response · 200

{
  "data": { "notes": [] },
  "requestId": "req_5f2c1e40-...."
}

Errors

CodeStatusWhen
not_found404No such patient in this practice.

POST/api/v1/patients/:patientId/notes

Adds a note to a patient.

Path parameters

FieldTypeDescription
patientIdRequireduuidCanonical UUID.

Body

FieldTypeDescription
bodyRequiredstringTrimmed; must not be empty.
titlestring | nullOptional heading for the note.

Example request

{
  "body": "Called patient to confirm appointment."
}

Response · 201

{
  "data": {
    "id": "c41a8e93-....",
    "body": "Called patient to confirm appointment.",
    "createdAt": "2026-01-01T00:00:00.000Z",
    "updatedAt": "2026-01-01T00:00:00.000Z"
  },
  "requestId": "req_5f2c1e40-...."
}

Errors

CodeStatusWhen
validation_failed400body is missing or empty, or the body carries an unrecognized field.
not_found404No such patient in this practice.

PATCH/api/v1/patients/:patientId/notes/:noteId

Edits a note. The previous version is kept in revisions.

Path parameters

FieldTypeDescription
patientIdRequireduuidCanonical UUID.
noteIdRequireduuidCanonical UUID.

Body

FieldTypeDescription
titlestring | nullOptional heading.
bodystringIf present, must still be non-empty.

Example request

{ "title": "Follow-up" }

Response · 200

{
  "data": { "id": "c41a8e93-....", "title": "Follow-up" },
  "requestId": "req_5f2c1e40-...."
}

Errors

CodeStatusWhen
validation_failed400body is present but empty, or an unrecognized field was sent.
not_found404No such patient or note in this practice.
conflict409Somebody else edited the note first; re-read it and apply your change again.

DELETE/api/v1/patients/:patientId/notes/:noteId

Deletes a note.

Path parameters

FieldTypeDescription
patientIdRequireduuidCanonical UUID.
noteIdRequireduuidCanonical UUID.

Response · 200

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

Errors

CodeStatusWhen
not_found404No such patient or note in this practice.

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.

No. Valid status values come from the practice's current pack rather than a fixed API-wide enum, and are returned by GET /api/v1/me. Creating or updating a patient with a status outside that list returns validation_failed naming the offending path.

Because a malformed id and a valid id belonging to another practice must look identical from outside. If one returned 400 and the other 404, the difference would let a caller confirm which record ids exist in other practices.

Yes. Each note carries a revisions array that accumulates prior versions of its content every time it is edited (the 20 most recent), alongside the id of the user who created it. It is an audit trail rather than a versioning feature you opt into.