> ## 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.

# Why the new API

> What changed from the legacy /api, and why each change makes integrations simpler, safer and faster to build

The legacy REST API at `/api` grew organically over a decade alongside the original PracticeHub. The new PracticeHub API is a ground-up rebuild on the platform that runs PracticeHub 2.0. It keeps everything integrators relied on — the resource names, the attribute names, the filter syntax — and changes the things that made building against the old API slower and less predictable than it needed to be. This page goes through those changes one by one, with the reasoning, so you can judge the migration for yourself.

<Note>
  Short version: same data model, same query language; one base URL, one auth header, one response shape, one error shape; writes that behave exactly like the PracticeHub app; and capabilities the legacy API never had — an MCP server for AI assistants, per-record integrator metadata and, coming soon, webhooks.
</Note>

## Why retire the legacy API rather than extend it

The legacy `/api` did its job for a decade, and plenty of integrations run on it today. It is being sunset for reasons that are about the platform, not about any one integrator:

* **It was designed for a different era of integration.** It predates the patterns integrators now take for granted — a machine-readable contract (`/schema`, OpenAPI), typed responses, one error shape with stable codes, idempotent retries, webhooks — and none of those can be retrofitted without changing behaviour existing clients depend on. Every one of them is a breaking change on `/api`, so the honest way to ship them is a new version with a clean contract.
* **It was not built for AI assistants.** Assistants and agents work well against APIs that describe themselves, return predictable typed data, refuse bad input clearly and expose a small set of safe, well-named operations. The legacy API's untyped strings, permissive filters and static documentation are the opposite of that. The new API — and the [MCP server](/mcp/overview) that sits on it — was designed for people *and* assistants from the start.
* **It predates the app's service layer.** The new API routes every write through the same services the PracticeHub app uses — validation, business rules, side effects and audit trail included — so a record created by an integration behaves exactly like one created by staff. Keeping a second write path alive would mean two sets of behaviour for the same records.
* **Two APIs is worse for everyone.** Every fix, every new resource and every security improvement would have to be built, tested and documented twice, and the two would still drift. Consolidating on one contract lets us invest in it properly — the additive-only guarantee, CI-checked OpenAPI, the changelog and the roadmap all depend on there being one API.
* **Credentials designed for today's reviews.** The new credential model — hashed keys with a scannable prefix, read-only or read & write scopes, optional expiry, one-click rotation with a grace period, an audit trail for every key event, and revocable OAuth connectors — is built into the new base rather than bolted onto the old one.

The retirement is deliberate and gradual: the legacy API keeps running on every clinic domain alongside the new API, its responses now carry `Deprecation` / `Sunset` / `Link` headers, the switch-off date is announced ahead of time, and everything the legacy API offered is either on the new API today or on the public roadmap. See [Migrating from the legacy API](/guides/migrating-from-legacy) for the mechanics.

## At a glance

|                                      | Legacy `/api`                                                     | New API                                                                                                                                                      |
| ------------------------------------ | ----------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| Base URL                             | `https://{clinic}.{region}.practicehub.io/api`                    | `https://{clinic}.{region}.practicehub.io/v3/api`                                                                                                            |
| Authentication                       | Two custom headers: `x-practicehub-key` + `x-app-details`         | One `Authorization: Bearer` header; keys hashed at rest, scannable `phk_` prefix, **read-only or read & write**, optional expiry, rotation with grace period |
| AI assistants                        | —                                                                 | MCP server; OAuth 2.1 connectors for Claude/ChatGPT acting as a named team member, per-role opt-in, revocable in-app                                         |
| Discovery                            | Static documentation                                              | `GET /schema` from the running API; OpenAPI generated from the same source and checked in CI                                                                 |
| Update verb                          | `POST /{resource}/{id}`                                           | `PATCH /{resource}/{id}` (partial — send only what changes)                                                                                                  |
| List envelope                        | `{ total_entries, data, links{previous,self,next} }`              | `{ data, links{first,prev,next}, meta{total,per_page,current_page,last_page} }`                                                                              |
| Create / update response             | `201 {"patientID": 123}` / `{"updated": true}`                    | The full record, every time                                                                                                                                  |
| Errors                               | Four different shapes, some with an empty body; message text only | One shape `{ message, code, errors? }` with a stable machine-readable `code`                                                                                 |
| Types                                | Every value a string (`"12"`, `"0"`)                              | Integers as numbers, money as fixed 2-dp strings, `null` for empty                                                                                           |
| Unknown filter attribute             | Not validated                                                     | `422` naming the attribute                                                                                                                                   |
| Filter without operator              | Ignored                                                           | Treated as `eq`                                                                                                                                              |
| Page ordering                        | Undefined without an explicit sort                                | Always deterministic (record id as final tiebreaker)                                                                                                         |
| Writes to clinical/financial records | Generic CRUD                                                      | Through the same services as the app: validation, business rules, confirmations, balances, audit trail                                                       |
| Idempotent retries                   | —                                                                 | `Idempotency-Key` header, 24 h replay                                                                                                                        |
| Your own identifiers on records      | —                                                                 | `metadata` object on every record, filterable                                                                                                                |
| Change notifications                 | Poll `deleted_entities` / `updated` filters                       | *(coming soon)* Signed webhooks for every resource, from the API *and* the app, with a self-serve portal (2.0 accounts)                                      |
| Rate limiting                        | Bare `429`, no headers                                            | Per-key limit with `X-RateLimit-*` and `Retry-After`                                                                                                         |
| Request tracing                      | —                                                                 | `X-Request-Id` on every response, visible to the customer on Developers → API Logs                                                                           |
| Contract stability                   | Undocumented                                                      | Additive-only within a version; OpenAPI freshness + breaking-change checks in CI; dated changelog                                                            |
| Docs                                 | Static                                                            | Generated reference + guides; migration guide with the exact envelope mapping                                                                                |

The rest of this page explains each row.

## What stayed the same — on purpose

Migration cost is dominated by things you *cannot* keep. So the new API deliberately kept:

* **Resource names.** `patients`, `appointments`, `practitioners`, `locations`, `appointment_types`, `payments`, `invoices`, `payment_allocations`, `files`, `numbers`, `addresses`, `referral_sources` … — the same paths as `/api`.
* **Attribute names.** `first_name`, `patient_status`, `start`, `appointment_type_id`, … the same `snake_case` names, so your field mappings carry over.
* **The filter language.** `?field=op:value` with `eq, ne, gt, gte, lt, lte, like, in, not-in, between, null, not-null`, `sort=attr:desc`, `page`, `page_size`. Query strings you already build keep working.
* **Tenancy model.** One clinic per subdomain, one credential per clinic. Nothing changes about *where* you call.

Some legacy resources are not on the new API yet (`clinical_notes`, `custom_forms`, `custom_form_responses`, `care_plans`, `packages`, `patient_packages`, `patient_payment_methods`, `discount_codes`, `notifications`). That is scoping, not a limitation of the platform, and the roadmap is public — see [Migrating from the legacy API](/guides/migrating-from-legacy).

## Authentication and credentials

**Legacy:** two custom headers on every call; one key per integration with full access, no scopes, no expiry and no in-place rotation.

**New API:** a single standard `Authorization: Bearer` header — every HTTP client and every API tool understands it. Behind that:

* **Hashed at rest.** Only a SHA-256 hash is stored, so a key is not recoverable from storage — even by us.
* **`phk_` prefix.** Keys are recognisable (`phk_…`), so GitHub secret scanning and tools like gitleaks flag a leaked key immediately.
* **Scopes.** Choose **Read only** for reporting/sync integrations. A read-only key cannot write, whatever it tries — enforced in one place, so it also holds for the MCP server.
* **Expiry and rotation.** Optional 90-day / 1-year lifetimes; rotate in one click and the old secret keeps working for 24 hours while you switch.
* **Audit.** Creation, rotation, revocation — and connector approvals/revocations — are on the account's activity log with who and when.
* **`GET /me`** tells the integration what it is holding: its scopes, and the account's **timezone** and **currency**, so nobody has to guess what timezone the API's datetimes are in.

