Skip to main content
A clinic’s CRM is where it keeps people who are not yet patients: a website enquiry, a lead from an event, someone on a waiting list, a referral. Each person is a contact. A contact sits at a stage (for example “New” or “Booked”) of a pipeline, and has a timeline of activities: a call, an email, a note. With these endpoints you can:
  • 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.
A contact is not a patient. It is not part of the patient’s chart, and these routes never convert a contact into a patient or send a message to anyone.

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’s pipeline_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.
The answer is the new contact, in the same shape as the contact above. 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.
Each result is the full contact, exactly as reading it by id returns it, including 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}
The answer is { "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 JSON null 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.
The answer is the updated contact. Setting 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’s last_activity_at. POST /v1/clinics/{clinic_id}/crm/contacts/{contact_id}/activities
The answer is the new activity, in the shape above. Its 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.
  1. POST …/crm/contacts/search with the email.
  2. If data is empty, POST …/crm/contacts with a fresh Idempotency-Key.
  3. If it found a contact, POST …/crm/contacts/{id}/activities with a note describing the new enquiry.
Move a contact to the next stage. 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.