- read the clinic’s pipelines and their stages;
- list, read, create and update contacts;
- look a contact up by exact email or phone number;
- read a contact’s timeline and add an entry to it.
Who can use it
Pipelines and stages are reference data with no person in them, so
crm.pipelines:read is not a patient-data permission and
is available to a clinic on a Team subscription or above. Contacts and activities hold people’s details, so they are
patient-data permissions: the clinic’s API add-on, the accepted data agreement (or, for an organization, patient-data
approval for the CRM contacts and activities kind and a connection the clinic has approved), and an Enterprise
subscription for the clinic. See Patient information access, Permissions and
Authentication. A missing requirement answers 403 with a code that names it, see Errors.
Try it in the sandbox first. A test key reaches only your workspace’s sandbox, which has one invented pipeline and
only the contacts your own test key has created. Nothing real is involved.
Every example uses a test key and a placeholder clinic. Replace
YOUR_CLINIC_ID and the ids with your own.
List pipelines and stages
Lists the clinic’s pipelines, each with its stages in order. Read this first: a contact’spipeline_id and stage_id come
from here.
GET /v1/clinics/{clinic_id}/crm/pipelines
Archived pipelines are not returned. The list is not paginated.
List contacts
Lists the clinic’s contacts, newest first.GET /v1/clinics/{clinic_id}/crm/contacts
What a contact looks like
Internal fields, including the person’s unsubscribe link, are never returned.
Create a contact
Adds a contact. It never starts outreach: creating a contact through the API does not start any of the clinic’s automations and does not enrol the person in any marketing journey, even if the clinic has them switched on for new contacts.POST /v1/clinics/{clinic_id}/crm/contacts
A field not on this list (including
status, score, do_not_contact and the conversion fields) is refused with
invalid_request, naming it.
Idempotency-Key is required on this route. A retry with the same key and the same body returns the same contact
instead of creating a second one. See Retries and duplicates.
When marketing_opt_in is true, the contact is stamped with the time of consent and consent_source is set to the
contact’s source.
Search contacts by email or phone
Finds contacts whose email or phone is exactly what you send. Use it to check whether someone is already a contact before you create them. The identifier goes in the body, never in the URL, so it does not end up in logs.POST /v1/clinics/{clinic_id}/crm/contacts/search
At least one of
email and phone is required. It is an exact match only: never a name, never a partial value. You get at
most 25 contacts, newest first, and no pagination.
do_not_contact, unsubscribed_at and
consent_at. Check those before you contact anyone.
Read one contact
GET /v1/clinics/{clinic_id}/crm/contacts/{contact_id}
{ "data": { … } } with the full contact. A contact that does not exist, or belongs to another clinic, answers
404 not_found.
Update a contact
Changes some fields of a contact. Fields you leave out are unchanged. A field you send as JSONnull is cleared (for a
field that can be empty).
PATCH /v1/clinics/{clinic_id}/crm/contacts/{contact_id}
The body can carry any of:
record_type, first_name, last_name, email, phone, company, lifecycle_stage,
pipeline_id, stage_id, source, owner_staff_id, referral_source_id, interest, desired_service_id,
desired_provider_id, estimated_value_cents, priority, tags, custom_fields, marketing_opt_in, sms_opt_in. The
values are the same as when creating. To move a contact along the pipeline, send a new stage_id.
These are never writable here: status, score, do_not_contact, unsubscribed_at, converted_patient_id,
converted_at and lost_reason. They belong to the clinic. Sending one is refused with invalid_request.
marketing_opt_in to true records the time of consent and clears any
unsubscribed time. A body with no recognised field is refused with invalid_request. This route has no Idempotency-Key.
Sending the same change twice leaves the contact in the same state.
List a contact’s activities
Returns the contact’s timeline, newest first.GET /v1/clinics/{clinic_id}/crm/contacts/{contact_id}/activities
A contact that does not exist, or belongs to another clinic, answers
404 not_found.
Log an activity
Adds an entry to a contact’s timeline. It also updates the contact’slast_activity_at.
POST /v1/clinics/{clinic_id}/crm/contacts/{contact_id}/activities
occurred_at is the moment of your request, and actor_staff_id
is null. This route has no Idempotency-Key, so a retry after a timeout adds a second entry; read the timeline before
retrying if you need to be sure.
Things to know
Refusals you may see
Pagination. The contacts list and the activities list are paginated with
limit and starting_after. The pipelines
list and a search are not. See Pagination.
FHIR. These routes return JSON only.
Reading is recorded. Reading, listing or searching contacts is recorded in the clinic’s access log, so a clinic can see
what your key looked at.
Common tasks
Add a website enquiry without duplicating someone.POST …/crm/contacts/searchwith the email.- If
datais empty,POST …/crm/contactswith a freshIdempotency-Key. - If it found a contact,
POST …/crm/contacts/{id}/activitieswith anotedescribing the new enquiry.
GET …/crm/pipelines to find the stage id, then PATCH …/crm/contacts/{id} with
{ "stage_id": "…" }.
Pull everything that changed since your last sync. GET …/crm/contacts?updated_since=…, following next_cursor
until has_more is false.