## AI assistants: the MCP server and OAuth connectors

The legacy API predates AI assistants. The new API ships an [MCP server](/mcp/overview) on the same base URL, backed by the same resources and rules:

* **Two ways in.** Headless agents use an API key; AI apps that a person uses (Claude, ChatGPT, …) connect with **OAuth 2.1** — dynamic client registration and PKCE, so there is nothing to pre-register: paste the URL, approve, done.
* **Acts as a person, not the clinic.** A connector approved by Dana acts as Dana: her permissions, her name on the audit trail. Connecting is **opt-in per role** (Settings → Security → Roles → *Connect AI apps*); account owners can always connect.
* **Governed.** Access tokens live an hour, refresh tokens rotate; an admin can revoke any connection from Developers → API Keys; deactivating a team member — or removing the permission from their role — disconnects everything they approved, immediately.
* **Discovers itself.** `list_entity_types` describes the model; `query_entities` / `get_entity` / `write_entity` cover every resource; curated tools cover the common booking flows. New resources appear to assistants automatically.

## Discovery: the API describes itself

**Legacy:** static documentation maintained separately from the code.

**New API:** `GET /schema/{resource}` is generated from the same definitions the API runs on: every attribute, whether it is filterable and sortable, which write operations exist, and the exact validation rules per attribute per operation. The OpenAPI document on this site is produced by the same generator on every change, and CI fails if it goes stale or if a change would break existing clients. If the docs and the API disagree, that is a bug we can't ship — not something you find in production.

## Consistent envelopes and real types

**Legacy:** a list came back as `{ total_entries, data, links }`, a single record as bare fields, a create as `{"patientID": 123}`, an update as `{"updated": true}`; and every value — ids, booleans, amounts — was a string because it was echoed straight from the database.

**New API:** everything is wrapped in `data`; lists add `links` and `meta`; create and update return the **full record**, so you never need a second request to see what was actually stored (defaults, computed fields, `metadata`). Ids and integer attributes are JSON numbers; money is a fixed 2-decimal string (`"45.00"`) so no client accidentally rounds it as a float; empty is `null`, not `""` or `"0"`. `PATCH` for updates, `DELETE` returning `204` — the verbs mean what HTTP says they mean.

## Errors you can program against

**Legacy:** four different error shapes depending on where the failure happened, some `403`/`404`s with an empty body, and nothing but human text to distinguish "the patient is deleted" from "the slot is taken".

**New API:** one shape everywhere — `{ message, code, errors? }`. `code` is a stable, documented, snake\_case identifier (`patient_not_found`, `group_full`, `appointment_rule`, `key_read_only`, `idempotency_key_reused`, `rate_limited`, …); `errors` is keyed by attribute for input problems. Status codes are used properly: `401` credentials, `403` scope/permission, `404` missing, `405` unsupported operation, `409` business rule, `422` input, `429` rate limit. You branch on codes; `message` is for showing to a human. See [Errors](/guides/errors).

## Writes that behave like PracticeHub

This is the most consequential difference and the one to read carefully if you write data.

**Legacy:** a generic CRUD layer, separate from the application's own booking, billing and patient services.

**New API:** writes to `patients`, `appointments`, `payments`, `invoices`, `payment_allocations` and `files` go through the **same service classes the PracticeHub app uses**. Consequences:

* Bookings are validated as in the calendar (active patient/location/practitioner, resource at that location, group capacity); status changes are real transitions (`cancelled`, `missed`, `arrived`, `pending` = reinstate/un-arrive) with the same side effects as a staff member clicking the button.
* Payments must use a real, active payment method; deleting a payment or invoice **voids** it, exactly like the app.
* Confirmation and reminder messaging fires for API bookings just as for staff bookings.
* The audit trail records the integration ("Created via API: Booking widget") or, for connectors, the team member.

The trade is that a few writes are refused with a `409` and a reason where the practice's rules say so. We think that is strictly better for the practice — and for the integrator, who no longer has to reverse-engineer what the app would have done.

## Safe retries: idempotency keys

**Legacy:** no idempotency support — a client had to check before retrying a timed-out `POST`.

**New API:** send an `Idempotency-Key` (any unique string) with a `POST` and a retry within 24 hours replays the original response (marked `Idempotent-Replayed: true`) instead of creating a second record; the same key with a different body is refused. See [Writing → Retrying safely](/guides/writing#retrying-safely-idempotency-keys).

## Your identifiers on our records: metadata

**Legacy:** integrations kept their own mapping tables ("PracticeHub patient 340 = HubSpot contact hs-778") and had no way to find a record by their own id.

**New API:** every record carries a `metadata` object you own — up to 50 string keys, merged on update, `null` removes — and every resource is filterable by it: `GET /patients?metadata[crm_id]=eq:hs-778`, or `metadata[crm_id]=null` for "everything I haven't synced yet". Set it in the same request that creates the record. See [Metadata](/guides/metadata).

## Push instead of poll: webhooks (coming soon)

**Legacy:** to know what changed you polled `updated`/`created` filters and the `deleted_entities` list, and you only saw changes made through the API.

**New API — coming soon (accounts on PracticeHub 2.0):** subscribe an HTTPS endpoint and receive `{resource}.created / .updated / .deleted` for every resource — **whether the change came through the API, from staff in the app, or from a background job** — plus semantic events such as `appointments.rescheduled` and `appointments.cancelled`. Payloads are thin (event type + record id) so no clinical data transits the delivery network; they are signed with the Standard Webhooks scheme; retries, replay, delivery logs and secret rotation are self-serve in the portal under Developers → Webhooks; each PracticeHub region delivers from its own environment. See [Webhooks](/guides/webhooks).

## Predictable lists

**Legacy:** unknown filter attributes were not validated, a filter without an operator was ignored, and list order was undefined without an explicit sort.

**New API:** unknown filter attributes are a `422` that names them; a bare value means `eq`; every list has a total order (your sort, then id) so pages are stable; `page_size` is capped at 100, with cursor pagination for large syncs and exports.

## Operability

* **`X-Request-Id`** on every response. Quote it to support — and see it yourself on **Developers → API Logs**: a filterable report of every request your integration made (credential, method, path, resource, status and error code, latency, request id) with usage and error summaries. PHI-free, retained 90 days.
* **Rate limits with headers.** 600 requests/minute per key, `X-RateLimit-Limit`, `X-RateLimit-Remaining` and `Retry-After` on `429`, so clients can back off intelligently rather than guessing.
* **Contract guarantees.** Additive-only within a version; the OpenAPI document is regenerated and diffed for breaking changes on every change; a dated [changelog](/changelog).
* **Regions.** Every clinic account is served from its own region (London, Frankfurt, Ohio, Singapore, Sydney); webhook events are delivered from a regional environment — see [Security & data handling](/security).

## Security posture, in one place

* Keys hashed at rest, scannable prefix, scopes, expiry, rotation, immediate revocation, creation/revocation audited.
* Connectors: OAuth 2.1 + PKCE, short-lived tokens, per-role opt-in, revocation and inactive-user cut-off enforced on every call.
* Logs and webhooks carry no clinical or personal data by construction.
* One database per clinic; a credential is bound to the account it was issued on.
* Deprecation/Sunset headers on the legacy API so nothing disappears without notice.

## Migrating

Most integrations move in an afternoon: create an API key, change the base URL and auth header, update response parsing (`data` / `meta` / `links`), switch updates to `PATCH`, handle errors by `code`. The [migration guide](/guides/migrating-from-legacy) has the exact before/after tables and the behavioural differences list; the [quickstart](/quickstart) gets you to a booked appointment in five calls.
