# Claro Developers > Documentation for the Claro public API v1: a read-only REST API over clinics, practitioners, patients and appointments, with signed webhooks and a catch-up list of webhook events. Served separately in Australia (https://api-au.claro.clinic/api/public/v1) and the United States (https://api-us.claro.clinic/api/public/v1); an API key names its region (claro_sk_live_au_..., claro_sk_live_us_...). Current Claro-Version: 2026-11-01. The API publishes its own machine-readable contract. Prefer it for exact shapes: - [API llms.txt (AU)](https://api-au.claro.clinic/api/public/v1/llms.txt): the API's own summary, served by the API - [OpenAPI document (AU)](https://api-au.claro.clinic/api/public/v1/openapi.json): the full contract - [OpenAPI document (US)](https://api-us.claro.clinic/api/public/v1/openapi.json): the same contract, US realm - [All guides in one file](https://developers.clarohealth.app/llms-full.txt) ## Start here - [Quickstart](https://developers.clarohealth.app/guides/quickstart.md): Make an API key in Claro, then read your clinic and its patients. - [Regions](https://developers.clarohealth.app/guides/regions.md): Australia and the United States are separate deployments, each at its own host. A key, and every ID it sees, belongs to one. - [Authentication and scopes](https://developers.clarohealth.app/guides/authentication.md): Send a key or an OAuth token as a bearer token. Scopes decide which endpoints answer; data classes decide which fields come back. - [Choosing a clinic](https://developers.clarohealth.app/guides/clinics.md): Every request reads one clinic. Name it in the Claro-Clinic header when your credential reaches more than one. ## Reading data - [Pagination and incremental sync](https://developers.clarohealth.app/guides/pagination.md): Lists are cursor-paginated. Patients and appointments also take updated_since, so a sync can fetch only what changed. - [External IDs and matching records](https://developers.clarohealth.app/guides/external-ids.md): Match records by ID, never by name. Every object carries the IDs other systems know it by. - [Webhooks](https://developers.clarohealth.app/guides/webhooks.md): Claro sends a signed POST when something changes. Verify it with a Standard Webhooks library, skip repeats by webhook-id, then re-fetch. ## Handling responses - [Errors](https://developers.clarohealth.app/guides/errors.md): Every error has one shape. Branch on type and code; show message to developers only. - [Rate limits](https://developers.clarohealth.app/guides/rate-limits.md): Each credential has its own budget per 60-second window. The response headers say how much is left. - [Idempotency keys](https://developers.clarohealth.app/guides/idempotency.md): Send an Idempotency-Key with every POST, so a retry never does the work twice. ## Policies - [Versioning and changelog](https://developers.clarohealth.app/guides/versioning.md): v1 grows without breaking you. Every response names its version in Claro-Version. - [Privacy and security](https://developers.clarohealth.app/guides/privacy-security.md): The clinic controls its records. Every read is audited, every field is allowlisted, and some data never leaves. ## Optional - [Changelog](https://developers.clarohealth.app/changelog.md): every change to the API contract - [API reference](https://developers.clarohealth.app/reference/): the rendered OpenAPI document (needs JavaScript) --- # Quickstart Source: https://developers.clarohealth.app/guides/quickstart/ Make an API key in Claro, then read your clinic and its patients. ## 1. Make an API key A clinic admin makes keys in Claro on the web. 1. Go to **Manage → Integrations** and open **Developer tools**. 2. On the **API Keys** tab, choose **Create key**. 3. Name it after what will use it, for example "Patient sync". 4. Under **Scopes**, pick **Read patients** for this guide. Pick as few as you need. 5. Choose **Create**, copy the key, then choose **I've saved it**. Claro shows the key once. A key belongs to one clinic, in one [region](https://developers.clarohealth.app/guides/regions/). It acts as the admin who made it. ## 2. Know your key | Prefix | Works against | |---|---| | `claro_sk_live_au_` | Live clinics in Australia, at `https://api-au.claro.clinic/api/public/v1` | | `claro_sk_live_us_` | Live clinics in the US, at `https://api-us.claro.clinic/api/public/v1` | | `claro_sk_test_au_`, `claro_sk_test_us_` | The sandbox only. Self-serve sandbox access is coming. | | `claro_k` | Older keys. They keep working where they always did. | The key names its region, so use the matching base URL. Sent to the other region, a key gets `421 wrong_region`, with the right base URL in `param`. A test key on a live clinic gets `401 api_key_wrong_mode`, and so does a live key on the sandbox. > **Keep keys on a server.** A key reads patient records. Never put one in a browser, a mobile app or a repository. Load it from an environment variable or a secrets manager. ## 3. Read your clinic ```shell export CLARO_API_KEY="claro_sk_live_au_..." # paste your key curl https://api-au.claro.clinic/api/public/v1/clinics \ -H "Authorization: Bearer $CLARO_API_KEY" ``` With a `claro_sk_live_us_` key, use `https://api-us.claro.clinic/api/public/v1`. Every key can read its own clinic, whatever its scopes. ```json { "object": "list", "data": [ { "id": "clinic_k3v5tq2m7xw4b6n5r2c6hd3yja", "object": "clinic", "name": "Harbourside Skin", "org": { "id": "org_m2p6x5c3v7n3b5k4q6w2tr2zey", "name": "Harbourside Group" }, "timezone": "Australia/Sydney", "country": "AU", "address": { "line1": "1 Example Street", "city": "Sydney", "state": "NSW", "postal_code": "2000", "country": "AU" }, "phone": "+61 2 5550 0100", "email": "hello@example.com", "external_ids": [], "created_at": "2026-03-02T09:15:00Z" } ], "has_more": false, "next_cursor": null } ``` ## 4. Read your patients ```shell curl "https://api-au.claro.clinic/api/public/v1/patients?limit=2" \ -H "Authorization: Bearer $CLARO_API_KEY" ``` ```json { "object": "list", "data": [ { "id": "pat_3f2a9c1e7b4d4e0a9f6c2b8d5e1a7c3f", "object": "patient", "first_name": "Jordan", "middle_name": null, "last_name": "Sample", "preferred_name": null, "date_of_birth": "1988-04-12", "sex": "female", "gender": null, "gender_identity": "woman", "pronouns": "she/her", "email": "jordan.sample@example.com", "mobile": "+61491570006", "address": { "line1": null, "city": "Sydney", "state": "NSW", "postal_code": "2000" }, "preferred_language": "English", "interpreter_required": false, "external_ids": [{ "source": "cliniko", "id": "100234" }], "created_at": "2026-05-20T01:02:03Z", "updated_at": "2026-10-29T04:05:06Z" } ], "has_more": true, "next_cursor": "eyJyIjoicGF0aWVudHMi..." } ``` An API key reaches one clinic, so it needs no `Claro-Clinic` header. `has_more` says there is another page: pass `next_cursor` back as `cursor`. ## Next - [Authentication and scopes](https://developers.clarohealth.app/guides/authentication/): what each scope unlocks. - [Pagination and incremental sync](https://developers.clarohealth.app/guides/pagination/): keep a copy current. - [Errors](https://developers.clarohealth.app/guides/errors/): every error has one shape. - [API reference](https://developers.clarohealth.app/reference/): every field. # Regions Source: https://developers.clarohealth.app/guides/regions/ Australia and the United States are separate deployments, each at its own host. A key, and every ID it sees, belongs to one. ## Base URLs | Region | Base URL | Keys start | `x-claro-realm` | |---|---|---|---| | Australia | `https://api-au.claro.clinic/api/public/v1` | `claro_sk_live_au_` | `au` | | United States | `https://api-us.claro.clinic/api/public/v1` | `claro_sk_live_us_` | `us` | A key names its region in its prefix. Send it to that region's base URL. Every response names its region in the `x-claro-realm` header. ## The wrong region A key sent to the other region is refused before anything is looked up: ```http HTTP/1.1 421 Misdirected Request { "error": { "type": "invalid_request_error", "code": "wrong_region", "message": "This API key belongs to the US region, which this server does not serve. Send it to https://api-us.claro.clinic.", "param": "https://api-us.claro.clinic", "request_id": "GOODd6zOX1oQKHIAAEOx" } } ``` - `param` holds the base URL to use instead. Fix your configuration rather than redirecting at run time. - It is a `421`, not a `401`: the key may be perfectly good. Don't discard it. - An OAuth token has no region in it. Sent to the wrong region, it is simply unknown there: `401 invalid_credential`. ## One region per credential - Each region has its own servers and its own database. - The API never returns another region's records, and never merges them. An integration that serves clinics in both regions holds one credential per clinic, and the key's prefix tells it which base URL to use. ## IDs are region-scoped IDs are opaque strings with a type prefix: `clinic_`, `org_`, `prac_`, `pat_`, `appt_`, `whsub_`. Store and compare them as strings. Never parse them. An Australian ID sent to the US base URL is simply not found: `404 resource_missing`. If you keep records from both regions in one table, key them on region and ID together. ## What differs by region The contract is the same code in both regions. The data reflects each country: - **Practitioner registrations.** `registrations[].authority` is `ahpra` in Australia and `npi` in the US. - **Addresses.** `state` and `postal_code` hold the local form: a state and postcode in Australia, a state and ZIP code in the US. - **Error messages** use the region's spelling. Branch on `type` and `code`, which never change. Government identifiers are never returned in either region. See [data classes](https://developers.clarohealth.app/guides/authentication/#data-classes). # Authentication and scopes Source: https://developers.clarohealth.app/guides/authentication/ Send a key or an OAuth token as a bearer token. Scopes decide which endpoints answer; data classes decide which fields come back. ## Send a bearer token ```http Authorization: Bearer claro_sk_live_au_... ``` Send it to the base URL for the key's [region](https://developers.clarohealth.app/guides/regions/): `_au_` keys to `https://api-au.claro.clinic/api/public/v1`, `_us_` keys to `https://api-us.claro.clinic/api/public/v1`. Two kinds of credential work: - **An API key.** Made by a clinic admin in **Manage → Integrations → Developer tools → API Keys**. It reaches that one clinic, and acts as the admin who made it. - **An OAuth access token.** From an OAuth app (authorization code with PKCE), made under **Developer tools → OAuth Apps**. It acts as the person who approved it, at the clinics they can reach. A full OAuth guide is coming. A signed-in Claro session is refused: `401 machine_credential_required`. The public API is for software, not for the Claro apps. ### When a credential stops working - **Revoked or rotated** in Developer tools, or its OAuth app revoked: `401 invalid_credential` on the next request. - **The person it acts as leaves the clinic:** it reaches no clinic, so clinic reads are `403 no_clinic_access`. ## Scopes A scope decides whether an endpoint answers at all. Missing one is `403 insufficient_scope`. | Scope | Label in Claro | Unlocks in v1 | |---|---|---| | none needed | | `GET /clinics`, `GET /clinics/{id}` | | `patients:read` | Read patients | `GET /patients`, `GET /patients/{id}`, and an appointment's `patient` | | `appointments:read` | Read appointments | `GET /appointments`, `GET /appointments/{id}` | | `practitioners:read` | Read practitioners | `GET /practitioners`, `GET /practitioners/{id}` | | `events:read` | Read webhook events (catch-up) | `GET /events`. See [catching up](https://developers.clarohealth.app/guides/webhooks/#catching-up). | | `clinical_content:read` | Read clinical content | An appointment's `notes`, with the clinic's opt-in (below) | Some scopes include others: `patients:write` and `clinical:read` include `patients:read`, and `appointments:write` includes `appointments:read`. Other scopes, such as `patients:write`, `appointments:write` and `notes:read`, exist for writes and resources the public API does not serve yet. ## Data classes A scope opens an endpoint. The data class decides which fields come back. There are three. | Class | What it covers | You need | |---|---|---| | Operational | Clinics, practitioners, appointment times, status and type | The endpoint's own scope | | Clinical metadata | Who a patient is: names, date of birth, contact details, address. An appointment's patient. | `patients:read` | | Clinical content | What was written about care. In v1, an appointment's `notes`. | `clinical_content:read` **and** the clinic's opt-in for your app | - **A field you may not see is absent, not `null`.** `null` always means "nothing recorded". - **The opt-in is per clinic, per app.** The clinic's owner or admin grants it in **Manage → Integrations**, under **Apps that can read clinical content**, and can withdraw it at any time. An app can't opt itself in. The **Allow** button appears only for an app that holds `clinical_content:read`. - **Patients need wide access.** The person a credential acts as must see all of the clinic's patients in Claro. If not, patient reads are `403 patients_not_visible`. ## Never returned Government identifiers are never returned, whatever the scope: IHI, Medicare and DVA numbers in Australia, and practitioner provider and prescriber numbers. Nor are free-text fields written by or about a patient that are not yet classified. Every field the API returns is on an explicit allowlist. See [Privacy and security](https://developers.clarohealth.app/guides/privacy-security/). # Choosing a clinic Source: https://developers.clarohealth.app/guides/clinics/ Every request reads one clinic. Name it in the Claro-Clinic header when your credential reaches more than one. ## One clinic per request Each clinic controls its own records. So a request reads one clinic's practitioners, patients or appointments, never a mix. - **An API key** reaches exactly one clinic. Leave the header out. - **An OAuth token** can reach every clinic its person works at. Send the header on every request. ## Find the clinic IDs ```shell curl https://api-au.claro.clinic/api/public/v1/clinics \ -H "Authorization: Bearer $CLARO_TOKEN" ``` `GET /clinics` lists every clinic the credential reaches. It needs no scope and takes no header. Clinics in one organization share an `org.id`. ## Send `Claro-Clinic` ```shell curl https://api-au.claro.clinic/api/public/v1/patients \ -H "Authorization: Bearer $CLARO_TOKEN" \ -H "Claro-Clinic: clinic_k3v5tq2m7xw4b6n5r2c6hd3yja" ``` | Situation | Response | |---|---| | Header left out, credential reaches one clinic | That clinic | | Header left out, credential reaches several | `400 clinic_required`, `param: "Claro-Clinic"` | | Header names a clinic the credential does not reach | `404 resource_missing`, the same as a clinic that does not exist | | Credential reaches no clinic | `403 no_clinic_access` | ## The same person at several clinics A practitioner's `prac_` ID is the same at every clinic. Their membership is not: list practitioners per clinic to see where they work, and whether they prescribe there. A patient is a different record at each clinic, with a different `pat_` ID. Claro never links patients across clinics, and neither should you without each clinic's say-so. # Pagination and incremental sync Source: https://developers.clarohealth.app/guides/pagination/ Lists are cursor-paginated. Patients and appointments also take updated_since, so a sync can fetch only what changed. ## Every list has one shape ```json { "object": "list", "data": [ ... ], "has_more": true, "next_cursor": "eyJyIjoicGF0aWVudHMi..." } ``` - `limit` is 1 to 100. The default is 50. - While `has_more` is `true`, pass `next_cursor` back as `cursor`. - A cursor is opaque, and belongs to the list that made it. Do not reuse it on another list. - Keep the other parameters the same from page to page. ## Incremental sync `GET /patients` and `GET /appointments` accept `updated_since`, an ISO 8601 time with an offset. They return records changed at or after it, **oldest change first**. So walking to the last page is a complete sync. The recipe: 1. First run: list with no `updated_since`. Walk every page. 2. Keep the newest `updated_at` you saw. 3. Next run: send that time as `updated_since`. Walk every page again. 4. Upsert by `id`. The boundary time is inclusive, so the last record can come back once more. ```python import json, os, urllib.parse, urllib.request from datetime import datetime BASE = "https://api-au.claro.clinic/api/public/v1" KEY = os.environ["CLARO_API_KEY"] def get(url): req = urllib.request.Request(url, headers={"Authorization": f"Bearer {KEY}"}) with urllib.request.urlopen(req) as resp: return json.load(resp) def sync_patients(since=None): """Upsert every patient changed since `since`; return the new high-water mark.""" params = {"limit": 100} if since: params["updated_since"] = since newest = since while True: page = get(f"{BASE}/patients?" + urllib.parse.urlencode(params)) for patient in page["data"]: upsert(patient) # your code: insert or update by patient["id"] changed = patient["updated_at"] if newest is None or datetime.fromisoformat(changed) > datetime.fromisoformat(newest): newest = changed if not page["has_more"]: return newest params["cursor"] = page["next_cursor"] ``` Save the returned mark only after the walk finishes. A run that fails part way simply starts again from the old mark. Compare times as times, not as strings: the fractional seconds can vary in length. ## What a sync cannot see yet - **Removals.** A deactivated patient drops out of the list, and `GET /patients/{id}` answers `404`. A sync alone never sees it go. Check the patients you hold now and then, and treat a `404` as removed. - **Clinics and practitioners.** They have no `updated_at`, so they refuse `updated_since` with `400 parameter_unsupported`. Re-list them in full. The lists are short. ## Pair it with webhooks Webhooks tell you something changed, with the public ID to re-fetch. A sync makes sure you missed nothing. Run both: handle events as they arrive, and sync on a schedule, at least nightly. See [Webhooks](https://developers.clarohealth.app/guides/webhooks/). # External IDs and matching records Source: https://developers.clarohealth.app/guides/external-ids/ Match records by ID, never by name. Every object carries the IDs other systems know it by. ## The `external_ids` field Clinics, practitioners, patients and appointments all carry it: ```json "external_ids": [ { "source": "cliniko", "id": "100234" }, { "source": "halaxy", "id": "PT-88412" } ] ``` - Each entry is another system's ID for this record, exactly as that system gave it. - One record can carry several, from several systems. An empty list means none are linked. - Links belong to one clinic. The same patient at another clinic is another record, with its own links. - Treat an unknown `source` as just another system. New sources appear without notice. Today the links come from the integrations a clinic connects in Claro. Writing your own, and updating or creating a record by its external ID, is coming. ## Store both IDs Keep Claro's ID (`pat_…`, `appt_…`) on your record, beside your own. Then every later sync is an exact lookup, not a search. ## Matching when there is no link Names are not identifiers. People share them, change them and spell them differently. Match in this order and stop at the first rule that applies: ### Patients 1. **An ID.** Your ID in `external_ids`, or a `pat_` ID you stored. A match. 2. **A strong pair.** `date_of_birth` with `email`, or `date_of_birth` with `mobile`. A match. 3. **Name and date of birth.** Suggest it to a person. Never link automatically. 4. **Nothing.** Treat the patient as new. Compare emails case-insensitively, and mobiles in one normalized format. ### Practitioners Match on `email` together with a registration from `registrations`: an AHPRA number in Australia, an NPI in the US. Never on name alone. ## Keep it reversible Record why you linked two records, and let a person undo it. A wrong link puts one patient's details on another's file. # Webhooks Source: https://developers.clarohealth.app/guides/webhooks/ Claro sends a signed POST when something changes. Verify it with a Standard Webhooks library, skip repeats by webhook-id, then re-fetch. ## Set one up A clinic admin adds webhooks in Claro on the web: 1. Go to **Manage → Integrations**, open **Developer tools** and choose the **Webhooks** tab. 2. Choose **Add webhook**. Enter your endpoint. It must be `https://`. 3. Pick the events. Leave **Include clinical content in payloads** off unless you truly need it (below). 4. Choose **Create**, copy the signing secret, then choose **I've saved it**. **Send test event** sends a sample. **View deliveries** shows recent attempts and your responses. ## Events | Resource | Events | |---|---| | Patient | `patient.created`, `patient.updated` | | Appointment | `appointment.booked`, `appointment.updated`, `appointment.cancelled`, `appointment.completed` | | Note | `note.created`, `note.updated`, `note.discarded` | | Sale | `sale.completed`, when a sale is paid in full. Not in the Webhooks tab yet. | One change sends one event. Canceling a series sends one `appointment.cancelled` per appointment. ## What arrives ```http POST /your/endpoint HTTP/1.1 Content-Type: application/json webhook-id: msg_2KWPBgLlAfxdpx2AI54pPJ85f4W webhook-timestamp: 1761876900 webhook-signature: v1,K5oZfzN95Z9UVu1EsfQmfVNQhnkZ2pj9o9NDN/H/pI4= { "event": "patient.updated", "occurred_at": "2026-10-31T02:15:00Z", "data": { "patient_uuid": "3f2a9c1e-7b4d-4e0a-9f6c-2b8d5e1a7c3f", "patient_public_id": "pat_3f2a9c1e7b4d4e0a9f6c2b8d5e1a7c3f", "clinic_id": 42, "clinic_public_id": "clinic_k3v5tq2m7xw4b6n5r2c6hd3yja", "user_id": 7, "fields_changed": ["mobile"] } } ``` | Header | Meaning | |---|---| | `webhook-id` | This event, for this endpoint. The same on every retry. | | `webhook-timestamp` | Unix seconds, for this attempt. | | `webhook-signature` | One or more `v1,` entries, separated by spaces. | | `x-claro-event` | The event type. | An older `x-claro-signature` header is still sent for existing receivers. Use the Standard Webhooks headers for anything new. ## Re-fetch by public ID Every payload names its records twice: the internal reference it has always carried, and beside it the public ID you can fetch. | Public ID | Beside | Fetch with | |---|---|---| | `clinic_public_id` | `clinic_id` | `GET /clinics/{id}` | | `patient_public_id` | `patient_uuid` | `GET /patients/{id}` | | `appointment_public_id` | `appointment_id`, or a sale's `appointment_uuid` | `GET /appointments/{id}` | | `practitioner_public_id` | `practitioner_id` (`appointment.booked`) | `GET /practitioners/{id}` | - Use the public IDs. The internal references stay for existing receivers. - A public ID is `null` when its reference is. - Notes have no public ID yet, and `user_id` (who acted) has none. - Ignore keys you don't know. Keys are added without a new version. ## Verify every request Claro follows [Standard Webhooks](https://www.standardwebhooks.com/). Use its official libraries with your signing secret (`whsec_…`), on the **raw** request body, before you parse it. ```python # pip install standardwebhooks import os from standardwebhooks.webhooks import Webhook wh = Webhook(os.environ["CLARO_WEBHOOK_SECRET"]) def handle(raw_body: bytes, headers: dict): event = wh.verify(raw_body, {k.lower(): v for k, v in headers.items()}) # raises if invalid ... ``` ```javascript // npm install standardwebhooks import { Webhook } from "standardwebhooks"; const wh = new Webhook(process.env.CLARO_WEBHOOK_SECRET); export function handle(rawBody, headers) { const event = wh.verify(rawBody, headers); // throws if invalid // ... } ``` The libraries also reject old timestamps, which stops replays. Libraries exist for many more languages: see [standard-webhooks on GitHub](https://github.com/standard-webhooks/standard-webhooks). ### By hand 1. Take the secret after `whsec_` and base64-decode it. That is the key. 2. Build `webhook-id` + `.` + `webhook-timestamp` + `.` + raw body. 3. HMAC-SHA256 it with the key, then base64-encode the result. 4. Accept if it equals any `v1,` entry in `webhook-signature`. Compare in constant time. 5. Reject a timestamp more than five minutes from now. ## Handle it - **Reply fast.** Any `2xx` within 10 seconds. Queue the work; do it after you reply. - **Expect repeats.** A failed delivery is retried, up to five attempts in all. Skip a `webhook-id` you have already handled. - **Expect any order.** Events can arrive out of order. Never treat a payload as the latest state: re-fetch the record by its public ID. - **Expect gaps.** An endpoint that keeps failing is switched off and shows **Inactive**. [Catch up](#catching-up) with `GET /events`, and run your [sync](https://developers.clarohealth.app/guides/pagination/) on a schedule too. ## Clinical content in payloads By default a payload carries identifiers and times only. A clinic can tick **Include clinical content in payloads** to add patient details and note text. Leave it off where you can. Read what you need through the API instead, under your credential's scopes, with every read audited. ## Rotating the secret A clinic admin changes the secret on the **Webhooks** tab: **Change signing secret** beside the webhook. Claro shows the new secret once, then offers two choices: - **Change secret.** For the next 24 hours, both the new and the old secret sign every delivery, new first. Update your receiver any time inside that window. The libraries accept any matching signature, so nothing fails in between. - **End the old secret now.** For a leaked secret. The old one stops at once, so deliveries fail your checks until your receiver has the new one. ## Catching up `GET /events` lists exactly what one webhook subscription was sent, oldest first. Use it to catch up after downtime, or to replay. It still lists the events of a webhook that was switched off. - Needs the `events:read` scope. - `subscription` is the webhook's ID, `whsub_…`. The **Webhooks** tab shows it under each webhook. - Narrow it with `type` (comma-separated event types) and `since` (ISO 8601 with an offset). ```shell curl "https://api-au.claro.clinic/api/public/v1/events?subscription=whsub_q4m2x7c5v3n6b2k5r7w3tz4hea&since=2026-10-31T00:00:00Z" \ -H "Authorization: Bearer $CLARO_API_KEY" ``` ```json { "object": "list", "data": [ { "id": "msg_2KWPBgLlAfxdpx2AI54pPJ85f4W", "event": "patient.updated", "occurred_at": "2026-10-31T02:15:00Z", "data": { "patient_uuid": "3f2a9c1e-7b4d-4e0a-9f6c-2b8d5e1a7c3f", "patient_public_id": "pat_3f2a9c1e7b4d4e0a9f6c2b8d5e1a7c3f", "clinic_id": 42, "clinic_public_id": "clinic_k3v5tq2m7xw4b6n5r2c6hd3yja", "user_id": 7, "fields_changed": ["mobile"] } } ], "has_more": false, "next_cursor": "eyJzIjoid2hzdWIi..." } ``` - **Each event is its delivery**, plus `id`: the same value as that delivery's `webhook-id`. De-duplicate across both paths on it. - **Poll with the cursor.** `next_cursor` comes back on the last page too. Keep it, and pass it as `cursor` next time to get only what happened since. - **Late events are never skipped.** Events from the last few seconds wait for the next page. - **Data classes apply inside each event.** `patient_uuid` and `patient_public_id` need `patients:read`, and are absent without it. Clinical content needs `clinical_content:read`, the clinic's opt-in for your app, and a subscription that includes it. - **Order is still not a promise about the records.** Re-fetch them. # Errors Source: https://developers.clarohealth.app/guides/errors/ Every error has one shape. Branch on type and code; show message to developers only. ## The envelope ```json { "error": { "type": "invalid_request_error", "code": "parameter_invalid", "message": "limit must be a whole number from 1 to 100.", "param": "limit", "request_id": "GON6k_hAgBsHUEMAAGhx" } } ``` | Field | Meaning | |---|---| | `type` | The class of problem. One of six, below. Treat an unknown type as `api_error`. | | `code` | The specific problem. Stable once published. | | `message` | For the developer. Wording may change, and follows the region's spelling. Never parse it. | | `param` | The parameter or header at fault, or `null`. For `wrong_region`, the base URL to use instead. | | `request_id` | Also in the `x-request-id` header. Quote it when you contact us. | ## Types and codes | Status | `type` | `code` | What to do | |---|---|---|---| | 401 | `authentication_error` | `invalid_credential` | The key or token is missing, wrong, revoked or rotated. Get a new one. | | 401 | `authentication_error` | `api_key_wrong_mode` | A test key on a live clinic, or a live key on the sandbox. | | 401 | `authentication_error` | `machine_credential_required` | A signed-in session was sent. Use a key or OAuth token. | | 403 | `permission_error` | `insufficient_scope` | Ask the clinic for a credential with the scope. | | 403 | `permission_error` | `route_not_allowed` | This endpoint is not open to credentials. | | 403 | `permission_error` | `no_clinic_access` | The credential reaches no clinic, for example after its person left. | | 403 | `permission_error` | `patients_not_visible` | The person the credential acts as cannot see all the clinic's patients. | | 400 | `invalid_request_error` | `parameter_invalid` | Fix the parameter named in `param`. | | 400 | `invalid_request_error` | `parameter_unsupported` | This list does not take that parameter, such as `updated_since` on clinics. | | 400 | `invalid_request_error` | `clinic_required` | Send `Claro-Clinic`. See [Choosing a clinic](https://developers.clarohealth.app/guides/clinics/). | | 400 | `invalid_request_error` | `parameter_missing` | Send the required parameter named in `param`, such as `subscription` on `GET /events`. | | 400 | `invalid_request_error` | `event_type_not_subscribed` | A `type` on `GET /events` that the subscription does not take. | | 400 | `invalid_request_error` | `idempotency_key_invalid` | The key must be 1 to 255 characters. | | 404 | `invalid_request_error` | `resource_missing` | No such object, or not one this credential reaches. | | 404 | `invalid_request_error` | `route_not_found` | No such endpoint, whatever the method. Check the path against the [reference](https://developers.clarohealth.app/reference/). | | 405 | `invalid_request_error` | `method_not_allowed` | The path exists, but not with this method. The `Allow` header lists the ones it takes. | | 421 | `invalid_request_error` | `wrong_region` | The API key belongs to the other region. `param` is the base URL to use. See [Regions](https://developers.clarohealth.app/guides/regions/#the-wrong-region). | | 409 | `idempotency_error` | `idempotency_key_reused` | The key was used for a different request. Use a new key. | | 409 | `idempotency_error` | `idempotency_key_in_progress` | The first request is still running. Retry after `Retry-After`. | | 429 | `rate_limit_error` | `rate_limited` | Wait `Retry-After` seconds. See [Rate limits](https://developers.clarohealth.app/guides/rate-limits/). | | 500 | `api_error` | varies | Our problem. Retry with backoff, and quote `request_id` if it persists. | New codes can be added within a type. Handle an unknown code by its type. ## Not found means not yours, too A record at a clinic your credential does not reach is a `404`, exactly like one that does not exist. The API never confirms that someone else's record exists. ## Retrying - **Retry** `429`, `409 idempotency_key_in_progress` and `5xx`, with exponential backoff and jitter. - **Do not retry** other `4xx` errors unchanged. They fail the same way again. - When writes arrive, retry a `POST` with the same [Idempotency-Key](https://developers.clarohealth.app/guides/idempotency/). # Rate limits Source: https://developers.clarohealth.app/guides/rate-limits/ Each credential has its own budget per 60-second window. The response headers say how much is left. ## The budget - Each credential has its own limit, counted in fixed 60-second windows. - The default is 300 requests a minute. A key can carry its own limit. - The budget is yours alone. The clinic's own use of Claro never spends it, and your sync never spends theirs. ## The headers Every response that gets past the credential and scope checks carries them, including `400` and `404` errors. ```http RateLimit-Limit: 300 RateLimit-Remaining: 287 RateLimit-Reset: 41 RateLimit-Policy: 300;w=60 ``` | Header | Meaning | |---|---| | `RateLimit-Limit` | Requests allowed in the window | | `RateLimit-Remaining` | Requests left in this window | | `RateLimit-Reset` | Seconds until the window resets | | `RateLimit-Policy` | The limit and the window, in seconds | ## When you run out The response is `429`, with `Retry-After` in seconds: ```http HTTP/1.1 429 Too Many Requests Retry-After: 41 RateLimit-Remaining: 0 {"error": {"type": "rate_limit_error", "code": "rate_limited", ...}} ``` Wait that long, then carry on. Retrying sooner only earns another `429`. ## Staying under - Use `limit=100` on lists. One page of 100 costs the same as one of 10. - Sync with [`updated_since`](https://developers.clarohealth.app/guides/pagination/) rather than re-reading everything. - Slow down as `RateLimit-Remaining` nears zero, instead of waiting for a `429`. - Need more for a real workload? Tell us at [hello@claro.clinic](mailto:hello@claro.clinic). # Idempotency keys Source: https://developers.clarohealth.app/guides/idempotency/ Send an Idempotency-Key with every POST, so a retry never does the work twice. > **Ready before the writes.** v1 is read-only, so no endpoint takes a `POST` yet. The rules below already apply, so you can build retries now. Writes are coming. ## How it works ```http Idempotency-Key: 5f0c8a62-3a8e-4d1b-9a35-6a1d2b7c9e40 ``` - Make a fresh key for each operation: a random UUID is ideal. Up to 255 characters. - Send the same key again only to retry that same request. - Keys are kept for 24 hours, per credential. - The header is optional. Without it, a retried `POST` runs twice. ## What you get back | You send | You get | |---|---| | A new key | The request runs. Its response is stored. | | The same key, the same request | The stored response again, same status and body, with `Idempotent-Replayed: true`. Nothing runs twice. | | The same key, a different request | `409 idempotency_key_reused`. Different method, path, query or body all count. | | The same key while the first is still running | `409 idempotency_key_in_progress`, with `Retry-After` | | A blank or over-long key | `400 idempotency_key_invalid` | A `5xx` or `429` response is never stored. Retrying with the same key after one of those runs the request for real, which is what you want. A replay still counts against your [rate limit](https://developers.clarohealth.app/guides/rate-limits/). # Versioning and changelog Source: https://developers.clarohealth.app/guides/versioning/ v1 grows without breaking you. Every response names its version in Claro-Version. ## The promise Within v1, nothing you rely on is removed or changed in a breaking way. Every change to the API's contract is checked in CI against the last released one, and a breaking change fails the build. Breaking means, for example: - removing an endpoint, a field or a parameter; - renaming one, or changing its type; - making an optional parameter required. ## What can change without notice Additive changes arrive in v1 as they are ready. Build for them: - **New fields.** Ignore fields you do not know. - **New values in a string field.** Treat an unknown value as the default the reference names for that field. An unknown `status` is `booked`, for example. - **New endpoints, error codes and event types.** Handle an unknown error code by its `type`. ## The `Claro-Version` header ```http Claro-Version: 2026-11-01 ``` Every response carries it. The value is a date. It changes only when a new version ships, and the changelog says what changed. Pinning your integration to a version is coming. Each integration will keep the version it was built against until it chooses to move. ## The changelog Every change to the contract is listed in the [changelog](https://developers.clarohealth.app/changelog/), additive ones included. # Privacy and security Source: https://developers.clarohealth.app/guides/privacy-security/ The clinic controls its records. Every read is audited, every field is allowlisted, and some data never leaves. ## The clinic is in control Each clinic controls its own patients' records. Your app sees a clinic's records because that clinic gave you a credential, and only within its scopes. The clinic can revoke it at any time. - Nothing merges records across clinics, or across regions. - Clinical content needs a scope **and** the clinic's opt-in for your app, which the clinic gives and withdraws under **Apps that can read clinical content**. See [data classes](https://developers.clarohealth.app/guides/authentication/#data-classes). ## Every read is audited Every successful read is written to the clinic's audit log before the response is sent. The entry records the credential, what was read, which records were returned, their data classes and the `request_id`. If a read cannot be audited, it is not answered. You get a `500 api_error` and no data. ## Every field is allowlisted - Each resource returns a fixed list of fields. A column added to Claro's database never reaches the API until someone decides it should. - Government identifiers are never returned: IHI, Medicare and DVA numbers, and practitioner provider and prescriber numbers. - Free text written by or about a patient stays out until it is classified. ## Your part - Ask for the fewest scopes that do the job. - Keep credentials and webhook secrets on a server, in a secrets manager. Never in a browser, an app or a repository. - Use HTTPS for your webhook endpoint and verify every request. - Keep only what you need, for as long as you need it. You are handling health information. - If a credential may have leaked, ask the clinic to revoke or rotate it at once. ## Report a security issue Email [security@claro.clinic](mailto:security@claro.clinic). Include what you found, how to reproduce it, and any `request_id`s. - Please don't access, change or keep data that isn't yours while you investigate. - Please give us reasonable time to fix an issue before you share it. - Never send real patient information. Describe it instead. --- # Changelog Every change to the Claro public API contract, newest first. # Changelog Every change to the Claro public API contract, newest first. ## 2026-10-31: a host per region, and keys that name theirs - The API's hosts are now `https://api-au.claro.clinic/api/public/v1` and `https://api-us.claro.clinic/api/public/v1`. The spec's `servers` name them. - New API keys carry their region: `claro_sk_live_au_…`, `claro_sk_live_us_…`, and `claro_sk_test_au_…` / `claro_sk_test_us_…` on the sandbox. - A key sent to the other region gets `421 wrong_region`, with the right base URL in `param`. - Older `claro_k` keys work as before. ## 2026-10-31: catch-up, and public IDs in webhooks - New: `GET /events` lists what one webhook subscription was sent, with the same `webhook-id`s. It needs the new `events:read` scope. - Webhook subscriptions have a public ID, `whsub_…`. - Webhook payloads now carry `clinic_public_id`, `patient_public_id`, `appointment_public_id` and `practitioner_public_id` beside the internal references, so you can re-fetch each record. ## 2026-10-31: one error shape on every path - An unknown path gets `404 route_not_found` under any method. - A known path with the wrong method gets `405 method_not_allowed`, with an `Allow` header. ## 2026-10-31: new clinic controls in Claro - **Apps that can read clinical content**, in Manage → Integrations: where a clinic allows or withdraws your app's clinical-content access. - The key picker offers **Read practitioners**, **Read webhook events (catch-up)** and **Read clinical content**. - **Change signing secret** on each webhook, with a 24-hour overlap or an immediate end for a leaked secret. ## 2026-10-31: v1 launches `Claro-Version: 2026-11-01`. Live in Australia and the United States. - Read endpoints for clinics, practitioners, patients and appointments, each as a list and by ID. - Cursor pagination on every list, and `updated_since` on patients and appointments. - One error format, `RateLimit-*` headers, and `Idempotency-Key` support ready for writes. - Three data classes: operational, clinical metadata and clinical content. - New API keys: `claro_sk_live_…`, and `claro_sk_test_…` for the sandbox. - New scopes: `practitioners:read` and `clinical_content:read`. - `external_ids` on every object. - The OpenAPI document and `llms.txt`, served by the API with no key. ## 2026-10-30: webhooks follow Standard Webhooks - Every delivery carries `webhook-id`, `webhook-timestamp` and `webhook-signature`. - New signing secrets start `whsec_`, so Standard Webhooks libraries take them as they are. - Rotating a secret keeps the old one signing for an overlap window, 24 hours by default. - The older `x-claro-signature` header is still sent.