Skip to main content
Goal: every patient exists as a contact in the CRM, with the CRM’s contact id stored on the PracticeHub record and vice versa; changes on either side flow to the other within seconds; the sync survives restarts, retries and duplicates without creating duplicate contacts. Key: read & write. Resources used: patients (with embedded numbers / address), metadata, cursor pagination, webhooks.

The design

  • The id map lives on the records, not in a table. Each PracticeHub patient carries metadata.crm_id; each CRM contact carries a practicehub_id custom field. “Not yet synced” is simply metadata[crm_id]=null.
  • Backfill by cursor, then follow webhooks. The first run walks every patient with ?cursor=; after that, patients.created / patients.updated / patients.deleted events drive incremental work. Webhooks are thin (id only), so every event ends in a GET.
  • Idempotent everywhere. Backfill can be re-run (it skips anything with a crm_id), a redelivered webhook is a no-op, and CRM → PracticeHub writes use Idempotency-Key.

Client

1. Backfill: everything not yet mapped

?cursor= walks the whole set at constant cost per page and is stable while other things write; metadata[crm_id]=null means the same run can be restarted at any point and simply carries on.
Patient reads embed numbers[] and address already — no extra requests. PATCH with metadata merges, so other integrations’ keys on the record are untouched.
A read-only key can do the CRM-side half of a backfill (read patients, create contacts) — only writing crm_id back needs write. If you want two keys for least privilege, use a read-only one for the walk and a write one for the PATCH.

2. Steady state: PracticeHub → CRM via webhooks (coming soon — poll updated by cursor until they ship)

Subscribe an endpoint to patients.created, patients.updated, patients.deleted (Developers → Webhooks). Verify, acknowledge, then work — the webhook receiver example has the full receiver; the handler is:
Events carry no data and are not ordered, so the handler always fetches the current record and writes the whole contact — that makes it idempotent by construction. Keep the event id for a day to short-circuit redeliveries.
Your own PATCH … metadata.crm_id fires a patients.updated event back at you. That is fine — the handler re-reads and re-writes an identical contact — but you can skip the round-trip by remembering the request’s X-Request-Id for a minute and ignoring events whose fetch shows the same updated timestamp you just caused, or simply by comparing the mapped contact before writing.

3. Steady state: CRM → PracticeHub

When a contact changes in the CRM (its webhook or polling), write the mapped fields back. Only send what changed — PATCH is partial and last-write-wins.
POST /patients accepts first_name (required), last_name, preferred_name, email, dob (YYYY-MM-DD), sex (male|female|other), numbers[] (each with number and a type), address{line1,line2,city,state,postcode,country}, referral_source_id/_type, custom_reference, note, metadata. Anything the practice’s rules refuse comes back as 422 keyed by attribute (errors.email, …) — surface it in the CRM rather than retrying blindly.

What to store on your side

Just the two ids and a watermark: No mapping table, no full snapshots, no polling.

Gotchas