Skip to main content
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.
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.

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 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 for the mechanics.

At a glance

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.
Six legacy resources are not on the new API yet (clinical_notes, custom_forms, custom_form_responses, care_plans, packages, patient_packages) and one is read-only (patient_payment_methods). That is scoping, not a limitation of the platform, and the roadmap is public — see Migrating from the legacy API.

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 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". PUT/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/404s 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.

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.

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.

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.

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.
  • 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 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 PUT/PATCH, handle errors by code. The migration guide has the exact before/after tables and the behavioural differences list; the quickstart gets you to a booked appointment in five calls.