https://{clinic}.{region}.practicehub.io/v3/api:
Filters
Filter with?attribute=operator:value. Any filterable attribute (see GET /schema/{resource}) accepts any operator; repeat an attribute to AND several conditions.
Dates and times take the shape the attribute reads back in: clinic wall time with no zone (
starts_at=gte:2026-09-01T00:00:00), UTC stamps with Z (updated_at=gte:2026-09-01T00:00:00Z; an offset is accepted and converted), calendar days as YYYY-MM-DD — see Dates. Attributes the API speaks in words (patients.sex: male, female, other) are filtered by those words — sex=eq:female, sex=in:male,female — never by a stored code; a word outside the set is a 422.
Filtering on an attribute the resource does not expose, or an unsortable attribute, is a
422 with the attribute named in errors — nothing is silently ignored.Metadata filters
Your own identifiers stored inmetadata are filterable on every resource with metadata[key]=operator:value — see Metadata.
Sorting
?sort=attr sorts ascending; ?sort=attr:desc descending; ?sort=last_name,first_name:asc applies one direction to several attributes. GET /schema/{resource} marks which attributes are sortable.
Pagination
Lists are paginated withpage (from 1) and page_size (default and maximum 100). Every list response carries navigation links and counts:
links.next until it is null; the links preserve your filters and sort. Ordering is stable — the record id is always applied as a final tiebreaker — so pages never repeat or skip rows for a fixed dataset. Records created or deleted between pages can still shift offset pages, and deep pages get slower on large tables — for syncs and exports use a cursor instead. page is capped at 10,000 — higher values are clamped to the cap and meta.current_page reports the page actually served, not the one requested; walk deeper datasets with cursor pagination instead.
Cursor pagination
Addcursor= (empty) to a list request to walk it by cursor. Each page’s links.next carries an opaque token that encodes where the last row sat in the sort order; the server resumes strictly after it (an index seek, not a skip), so every page costs the same however deep you are, and a record created or deleted elsewhere in the set cannot shift or duplicate what you have already seen. Same filters, sort and page_size apply.
links.next until it is null. Cursors are forward-only, carry no total, and are bound to the query they were issued for — reusing one with different filters, sort or page_size is a 422.
Discovering the model
GET /schema/{resource} is the contract for that resource:
embeds lists the related records every read of the resource carries (a patient’s numbers[], a care plan’s billing), each as a JSON Schema fragment keyed by the attribute name. detail_embeds lists what GET /{resource}/{id} adds on top of the list shape — a care plan’s schedule phases[], billing periods[], services[], products[] and visit tracking — and an embed named in both is the fuller shape a single-record read returns. Either is [] when the resource has nothing to embed.
The API Reference is generated from the same source, so both always agree.
Response shape
- Attributes are
snake_case; ids are integers; empty values arenull. - Every record includes
idand ametadataobject. - Only the attributes listed in
GET /schema/{resource}are ever returned — new attributes may be added over time, existing ones are not renamed or removed within the current version.
Deleted records
Records deleted in PracticeHub disappear from their resource.GET /deleted_entities lists what was deleted and when, so a sync can remove its copies — filter with deleted_at=gte:{last sync} (not created_at, which is the deleted record’s own original stamp) and read entity_type / entity_id.
Query cookbook
Every filter, operator and field below is verified againstGET /schema/{resource} and the API’s query parser — copy, paste, swap in your own ids and dates.
Patients updated since a timestamp — the standard incremental-sync query:
invoices.state is paid, unpaid or void:
balance (the amount still owed) is filterable and sortable too — balance=gt:0 is the numeric equivalent if you want it alongside other conditions.
Payments in a date range, by method:
like needs an explicit % wildcard (URL-encoded); contains doesn’t:
dob on file:
in for an OR within one attribute: pending or arrived appointments at either of two locations, next month:
page_size=1 still runs the full count; read meta.total and ignore data: