Skip to main content
The API is small and regular; most integration bugs are not about the API but about the things around it — clocks, retries, floats, pagination that shifts under you. This page collects the habits that avoid them. Each one is short.

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-Remaining are on every response; a 429 carries Retry-After (seconds).
  • Retry 429 and 5xx with backoff; never 4xx. Honour Retry-After when present, otherwise exponential (1, 2, 4, 8 s) with jitter, capped at 4–5 attempts. A 4xx means the request is wrong — retrying it is a loop.
  • Retry POSTs only with an Idempotency-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/DELETE are 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 follow links.next until null. 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>, sort updated_at:asc, walk by cursor, then set the watermark to the time the run started (with a few minutes’ overlap).

Time

  • Two clocks. created_at and updated_at (and webhook occurred_at) are UTC timestamps for windowing. Everything the practice sees — appointment starts_at/ends_at, invoice_date, payment_date, availability slots — is in the clinic’s timezone, as YYYY-MM-DDTHH:MM:SS or YYYY-MM-DD with no offset. GET /me tells 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 read 2026-09-01T09:00:00 as 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), never float. Send them back the same way ("45.00", not 45).
  • 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. 409 with a code (appointment_rule, group_full, billing_rule…) means the practice’s rules said no; the message is written to be shown to a person. 422 with errors is your input. Neither should be retried.
  • Send what changed. PATCH is 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_items on invoices, numbers on patients: read, edit, send back with ids; omit an id to add, leave one out to remove.
  • Availability then book, never book blind. /availability returns exactly what is bookable; copy the slot into POST /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 *.deleted event or a 404 on 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-Remaining trends 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 /me at startup: right account, right scopes, timezone and currency stored.
  • Retries: 429/5xx only, backoff, Idempotency-Key on creates.
  • Walks use ?cursor=; incremental runs use updated_at watermarks with overlap.
  • Times are clinic-local strings; money is decimal; updated_at/created_at only for windowing.
  • Webhook receiver verifies signatures, acks fast, de-duplicates on event id.
  • Errors: branch on code, show message, log X-Request-Id.
  • Tested against a real account with the practice’s own rules (a full group, a refused edit, a void).