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; the examples show them applied.

Credentials

  • One key per integration. Requests, rate limits and audit trails are per key, 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

  • 600 requests per minute per key. 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. PUT/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=gte:…, in:) exist so you can ask precisely.
  • Cache reference data (locations, appointment types, practitioners, payment methods) for minutes and refresh on their .updated webhooks rather than reading them on every call.

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=gte:<watermark>, sort updated:asc, walk by cursor, then set the watermark to the time the run started (with a few minutes’ overlap) — see the accounting example.

Time

  • Two clocks. created and updated (and webhook occurred_at) are UTC timestamps for windowing. Everything the practice sees — appointment start/end, invoice_date, payment_date, availability slots — is in the clinic’s timezone, as YYYY-MM-DD HH: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-01 09: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 (slot_unavailable, 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 idline_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. allow_clash exists for deliberate double-booking by staff tools, not for public flows.
  • 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 (coming soon) 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 watermarks with overlap.
  • Times are clinic-local strings; money is decimal; updated/created 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 slot clash, a refused edit, a void).