Read and update your CRM from your own tools, and hear about changes with webhooks. Every example uses https://crm.example.com and the made-up token ccrm_your_token_here: use your CRM's address and your own token.
Getting started
Make a personal access token in your CRM profile, under AI assistants and API. Send it on every request as a bearer token. A token acts as you, in the workspace you made it in: it sees the same companies and locations you do, with your role, and nothing more.
A read only token can list and read. A read and write token can also add and change contacts, consent, tags, deals and tasks, and manage webhooks if you're an agency admin or a company owner.
The API never sends email, text messages or social posts to anyone. There are no send endpoints.
This reference is also an OpenAPI 3.1 document at /api/v1/openapi.json, which needs no token.
One record comes back as { "data": { ... } }. Lists come back as { "data": [ ... ], "next_cursor": "..." }, in the order each list describes. Pass next_cursor as the cursor parameter to get the next page; it is null on the last page. limit sets the page size, up to 200 (50 if you leave it out).
Ids are uuids. Times are ISO 8601 in UTC. Money is whole CAD cents. Keys are snake_case. Request bodies are JSON, and fields the API doesn't know are refused rather than ignored.
To keep a copy in sync, list contacts with updated_since set a minute or two before your last sync started, and page through.
Errors
Errors come back as { "error": { "code": "...", "message": "..." } }, with details for validation errors. The message is written for people; the code is for your program.
Each token can make 300 requests a minute. Past that, requests get 429 with a Retry-After header saying how many seconds to wait. Requests with a missing or wrong token are limited to 20 a minute from each IP address, and that address then waits before even a good token works.
Request bodies can be up to 256 KB.
Consent (CASL)
Consent on a contact is read only except through POST /contacts/{id}/consent, which uses the same consent record as the CRM itself. Every record is dated the moment the CRM receives it: you can't set recorded_at, so consent can't be backdated. Send it when the person agrees.
Express consent needs the exact wording the person agreed to and evidence of how: the method, and for a web form the page address. Implied consent from a purchase or an inquiry needs your reference for it, like an order number. Withdrawals (unsubscribes) are always accepted and apply across the whole company.
Changing a contact's email or phone through the API is refused while consent is on record for that address, so consent is never carried over to an address nobody agreed for.
AI and API access policy
Each company's owners decide what tokens can see about its people, in the company's AI and API access settings. It applies to every token, because the CRM can't tell an AI assistant from any other app. Contacts outside the policy, and everything linked to them, are simply not there: lists leave them out and asking for one by id gets 404.
Details the policy holds back come back as the text [hidden by policy] in place of the value: email, phone, town and consent addresses, or custom fields. Notes and messages, deals and orders, and form answers can be held back as a whole. When a company turns personal tokens off, its people's tokens get 403 tokens_off until it's turned back on.
Every request that returns or changes people's records is logged for the company's owners, with the token's name.
Webhooks
Agency admins and company owners can have the CRM post events to an https address: in Settings, or with /webhooks below. Each event is a POST with a JSON body: { "id", "type", "created_at", "company_id", "data" }. data is the record, shaped as the API returns it. The id is the same for every webhook that gets the event, so you can drop repeats.
Answer with any 2xx status within 10 seconds. Anything else, including a redirect, counts as a failure. Failures are tried again after 1, 3, 9, 27 and 81 minutes, then about 4 hours and 12 hours: 8 tries in all, over about 18 hours. After 5 failures in a row the webhook is marked as failing in Settings until a delivery gets through.
Every delivery is signed. The ccrm-signature header looks like t=1760500000,v1=5257a869..., where v1 is the hex HMAC-SHA256 of the time, a full stop and the raw body, keyed with your signing secret (all of it, including whsec_). Check it against the raw body before you parse it, and refuse deliveries more than 5 minutes old. Other headers: ccrm-event, ccrm-event-id and ccrm-delivery.
Events: contact.created (a contact is added), contact.updated (a contact's details or tags change), consent.recorded (consent is recorded), consent.withdrawn (someone unsubscribes), form.submitted (a form is submitted), deal.created (a deal is added), deal.moved (a deal moves stage), task.completed (a task is done), appointment.booked (an appointment is booked), appointment.updated (an appointment is moved, confirmed, cancelled or marked done), order.paid (an order is paid at a checkout). contact.updated data also lists the changed fields in changed. The test button sends webhook.test.
// Node: check a delivery before trusting it.
import { createHmac, timingSafeEqual } from "node:crypto";
function verify(secret, header, rawBody) {
const parts = Object.fromEntries(header.split(",").map((p) => p.split("=")));
const t = Number(parts.t);
if (!t || Math.abs(Date.now() / 1000 - t) > 300) return false;
const expected = createHmac("sha256", secret).update(`${t}.${rawBody}`).digest();
const given = Buffer.from(parts.v1 || "", "hex");
return given.length === expected.length && timingSafeEqual(given, expected);
}
Record consent or an unsubscribe for a contact, with the evidence CASL needs.
POST/api/v1/contacts/{id}/consent
Record consent or an unsubscribe
Dated when the CRM receives it; recorded_at can't be set. Express consent needs wording and evidence.method (and evidence.page_url for web_form). Implied consent needs evidence.reference. Withdrawals are always accepted.
Token access: read and write.
Parameters
Name
In
Type
Required
About
id
path
string (uuid)
Yes
The record's id
Body (JSON)
Field
Type
Required
About
channel
one of email, sms, dm
Yes
type
one of express, implied_purchase, implied_inquiry, implied_donation, implied_volunteer, implied_membership, withdrawn
Yes
withdrawn records an unsubscribe; implied_donation, implied_volunteer and implied_membership are for charities and nonprofits only.
wording
string
No
The exact words the person agreed to. Needed for express consent. 1 to 2000 characters.
The answer includes the signing secret, this once only. The address must be https and must not point to a private or local network. Agency admins and company owners only.
Token access: read and write.
Body (JSON)
Field
Type
Required
About
url
string
Yes
Where to post events. https only. 1 to 2000 characters.
events
array of one of contact.created, contact.updated, consent.recorded, consent.withdrawn, form.submitted, deal.created, deal.moved, task.completed, appointment.booked, appointment.updated, order.paid
Yes
Events to send: contact.created, contact.updated, consent.recorded, consent.withdrawn, form.submitted, deal.created, deal.moved, task.completed, appointment.booked, appointment.updated, order.paid. Up to 11 items.
description
string, or null
No
A note to tell webhooks apart. Up to 200 characters.
company_id
string (uuid), or null
No
Agency admins: one company, or null for every company. Owners: always their own company.
array of one of contact.created, contact.updated, consent.recorded, consent.withdrawn, form.submitted, deal.created, deal.moved, task.completed, appointment.booked, appointment.updated, order.paid
No
Events to send: contact.created, contact.updated, consent.recorded, consent.withdrawn, form.submitted, deal.created, deal.moved, task.completed, appointment.booked, appointment.updated, order.paid. Up to 11 items.
description
string, or null
No
A note to tell webhooks apart. Up to 200 characters.
active
boolean
No
false pauses deliveries; true resumes them and clears a failing streak.