Credentials
- One key per integration. Requests and audit trails are per key (the rate limit is shared across the account), so “the booking widget” and “the nightly export” should never share one. Name keys after the integration.
- Read-only unless you write. A read-only key cannot write whatever it tries (
403 key_read_only) — the cheapest security control you have. - Server-side only. Never ship a key in a browser, mobile app or public repository.
phk_keys are recognised by secret scanners; if one leaks, rotate it (the old secret keeps working for 24 h while you switch). - Expiry + rotation for anything long-lived; rotate keys with a calendar reminder, not after an incident.
- Connectors are people. An OAuth connector acts as the team member who approved it, with their PracticeHub role — for anything automated use an API key, not someone’s connector.
Rate limits and retries
- 120 requests per minute per account, per surface — every API key shares the account’s REST bucket; AI connectors have their own separate MCP bucket.
X-RateLimit-Limit/X-RateLimit-Remainingare on every response; a429carriesRetry-After(seconds). - Retry
429and5xxwith backoff; never4xx. HonourRetry-Afterwhen present, otherwise exponential (1, 2, 4, 8 s) with jitter, capped at 4–5 attempts. A4xxmeans the request is wrong — retrying it is a loop. - Retry
POSTs only with anIdempotency-Key. A timeout on a create is the one case where you don’t know whether it happened; the key makes the retry return the original result.PATCH/DELETEare safe to repeat as they are. - Batch by fetching lists, not records. 100 patients is one request with
page_size=100, not 100 requests. Filters (updated_at=gte:…,in:) exist so you can ask precisely. - Cache reference data (locations, appointment types, practitioners, payment methods) for minutes and refresh on a timer — it changes rarely. (Reference-data webhook events arrive as the rollout expands; patient and appointment events are live today.)
Pagination
- Browsing / random access:
page+page_size(max 100). Ordering is stable (id is always the last tiebreaker). - Any walk that writes, syncs or exports:
?cursor=and followlinks.nextuntilnull. Constant cost per page, unaffected by rows appearing or disappearing while you walk, no risk of skipping. Cursors are bound to their filters/sort/page_size — build the first URL fresh each run. - Incremental runs: filter
updated_at=gte:<watermark>, sortupdated_at:asc, walk by cursor, then set the watermark to the time the run started (with a few minutes’ overlap).
Time
- Two clocks.
created_atandupdated_at(and webhookoccurred_at) are UTC timestamps for windowing. Everything the practice sees — appointmentstarts_at/ends_at,invoice_date,payment_date, availability slots — is in the clinic’s timezone, asYYYY-MM-DDTHH:MM:SSorYYYY-MM-DDwith no offset.GET /metells you the timezone once; store it. - Send clinic-local times to book. Do not convert to UTC and do not append
Z— the API will read2026-09-01T09:00:00as 9 am at the clinic. - DST is the clinic’s problem, not yours — as long as you keep clinic-local times as strings and only convert at display time using the account timezone.
Money
- Amounts are fixed 2-dp strings (
"45.00"). Parse them into a decimal type (Decimal,BigDecimal,bcmath,Money), neverfloat. Send them back the same way ("45.00", not45). - Currency comes from
/me, once per account. There is one currency per account. - Balances are computed (
invoices.balance,patient_balance,third_party_balance) — read them; don’t sum line items yourself and expect equality to the penny after tax and rounding.
Writes
- Expect refusals and show them.
409with acode(appointment_rule,group_full,billing_rule…) means the practice’s rules said no; themessageis written to be shown to a person.422witherrorsis your input. Neither should be retried. - Send what changed.
PATCHis partial and last-write-wins; sending the whole record back re-applies stale fields over changes staff made in between. - Reconcile child lists by id —
line_itemson invoices,numberson patients: read, edit, send back with ids; omit an id to add, leave one out to remove. - Availability then book, never book blind.
/availabilityreturns exactly what is bookable; copy the slot intoPOST /appointments. Booking itself does not check the diary — like a staff click it writes what you send — so a blind booking can land on top of another appointment. - Let the app do the side effects. Confirmations, reminders, balances, audit entries all happen because you went through the API — don’t replicate them.
Data hygiene
- Store ids, not copies. Keep the PracticeHub id (and put your id in
metadata); fetch details when you need them. Fewer copies of clinical/personal data on your side is less to protect and keeps you current. - Webhooks are thin on purpose — the event tells you what changed, the API tells you to what. Don’t try to reconstruct records from event streams.
- Log the
X-Request-Id, never the request body, when you log API calls; quote it to support. Your own request log is at Developers → API Logs. - Delete means gone. A
*.deletedevent or a404on something you had means soft-deleted or voided — remove or archive it on your side; don’t keep serving it. - Metadata is shared per record across integrations on the account — prefix keys with your app name (
crm_id,crm_synced_at) and keep to a handful.
Resilience
- Design every handler to be re-run. Backfills, nightly runs and webhook handlers should all be safe to execute twice: upsert by PracticeHub id, de-duplicate events by
id, idempotency keys on creates. - Acknowledge webhooks in milliseconds, do work in a queue — a slow endpoint gets retried and eventually disabled.
- Watch the headers. If
X-RateLimit-Remainingtrends to zero in normal operation, you are polling something that should be a webhook or a filter. - Version-tolerant parsing. New attributes and event types appear without notice (additive contract); ignore what you don’t know, never fail on an unexpected key.
Before you go live
- Key is server-side, named, read-only where possible, with an expiry.
-
GET /meat startup: right account, right scopes, timezone and currency stored. - Retries:
429/5xxonly, backoff,Idempotency-Keyon creates. - Walks use
?cursor=; incremental runs useupdated_atwatermarks with overlap. - Times are clinic-local strings; money is decimal;
updated_at/created_atonly for windowing. - Webhook receiver verifies signatures, acks fast, de-duplicates on event
id. - Errors: branch on
code, showmessage, logX-Request-Id. - Tested against a real account with the practice’s own rules (a full group, a refused edit, a void).