> ## Documentation Index
> Fetch the complete documentation index at: https://build.practicehub.io/llms.txt
> Use this file to discover all available pages before exploring further.

# CRM sync

> Keep a CRM and PracticeHub in step in Python — cursor backfill, metadata as the id map, webhooks for changes, both directions

**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`.

```mermaid theme={null}
flowchart LR
  subgraph first run
    A[GET /patients?cursor=&metadata[crm_id]=null] --> B[create CRM contact] --> C[PATCH /patients/{id} metadata.crm_id]
  end
  subgraph steady state
    D[webhook patients.updated] --> E[GET /patients/{id}] --> F[update CRM contact]
    G[CRM contact changed] --> H[PATCH /patients/{id}  Idempotency-Key]
  end
```

## Client

```python theme={null}
# practicehub.py
import os, time, requests

BASE = os.environ["PRACTICEHUB_BASE_URL"]      # https://your-clinic.your-region.practicehub.io/v3/api
KEY  = os.environ["PRACTICEHUB_API_KEY"]

class ApiError(Exception):
    def __init__(self, status, code, message, errors=None):
        super().__init__(f"{status} {code}: {message}")
        self.status, self.code, self.errors = status, code, errors or {}

_session = requests.Session()
_session.headers.update({"Authorization": f"Bearer {KEY}", "Accept": "application/json"})

def api(method, path, json=None, headers=None, absolute=False):
    url = path if absolute else f"{BASE}{path}"
    for attempt in range(5):
        r = _session.request(method, url, json=json, headers=headers, timeout=30)
        if r.status_code == 204:
            return None
        if r.ok:
            return r.json()
        if r.status_code == 429 or r.status_code >= 500:
            time.sleep(float(r.headers.get("Retry-After") or 2 ** attempt))
            continue
        body = r.json() if r.headers.get("content-type", "").startswith("application/json") else {}
        raise ApiError(r.status_code, body.get("code", "unknown"), body.get("message", r.reason), body.get("errors"))
    raise ApiError(r.status_code, "retries_exhausted", "gave up after retries")
```

## 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.

```python theme={null}
def unsynced_patients():
    """Yield every patient that has no crm_id yet. Safe to re-run at any time."""
    url = f"{BASE}/patients?cursor=&metadata[crm_id]=null&sort=created:asc&page_size=100"
    while url:
        page = api("GET", url, absolute=True)
        yield from page["data"]
        url = page["links"]["next"]           # None when done; the token is inside the URL

def to_crm_contact(p):
    numbers = {n["type"]: n["intl_number"] or n["number"] for n in p.get("numbers", []) if n.get("number")}
    addr = p.get("address") or {}
    return {
        "practicehub_id": p["id"],
        "firstname": p["first_name"],
        "lastname": p["last_name"] or "",
        "email": p["email"],
        "phone": numbers.get("mobile") or next(iter(numbers.values()), None),
        "date_of_birth": p["dob"],                                   # "YYYY-MM-DD" or None
        "address": ", ".join(x for x in [addr.get("line1"), addr.get("city"), addr.get("postcode")] if x) or None,
    }

def backfill():
    for p in unsynced_patients():
        contact_id = crm.upsert_contact(to_crm_contact(p))         # your CRM SDK: match on email or practicehub_id
        api("PATCH", f"/patients/{p['id']}", json={"metadata": {"crm_id": contact_id, "crm_synced_at": now_iso()}})
```

Patient reads embed `numbers[]` and `address` already — no extra requests. `PATCH` with `metadata` **merges**, so other integrations' keys on the record are untouched.

<Tip>
  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`.
</Tip>

## 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](/examples/webhook-receiver) has the full receiver; the handler is:

```python theme={null}
def on_patient_event(event):
    entity_id = event["data"]["id"]
    if event["type"] == "patients.deleted":
        crm.archive_contact_by_practicehub_id(entity_id)          # soft-deleted or merged in PracticeHub
        return
    try:
        p = api("GET", f"/patients/{entity_id}")["data"]
    except ApiError as e:
        if e.status == 404:                                        # deleted between event and fetch
            crm.archive_contact_by_practicehub_id(entity_id); return
        raise
    crm_id = p["metadata"].get("crm_id")
    if crm_id:
        crm.update_contact(crm_id, to_crm_contact(p))
    else:                                                          # created outside the sync (front desk, widget)
        crm_id = crm.upsert_contact(to_crm_contact(p))
        api("PATCH", f"/patients/{p['id']}", json={"metadata": {"crm_id": crm_id}})
```

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.

<Note>
  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.
</Note>

## 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.

```python theme={null}
def on_crm_contact_changed(contact):
    ph_id = contact.get("practicehub_id")
    body = {"first_name": contact["firstname"], "last_name": contact["lastname"], "email": contact["email"]}
    if contact.get("phone") and ph_id:
        # `numbers` is reconciled by id: keep the stored ones (with their ids), replace/add the mobile.
        current = api("GET", f"/patients/{ph_id}")["data"]["numbers"]
        body["numbers"] = [n for n in current if n["type"] != "mobile"] + [{"number": contact["phone"], "type": "mobile"}]
    elif contact.get("phone"):
        body["numbers"] = [{"number": contact["phone"], "type": "mobile"}]

    if ph_id:
        api("PATCH", f"/patients/{ph_id}", json=body,
            headers={"Idempotency-Key": f"crm-{contact['id']}-{contact['updated_at']}"})
    else:
        created = api("POST", "/patients", json={**body, "metadata": {"crm_id": contact["id"], "source": "crm"}},
                      headers={"Idempotency-Key": f"crm-create-{contact['id']}"})
        crm.set_practicehub_id(contact["id"], created["data"]["id"])
```

`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:

| Where               | Field                                       | Why                                                                       |
| ------------------- | ------------------------------------------- | ------------------------------------------------------------------------- |
| PracticeHub patient | `metadata.crm_id`, `metadata.crm_synced_at` | Find unsynced patients (`=null`), look a contact up from PracticeHub side |
| CRM contact         | `practicehub_id`                            | Route CRM changes to the right patient                                    |
| Your service        | processed webhook `id`s (24 h)              | Ignore redeliveries                                                       |

No mapping table, no full snapshots, no polling.

## Gotchas

| Symptom                                         | Cause                                                              | Fix                                                                                                                                   |
| ----------------------------------------------- | ------------------------------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------- |
| Duplicate contacts                              | Matching on name                                                   | Match on `practicehub_id` first, then email; never name                                                                               |
| Backfill "finishes" but misses people           | Used `page=` and rows shifted while writing                        | Use `?cursor=` for any walk that writes                                                                                               |
| Contact keeps flip-flopping                     | Both sides write on every event without comparing                  | Compare before writing; treat PracticeHub as the source of truth for clinical/contact fields                                          |
| `422` on `numbers.*.number`                     | Not a parseable phone number for the given `iso2`                  | Send `country_code`/`iso2` when you know the country; drop the number rather than fail the whole contact                              |
| Patient loses their landline after a CRM change | Sent a `numbers` list without the stored ids                       | Read the patient, keep the other numbers **with their `id`**, and send the full list — ids are updated in place, omitted ones removed |
| Events for a patient after they're merged       | The old record is `patients.deleted`; the surviving one `.updated` | Archive by `practicehub_id`; the survivor's event re-maps it                                                                          |
| Metadata write refused                          | Over 50 keys or key > 40 chars                                     | Prefix your keys (`crm_…`) and keep them few                                                                                          |
