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 apracticehub_idcustom field. “Not yet synced” is simplymetadata[crm_id]=null. - Backfill by cursor, then follow webhooks. The first run walks every patient with
?cursor=; after that,patients.created/patients.updated/patients.deletedevents drive incremental work. Webhooks are thin (id only), so every event ends in aGET. - 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 useIdempotency-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.
numbers[] and address already — no extra requests. PATCH with metadata merges, so other integrations’ keys on the record are untouched.
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:
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.