Overview
This reference covers twenty-two endpoints over four objects: contacts, their interaction timeline, resumes, and the hiring pipelines (lists) your contacts sit on as cards. Read endpoints use the crm:read scope; every write, including the pipeline routes, uses crm:write. Use /public/usage to check your remaining quota without spending it.
/public/contacts/public/contacts/{id}/public/contacts/public/contacts/{id}/public/contacts/{id}/public/contacts/{id}/resume/public/contacts/{id}/interactions/public/contacts/{id}/interactions/public/interaction-types/public/listshasMore./public/listsstages or copy another pipeline's stage skeleton. Accepts an Idempotency-Key header./public/lists/{id}/public/lists/{id}/public/lists/{id}/public/lists/{id}/stages/public/lists/{id}/stages/{stageId}/public/lists/{id}/stages/orderstageIds as the full desired order./public/lists/{id}/stages/{stageId}{ stagesRemaining }./public/lists/{id}/items/public/lists/{id}/items/{itemId}/public/lists/{id}/items/{itemId}/public/usageThe list withholds emails and phones
GET /public/contacts returns compact rows so an agent can browse and then open one contact. To protect against bulk harvesting, the list projection omits emails and phones (they come back as empty arrays) and returns companyId, locationGeoNameId, and updatedAt as null. Fetch GET /public/contacts/{id} for a single contact's full detail, including its channels. A contact that belongs to another workspace returns an indistinguishable 404, never a 403, so the API cannot be probed to learn whether a contact exists elsewhere.
Authentication
Send your API key with the x-api-key header or as an Authorization: Bearer token. This is the same CazVid Developer API key as the other API families - one key works across products, gated by scope. Keys belong to an organization; org owners and admins manage them in the app under Profile > Developer API. Plaintext keys are shown once when created and stored hash-only by CazVid. Treat keys as secrets: do not put them in frontend code, mobile apps, screenshots, logs, or support messages.
x-api-key: cazvid_jb_...
Authorization: Bearer cazvid_jb_...
Content-Type: application/json403 PLAN_REQUIRED; a key without the CRM scope receives a 403 SCOPE_FORBIDDEN. Org owners and admins manage keys from the Developer API section in CazVid.Read contacts
GET /public/contacts takes an optional search (1 to 200 characters) that matches name, company, job title, skills, industry, education, email, and phone, plus page (1-based) and pageSize (up to 50, default 10). It paginates on hasMore only - there is no total count. jobTitle is an array: it carries every role from the contact's resume, so one contact can show several titles. Long strings are truncated at 150 characters with a ... [truncated] marker.
/public/contactscurl "https://aio-backend-prod.cazvid.app/api/v3.0/crm/public/contacts?search=logistics%20manager&pageSize=10" \
-H "x-api-key: cazvid_jb_..."{
"items": [
{
"id": "6a57e240f55eb331f6e396c8",
"name": "Sidney G.",
"firstName": "Sidney",
"lastName": "G.",
"jobTitle": ["Logistics Manager", "Operations Coordinator"],
"companyName": "Acme Freight",
"companyId": null,
"emails": [],
"phones": [],
"linkedinUrl": "https://www.linkedin.com/in/example",
"locationName": "Bogota, Colombia",
"locationGeoNameId": null,
"tags": [{ "id": "6a1f0b2c9c7c630206ba1e77", "name": "priority" }],
"hasResume": true,
"resumeCount": 1,
"cazvidUserId": null,
"linkedCazvidUser": false,
"createdAt": "2026-07-01T12:30:00.000Z",
"updatedAt": null
}
],
"page": 1,
"pageSize": 10,
"hasMore": true
}GET /public/contacts/{id} returns the full contact. On the list endpoint the fields marked below as list-withheld are empty or null by design.
/public/contacts/{id}| Field | Type | Description |
|---|---|---|
| id | string | Contact id. Use it with the single-contact, interaction, resume, update, and delete endpoints. |
| name | string | null | Display name. |
| firstName | string | null | Given name. |
| lastName | string | null | Family name. |
| jobTitle | string[] | Array of job titles from the contact's resume; one contact can have several. |
| companyName | string | null | Company or employer name. |
| companyId | string | null | Linked company id. List-withheld: always null on the list endpoint. |
| emails | { email, type }[] | Email addresses with labels. List-withheld: empty on the list endpoint; populated on the single-contact endpoint. |
| phones | { phone, type }[] | Phone numbers with labels. List-withheld: empty on the list endpoint; populated on the single-contact endpoint. |
| linkedinUrl | string | null | LinkedIn profile URL, when known. |
| locationName | string | null | Human-readable location. |
| locationGeoNameId | number | null | GeoNames id. List-withheld: always null on the list endpoint. |
| tags | { id, name }[] | Workspace tags on the contact, each returned as an { id, name } object. |
| hasResume | boolean | Whether the contact has at least one attached resume. |
| resumeCount | number | Number of attached resumes. |
| cazvidUserId | string | null | Linked CazVid platform user id, when the contact is a CazVid user. |
| linkedCazvidUser | boolean | Whether the contact is linked to a CazVid platform user. |
| createdAt | string | null | ISO 8601 creation timestamp. |
| updatedAt | string | null | ISO 8601 last-update timestamp. List-withheld: always null on the list endpoint. |
{
"id": "6a57e240f55eb331f6e396c8",
"name": "Sidney G.",
"firstName": "Sidney",
"lastName": "G.",
"jobTitle": ["Logistics Manager", "Operations Coordinator"],
"companyName": "Acme Freight",
"companyId": "6a1f0b2c9c7c630206ba1e44",
"emails": [{ "email": "sidney@example.com", "type": "work" }],
"phones": [{ "phone": "+57 300 000 0000", "type": "mobile" }],
"linkedinUrl": "https://www.linkedin.com/in/example",
"locationName": "Bogota, Colombia",
"locationGeoNameId": 3688689,
"tags": [{ "id": "6a1f0b2c9c7c630206ba1e77", "name": "priority" }],
"hasResume": true,
"resumeCount": 1,
"cazvidUserId": null,
"linkedCazvidUser": false,
"createdAt": "2026-07-01T12:30:00.000Z",
"updatedAt": "2026-07-03T09:15:00.000Z"
}Create, update, and archive contacts
Create with POST /public/contacts (only name is required), update with PATCH /public/contacts/{id} (send only the fields you want to change), and archive with DELETE /public/contacts/{id}. All three use the crm:write scope.
Create is idempotent
Create de-duplicates within the workspace by CazVid-user link, then email, then phone, so re-sending a contact that carries one of those returns the existing one (with deduplicated: true) instead of making a duplicate - no idempotency key is needed, and a retry after a timeout is safe. **Send an email or a phone if you intend to retry**: only name is required, but a name-only create has no de-duplication key, so retrying it after a timeout CAN create a second contact. deduplicated is advisory (a created-timestamp heuristic that can misreport when an existing contact's createdAt falls in the same millisecond as the request, or on legacy records with a missing or clock-skewed createdAt). If an email or phone matches more than one existing contact, the API refuses with 409 CONTACT_CONFLICT rather than guessing which one you meant; reconcile the duplicates and retry.
The create body accepts these fields. Update accepts the same set, all optional.
| Field | Type | Required | Description |
|---|---|---|---|
| name | string | Required | Display name. The only required field on create. |
| firstName | string | Optional | Given name. |
| lastName | string | Optional | Family name. |
| jobTitle | string[] | Optional | One or more job titles (up to 10). Stored as the contact's jobTitle array. |
| companyName | string | Optional | Company or employer name. |
| linkedinUrl | string | Optional | LinkedIn profile URL. |
| locationName | string | Optional | Free-text location (city, region, or country). |
| locationGeoNameId | integer | Optional | GeoNames id for a normalized location, when known. |
| emails | { email, type? }[] | Optional | Email addresses as { email, type? } objects (type is work, personal, or other; up to 10 on create). On update, at most one - it replaces the primary email. |
| phones | { phone, type? }[] | Optional | Phone numbers as { phone, type? } objects (type is mobile, desk, home, or other; up to 10 on create). On update, at most one - it replaces the primary phone. |
| tags | string[] | Optional | Workspace tag names (up to 50). An unknown tag name creates that tag in the workspace. |
curl -X POST https://aio-backend-prod.cazvid.app/api/v3.0/crm/public/contacts \
-H "x-api-key: cazvid_jb_..." \
-H "Content-Type: application/json" \
-d '{
"name": "Sidney G.",
"companyName": "Acme Freight",
"jobTitle": ["Logistics Manager"],
"emails": [{ "email": "sidney@example.com", "type": "work" }],
"phones": [{ "phone": "+57 300 000 0000", "type": "mobile" }]
}'{
"id": "6a57e240f55eb331f6e396c8",
"deduplicated": false,
"name": "Sidney G.",
"firstName": "Sidney",
"lastName": "G.",
"jobTitle": ["Logistics Manager"],
"companyName": "Acme Freight",
"companyId": null,
"emails": [{ "email": "sidney@example.com", "type": "work" }],
"phones": [{ "phone": "+57 300 000 0000", "type": "mobile" }],
"linkedinUrl": null,
"locationName": null,
"locationGeoNameId": null,
"tags": [],
"hasResume": false,
"resumeCount": 0,
"cazvidUserId": null,
"linkedCazvidUser": false,
"createdAt": "2026-07-25T12:30:00.000Z",
"updatedAt": "2026-07-25T12:30:00.000Z"
}emails and phones are capped to a single entry each: a PATCH replaces the contact's primary email or phone slot rather than appending, so send at most one of each. deduplicated is present only on the create response; it is omitted on update.Resumes
POST /public/contacts/{id}/resume attaches a resume to a contact as an inline base64 file. fileName, mimeType, and contentBase64 are all required. It returns 202 Accepted and consumes one CRM daily call plus one of a separate per-key resume-upload quota (default 50 per UTC day; see the resumeUpload block in /public/usage).
/public/contacts/{id}/resumemimeTypemust be one ofapplication/pdf,application/msword, orapplication/vnd.openxmlformats-officedocument.wordprocessingml.document(PDF, DOC, DOCX).- The decoded file must be at most 10 MB, and its real type is verified from its magic bytes against
mimeType; a mismatch returns400 MIME_MISMATCH, an empty file400 EMPTY_FILE, and undecodable input400 INVALID_BASE64. - Parsing is asynchronous: the
202confirms the resume was queued. The parsed fields (name, email, phone, location, job title, skills, LinkedIn) are written onto the contact later, filling only its empty fields. PollGET /public/contacts/{id}to seehasResumeandresumeCountupdate.
curl -X POST https://aio-backend-prod.cazvid.app/api/v3.0/crm/public/contacts/6a57e240f55eb331f6e396c8/resume \
-H "x-api-key: cazvid_jb_..." \
-H "Content-Type: application/json" \
-d '{
"fileName": "sidney-resume.pdf",
"mimeType": "application/pdf",
"contentBase64": "JVBERi0xLjc...=="
}'Interactions
An interaction is a timeline entry on a contact - a call, email, interview, offer, note, and so on. Log one with POST /public/contacts/{id}/interactions (type is required), read the timeline newest-first with GET /public/contacts/{id}/interactions, and discover the valid type names with GET /public/interaction-types. Logging is never de-duplicated: each call creates a new entry, so do not blindly retry a call that may already have succeeded. You can pass a future date to record a scheduled follow-up.
/public/contacts/{id}/interactions/public/contacts/{id}/interactions/public/interaction-typesThe interaction taxonomy is fixed and global
type must be a name from a fixed global taxonomy that is the same for every workspace; an unknown name is rejected with 400 UNKNOWN_INTERACTION_TYPE, whose details lists every valid name. Call GET /public/interaction-types for the authoritative, current list rather than hardcoding them. The names are grouped into seven categories:
communicationassessmentinterviewsoffernextstepsotherrejectedcurl -X POST https://aio-backend-prod.cazvid.app/api/v3.0/crm/public/contacts/6a57e240f55eb331f6e396c8/interactions \
-H "x-api-key: cazvid_jb_..." \
-H "Content-Type: application/json" \
-d '{
"type": "phonecall",
"notes": "Left a voicemail about the operations role.",
"date": "2026-07-04T16:00:00.000Z"
}'Lists and pipelines
A list is a hiring pipeline: a named board with ordered stages, holding one card per candidate. It is the same object the CazVid app calls a list, so a pipeline you build here shows up on the board your recruiters use, and their drag-and-drop moves are visible through these routes. Twelve endpoints cover the three levels: the pipeline itself, its stages, and its cards. Reads use crm:read; every write uses crm:write.
/public/listshasMore./public/listsstages or copy another pipeline's stage skeleton. Accepts an Idempotency-Key header./public/lists/{id}/public/lists/{id}/public/lists/{id}/public/lists/{id}/stages/public/lists/{id}/stages/{stageId}/public/lists/{id}/stages/orderstageIds as the full desired order./public/lists/{id}/stages/{stageId}{ stagesRemaining }./public/lists/{id}/items/public/lists/{id}/items/{itemId}/public/lists/{id}/items/{itemId}Pipeline writes are workspace-wide
Any key scoped to a workspace can manage any pipeline in it, no matter which app user created it - there is no per-user ownership on these routes. The workspace boundary itself is absolute: a pipeline, stage, card, or contact in another workspace returns the same 404 NOT_FOUND as an id that does not exist, so the API cannot be probed to learn what exists elsewhere. The one narrower rule is copyFromListId on create, which in v1 also resolves only pipelines created by your own key's user; another member's pipeline reads as 404 even inside your workspace.
curl "https://aio-backend-prod.cazvid.app/api/v3.0/crm/public/lists?pageSize=10" \
-H "x-api-key: cazvid_jb_..."Creating a pipeline takes an Idempotency-Key
POST /public/lists is the one CRM write with no natural de-duplication - two identical calls make two pipelines - so it accepts an optional Idempotency-Key header carrying any opaque string you can reproduce on a retry (a job-run id, a week stamp). A repeat with the same key AND the same body replays the first result for 24 hours, returning the original pipeline and its original id with an Idempotency-Replayed: true response header; a response without that header was created by that request. The same key with a different body is 422 IDEMPOTENCY_KEY_REUSED and will keep failing, so use a new key. A repeat sent while the first create is still running is 409 IDEMPOTENCY_IN_PROGRESS - nothing was duplicated, so retry in a moment for the replay. That in-flight claim is short-lived (about 2 minutes, not 24 hours): if the original request died mid-create, the claim expires on its own and the next retry creates the pipeline. A create that FAILS is never remembered, and the key is scoped per organization and resolved workspace.
curl -X POST https://aio-backend-prod.cazvid.app/api/v3.0/crm/public/lists \
-H "x-api-key: cazvid_jb_..." \
-H "Idempotency-Key: weekly-pipeline-2026-W32" \
-H "Content-Type: application/json" \
-d '{
"name": "Warehouse Hiring 2026",
"color": "#0EA5E9",
"stages": [
{ "name": "Sourced", "color": "#64748B" },
{ "name": "Phone Screen", "color": "#4F46E5" },
{ "name": "Offer", "color": "#16A34A" }
]
}'GET /public/lists returns every field below except updatedAt and stages. GET /public/lists/{id} adds those two, and applies the same page / pageSize window to EVERY stage, echoing them alongside hasMore at the top level. pageSize goes up to 50 and defaults to 10; page is 1-based and capped at 10000. hasMore is true when ANY stage has more cards, not just the largest one.
| Field | Type | Description |
|---|---|---|
| id | string | Pipeline id. Use it in every /public/lists/{id} path. |
| name | string | null | Pipeline name as shown on the board. |
| color | string | null | Pipeline color, exactly as stored: a hex code or a plain color token. |
| templateType | string | null | The stage-template stamp. EVERY pipeline created through this API is stamped CANDIDATE_PIPELINE_EN, including one seeded from your own stages or copied from another board, so it does not tell you what the stages actually are; ones auto-created for a job posting carry a JOBSEARCH_PIPELINE_ value. Informational only, and it never changes after creation. |
| associatedPosts | string[] | Ids of the job postings linked to this pipeline. Empty for a pipeline created through this API - posting links are made in the CazVid app. |
| itemCount | number | Number of active cards on the pipeline, across every stage. |
| createdAt | string | null | ISO 8601 creation timestamp. |
| updatedAt | string | null | ISO 8601 last-change timestamp. Single-pipeline read only. |
| stages | Stage[] | The board's stages in board order (zorder ascending), each carrying id, name, color, zorder, its total itemCount, and one page of items. Single-pipeline read only. |
{
"id": "6a3ff55406ae2ed06ddb5fd2",
"name": "Warehouse Hiring 2026",
"color": "#0EA5E9",
"templateType": "CANDIDATE_PIPELINE_EN",
"associatedPosts": [],
"itemCount": 1,
"createdAt": "2026-08-01T10:00:00.000Z",
"updatedAt": "2026-08-02T09:12:00.000Z",
"page": 1,
"pageSize": 10,
"hasMore": false,
"stages": [
{
"id": "6a3ff55406ae2ed06ddb5fe1",
"name": "Sourced",
"color": "#64748B",
"zorder": 1,
"itemCount": 1,
"items": [
{
"id": "6a3ff55406ae2ed06ddb5fd1",
"listId": "6a3ff55406ae2ed06ddb5fd2",
"stageId": "6a3ff55406ae2ed06ddb5fe1",
"contactId": "6a57e240f55eb331f6e396c8",
"authorId": null,
"rating": null,
"applicationStatus": null,
"disqualified": false,
"disqualifiedReason": null,
"source": "contact",
"createdAt": "2026-08-01T10:05:00.000Z",
"updatedAt": "2026-08-01T10:05:00.000Z"
}
]
}
]
}A card carries ids, never personal data
The twelve fields below are everything a card exposes: ids plus pipeline state. There is no name, email, phone, or resume on a card by design. Pair the card's contactId with GET /public/contacts/{id} for those - that route is the only read that runs a linked candidate's own privacy check, and the board read deliberately does not repeat any of it. authorId is set when the applicant is a CazVid platform user, who may have no CRM contact record at all, in which case contactId is null.
Each card in a stage's items array carries exactly these twelve fields.
| Field | Type | Description |
|---|---|---|
| id | string | Card id. Use it in the /items/{itemId} paths. |
| listId | string | Id of the pipeline this card belongs to. |
| stageId | string | null | Id of the stage the card currently sits in. |
| contactId | string | null | Id of the CRM contact behind this card. Pair it with GET /public/contacts/{id} for the person's name, email, and phone. |
| authorId | string | null | Id of the CazVid platform user behind this card, set when the applicant is a CazVid user. Such a user may have no CRM contact record, in which case contactId is null. |
| rating | "good_fit" | "maybe" | "not_a_fit" | null | Recruiter rating on the card, when one was set in the app. Not writable through this API in v1. |
| applicationStatus | "invited" | "in_progress" | "completed" | "disqualified" | null | Application status of the card, when one was set. Not writable through this API in v1. |
| disqualified | boolean | Whether the card was disqualified. |
| disqualifiedReason | string | null | Free-text reason recorded when the card was disqualified. |
| source | string | null | How the card got onto the pipeline: contact for an API or CRM add, or an application source for a job applicant. |
| createdAt | string | null | ISO 8601 timestamp the card was added to the pipeline. |
| updatedAt | string | null | ISO 8601 timestamp the card last changed. |
curl -X POST https://aio-backend-prod.cazvid.app/api/v3.0/crm/public/lists/6a3ff55406ae2ed06ddb5fd2/items \
-H "x-api-key: cazvid_jb_..." \
-H "Content-Type: application/json" \
-d '{
"contactId": "6a57e240f55eb331f6e396c8",
"stageId": "6a3ff55406ae2ed06ddb5fe1"
}'zorderis 1-based and contiguous. Every stage mutation renumbers the whole board, sozorder: 1is always the stage new candidates land in.- A pipeline is capped at 30 active stages when you send your own
stagesor append one withPOST /public/lists/{id}/stages.copyFromListIddeliberately does NOT apply that cap, so copying a longer board gives you a pipeline over the limit - and one that can no longer be reordered through this API, sincestageIdsis itself capped at 30. stagesandcopyFromListIdare mutually exclusive on create (400 VALIDATION_ERRORwhen both are sent). Send neither and you get the default candidate pipeline. There is no language selector: build a Spanish board by sending your ownstages, or by copying an existing Spanish one.- Name rules differ between a pipeline and its stages. A pipeline
nameaccepts only alphanumeric words separated by single spaces, so an accent, hyphen, apostrophe, ampersand, period, or double space is400 VALIDATION_ERROR. Stage names carry no such rule: they accept accented characters and slashes, which is how the seeded Spanish boards are named. SoContratación Almacénis rejected as a pipeline name but accepted as a stage name. - Adding a card needs an existing contact.
POST /public/lists/{id}/itemstakes acontactId, not a person's details, so create the contact first. Adding the same contact twice never duplicates the card, but a repeat carrying a differentstageIdmoves it - treat stage placement as an intentional write rather than a no-op. stageIdmust belong to the pipeline in the path. Another pipeline's stage is404 NOT_FOUNDwithdetails[0].fieldofstageId; a card can never be moved onto a foreign board. Adding a card to a pipeline that has no stages is409 LIST_HAS_NO_STAGES- the id is valid, so add a stage rather than hunting for another pipeline.- Cards are never silently lost. Deleting a stage that holds cards requires
?moveToStageId, another active stage of the same pipeline; without it the call is400 MOVE_TO_STAGE_REQUIRED, and amoveToStageIdthat is not an active stage of that pipeline is404 NOT_FOUNDnamingmoveToStageId. A pipeline's last remaining stage cannot be deleted at all (400 LAST_STAGE_UNDELETABLE), and a stage holding an extreme number of cards is400 TOO_MANY_ITEMS_TO_REASSIGN. PUT /public/lists/{id}/stages/ordertakes the full order:stageIdsmust be exactly the pipeline's current active stage ids, with no missing, extra, foreign, or repeated entry.- Every pipeline route consumes exactly one call from the same daily quota, burst limit, and per-IP backstop as the contacts and interactions routes. There is no separate pool and no per-route multiplier.
- Deleting a pipeline reports its blast radius. The response carries
effectswithstagesDeletedanditemsDeleted, counted before the cascade runs. The cascade soft-deletes the pipeline, its stages, and its cards, and clears the pipeline link on the job posting that pointed at it - at most one, and not counted ineffects, so re-read a posting if you need to confirm. The contacts themselves are untouched.
{
"id": "6a3ff55406ae2ed06ddb5fd2",
"deleted": true,
"effects": {
"stagesDeleted": 3,
"itemsDeleted": 12
}
}Check remaining quota
GET /public/usage returns your daily CRM quota and the separate per-key resume-upload quota. It uses the same authentication as the other endpoints and does not count against the quota, so you can poll it before a batch to avoid 429 responses. Polling is throttled to 60 requests per minute per API key.
/public/usagecurl https://aio-backend-prod.cazvid.app/api/v3.0/crm/public/usage \
-H "x-api-key: cazvid_jb_..."| Field | Type | Description |
|---|---|---|
| dailyQuota | number | Total successful CRM calls allowed per organization per UTC day (500). |
| used | number | Quota-consuming CRM calls made with this organization's API keys today (UTC). |
| remaining | number | Successful calls left before the quota resets. |
| resetsAt | string (ISO 8601) | ISO 8601 timestamp of the next UTC midnight, when the quota resets. |
| period | "utc_day" | Quota window. Always utc_day in v1. |
| resumeUpload | { dailyLimit, used, remaining } | The separate per-key resume-upload quota for today: dailyLimit (50), used, and remaining. |
{
"dailyQuota": 500,
"used": 39,
"remaining": 461,
"resetsAt": "2026-07-26T00:00:00.000Z",
"period": "utc_day",
"resumeUpload": {
"dailyLimit": 50,
"used": 0,
"remaining": 50
}
}Errors
Error responses use stable errorCode values so integrations do not need to parse prose messages. Every error echoes the request path; 429 responses add retryAfter, the seconds until the limit window resets. A 429 from the daily quota carries details.period of utc_day; a 429 from the per-minute burst or per-IP backstop carries minute.
| Status | Code | Meaning |
|---|---|---|
| 400 | VALIDATION_ERROR | The request failed validation (for example a malformed id, an out-of-range page or page size, a pipeline name with an accent or punctuation, or a body field that is not on the allowlist). details names each offending field. |
| 400 | UNKNOWN_INTERACTION_TYPE | The type on a logged interaction is not in the global taxonomy. details lists every valid name. |
| 400 | MIME_MISMATCH | The uploaded file's real type does not match the declared mimeType. |
| 400 | EMPTY_FILE | The uploaded resume decoded to an empty file. |
| 400 | INVALID_BASE64 | The contentBase64 could not be decoded. |
| 400 | MOVE_TO_STAGE_REQUIRED | A delete was sent for a stage that still holds cards without ?moveToStageId. The call is rejected before anything is written - cards are never silently deleted. |
| 400 | LAST_STAGE_UNDELETABLE | The pipeline's only remaining stage cannot be deleted. A pipeline always keeps at least one. |
| 400 | TOO_MANY_ITEMS_TO_REASSIGN | The stage holds more cards than one request may reassign. Move some of them to another stage first. |
| 401 | API_KEY_MISSING | No API key was sent. |
| 401 | API_KEY_INVALID | The key does not exist or cannot be verified. |
| 401 | API_KEY_EXPIRED | The key is past its expiry date. |
| 401 | API_KEY_REVOKED | The key was revoked. |
| 403 | PLAN_REQUIRED | The CRM API requires a Platinum, Diamond, or Enterprise plan. |
| 403 | SCOPE_FORBIDDEN | The key lacks the required CRM scope (crm:read for reads, crm:write for writes). |
| 403 | ORG_ACCESS_DENIED | The key cannot access this organization. |
| 403 | WORKSPACE_ACCESS_DENIED | The workspaceId in the request does not belong to your API key. |
| 404 | NOT_FOUND | No contact, pipeline, stage, or card with that id exists in the resolved workspace. One in another workspace returns this same 404. On the pipeline routes, details[0].field names which id to fix (id, stageId, itemId, contactId, or moveToStageId). |
| 409 | CONTACT_CONFLICT | The email or phone matches more than one existing contact, so it cannot be resolved automatically. Reconcile the duplicates and retry. |
| 409 | LIST_HAS_NO_STAGES | A card was added to a pipeline with no active stage for it to land on. Add a stage, then retry. |
| 409 | IDEMPOTENCY_IN_PROGRESS | A POST /public/lists with this Idempotency-Key is still running. Nothing was duplicated; retry shortly for the replay. |
| 413 | PAYLOAD_TOO_LARGE | The decoded resume exceeds the 10 MB limit. |
| 422 | IDEMPOTENCY_KEY_REUSED | The Idempotency-Key was already used with a different request body. Use a new key; retrying will not help. |
| 429 | RATE_LIMIT_EXCEEDED | A rate limit was hit: the daily quota (details.period utc_day) or the per-minute burst or per-IP backstop (details.period minute). |
| 500 | CRM_API_ERROR | An unexpected server error occurred, or a dependency was unavailable. |
{
"statusCode": 429,
"errorCode": "RATE_LIMIT_EXCEEDED",
"message": "CRM API burst limit exceeded. Slow down and retry shortly.",
"details": {
"limit": 30,
"period": "minute"
},
"retryAfter": 43,
"path": "/api/v3.0/crm/public/contacts"
}Limits and behavior
- The CRM daily quota is 500 successful calls per organization per UTC day. This is a separate quota from the other API families. Every metered call (reads and writes) consumes one;
GET /public/usageis free. - A per-organization burst limit of 30 calls per minute, plus a shared 60-per-minute per-IP backstop, protect the backend. Exceeding either returns
429withdetails.periodofminute. - Resume uploads have their own quota of 50 per API key per UTC day, on top of the daily call slot each upload also consumes.
- The list endpoint withholds emails, phones,
companyId,locationGeoNameId, andupdatedAt; fetch a single contact for those.jobTitleis an array, and long strings truncate at 150 characters. - A contact in another workspace returns an indistinguishable
404. Rotate keys by creating a new key, updating your integration, then revoking the old key. - The pipeline routes share that one pool: each consumes a single daily call, and their writes are workspace-wide (any key scoped to the workspace can manage any pipeline in it). A pipeline, stage, or card in another workspace returns the same indistinguishable
404.
Use from an agent (MCP)
Prefer to drive your CRM from an AI agent instead of raw HTTP? The CazVid MCP server exposes the nine crm_* tools (search, get, upsert, update, delete contact, log and list interactions, list interaction types, and upload resume) plus list_api_usage to Claude Code, Claude.ai, ChatGPT, Cursor, and VS Code, using this same API key and quota.