Contacts
Core CRUD, filtering, bulk operations, export, and stats for contacts.
All endpoints require authentication via the X-API-Key header. See Authentication.
Create a contact
POST /contacts
Requires at least one of: email, phone, or twitterHandle.
Request body
| Field | Type | Required | Notes |
|---|---|---|---|
firstName | string | No | |
lastName | string | No | |
email | string | Conditional | Unique per org. Required if phone and twitterHandle are absent. |
phone | string | Conditional | E.164 format. Unique per org. Required if email and twitterHandle are absent. |
twitterHandle | string | Conditional | Unique per org. Required if email and phone are absent. A leading @, surrounding spaces and a twitter.com or x.com profile URL are accepted and stored as the bare handle. |
organization | string | No | |
website | string | No | |
address | string | No | |
birthDate | string | No | MM/DD format. |
profileImg | string | No | URL to profile image. |
tags | string[] | No | Trimmed, lowercased, deduplicated. |
fields | object | No | Custom field values keyed by field ID. See Custom field values. |
preferredCommunicationChannel | string | No | One of: email, personal-sms, stacked-sms, web-chat, twitter. |
notes | string | No |
Custom field values
fields is keyed by custom field ID. The accepted value depends on the field's type:
| Type | Value |
|---|---|
text | string |
number | number, or a numeric string |
date | timestamp in milliseconds, or a date string |
user | user ID |
select | option ID or option label |
multi-select | array of option IDs or option labels |
For select and multi-select, send either the option ID or the option's label. Labels are matched ignoring case and surrounding whitespace, so "Senior", "senior" and " Senior " all resolve to the same option. Mixing IDs and labels in one multi-select array is allowed, and an option sent twice (once as its ID, once as its label) is stored once.
A label that matches no existing option is rejected - the value is never created on the fly. GET /contacts/custom-fields returns each field's values object, which maps option ID to label.
Example request
Response
Errors
| Status | Message | Cause |
|---|---|---|
| 400 | Contact must have a phone number, an email address, or a twitter handle | None of email, phone, or twitterHandle provided. |
| 400 | email: Contact with the given email already exists. | Duplicate email within the org. |
| 400 | phone: Contact with the given phone already exists. | Duplicate phone within the org. |
| 400 | twitterHandle: Contact with the given twitter handle already exists. | Duplicate twitterHandle within the org. |
| 400 | phone: Phone parse failed: <reason> (input: <masked>) | The value could not be read as a phone number at all. <reason> names what libphonenumber rejected; the input is masked rather than shown in full. |
| 400 | phone: Invalid phone number: <reason> (region: <region>, input: <masked>) | The value parsed but is not a valid number for its region. This is the usual rejection for a plausible-looking number. |
| 400 | Class Year: Invalid select value "Freshmn" / Class Year: Invalid multi-select value "..." | A select or multi-select value matched neither an option ID nor an option label. The message is prefixed with the custom field's label. |
| 400 | fields.<id>: No custom field exists with this id | A fields key does not match any custom field in your org. |
Create or update a contact (upsert)
POST /contacts/upsert
Creates the contact when it does not exist and updates it when it does, in one call. Use this instead of POST /contacts when your system is the source of truth and you do not store our contact IDs.
Send calls for the same contact sequentially rather than in parallel. Two concurrent calls carrying the same identity can both create.
Matching
The contact is matched within your organization, in this order:
externalId, when sent together withsource. This is the most reliable key because it keeps working after the contact's email or phone changes.emailortwitterHandle, both compared case-insensitively. The handle is never required: a record still needs anemailor aphone.phone, compared after normalization to E.164.
A match on email or twitterHandle is enough to update. When your email matches one contact and your twitterHandle another, those are two people and the request answers 409 ambiguous-match/multiple-contacts. A match on phone alone is weaker: a phone number often belongs to a household rather than to one person. It updates only when nothing contradicts it, meaning the matched contact holds no email of its own and you sent one. If the matched contact already has a different email, or more than one contact carries the number, the request answers 409 and returns the candidate IDs for you to resolve. Names are never used to confirm or reject a match.
Request body
The same fields as Create a contact, plus:
| Field | Type | Required | Notes |
|---|---|---|---|
source | string | Only with externalId | Stable identifier for the system calling the API, for example my-crm. |
externalId | string | No | Your own immutable ID for this record. Stored on first use and reused on later calls. |
onInvalidField | string | No | warn (default) or reject. What a malformed twitterHandle, email or phone does: left out and listed in warnings, or the whole request is refused. |
Requires at least one of email or phone, unless the externalId was already linked by an earlier call: then it is enough on its own. A phone without a leading + is parsed as a US number, so send international numbers with their +. A number that is not a valid US number, including one too long to be US, is not guessed at and is treated as a malformed phone: it is left out and listed in warnings, or the request answers 400 when it is the only identity.
On update, the fields you send replace the existing values, tags are added to the existing tags rather than replacing them, and an archived contact is un-archived. Matching ignores the case of an email, but writing does not: if you send jane@example.com for a contact stored as Jane@Example.com, it matches that contact and then stores your spelling.
By default (onInvalidField omitted or warn), a malformed twitterHandle, email or phone does not refuse the record. The field is left out, everything else is written, and the field is listed in warnings on the response. The value is not stored, and an existing stored value is kept. When the dropped email or phone was the only way to identify the contact (no linked externalId and no other valid email or phone), the request answers 400 with that field's error instead.
With onInvalidField: "reject", any malformed twitterHandle, email or phone refuses the whole request with 400 and nothing is written. data.issues lists every malformed field, so one call reports them all. Duplicate values are answered the same way in both modes.
Once the contact is matched through its externalId, an email or phone sent exactly as stored, or a twitterHandle that is the stored handle in the same case (a leading @ or a profile URL is fine), is never refused as a duplicate, even when another contact shares it (a household phone, for example). A value it does not hold yet that already belongs to another contact answers 409 external-id-conflict, and nothing is written. Without a linked externalId, an email, phone or twitterHandle that another contact also holds makes that contact a second match, so the request answers 409 ambiguous-match/multiple-contacts.
Example request
Response
201 when the contact was created, 200 when it was updated. The code field carries the same information.
warnings is always present on 200 and 201. Each entry names a field that was left out because its value was malformed:
field is one of twitterHandle, email or phone. Phone messages mask the middle of the number.
Errors
| Status | Message | Code | Cause |
|---|---|---|---|
| 400 | email: Contact must have either an email or a phone number | Neither identity field was provided. The message names email regardless of which field is missing. | |
| 400 | phone: Phone parse failed: <reason> (input: <masked>) | Only when phone was the only identity sent. phone could not be read as a phone number at all. <reason> names what libphonenumber rejected; the input is masked rather than shown in full. | |
| 400 | phone: Invalid phone number: <reason> (region: <region>, input: <masked>) | Only when phone was the only identity sent. phone parsed but is not a valid number for its region. This is the usual rejection for a plausible-looking number. | |
| 400 | email must be a valid email | Only when email was the only identity sent and it is malformed. Otherwise the email is left out and reported in warnings. | |
| 400 | twitterHandle: Contact with the given twitter handle already exists. | Without a linked externalId, another contact took the twitterHandle between the lookup and the write. The email: and phone: versions appear the same way. Otherwise a contact already holding the value is a match (409 ambiguous-match/multiple-contacts when it is not the only one). | |
| 400 | The first malformed field's error, as in the rows above | onInvalidField is reject and at least one of twitterHandle, email or phone is malformed. Nothing was written. data.issues lists every malformed field with its path and message. | |
| 400 | source: `source` is required when `externalId` is provided | externalId sent on its own. | |
| 400 | onInvalidField: Invalid option: expected one of "warn"|"reject" | onInvalidField is not warn or reject. | |
| 400 | Class Year: Invalid select value "Freshmn" / Class Year: Invalid multi-select value "..." | A select or multi-select value matched neither an option ID nor an option label. The message is prefixed with the custom field's label. | |
| 400 | fields.<id>: No custom field exists with this id | A fields key you sent does not match any custom field in your org. An id the API itself echoed back from the contact's stored data is not rejected. | |
| 409 | More than one contact matches... | ambiguous-match/multiple-contacts | Two or more contacts matched. data.candidateContactIds lists them; pick one and use PATCH /contacts/:id. |
| 409 | A contact matches this phone number... | ambiguous-match/phone-only | Only the phone matched and the stored contact could not be confirmed: it already has a different email, or you sent no email. Pick a candidate from data.candidateContactIds and use PATCH /contacts/:id. |
| 409 | The contact this externalId is linked to would get a ... | external-id-conflict | The contact was matched through its externalId, and you sent an email, phone or twitterHandle it does not hold yet that another contact already has. Nothing was written. data.candidateContactIds lists the linked contact first, then the contacts holding the value; data.fields names the conflicting fields. Reconcile those contacts, for example by merging them or correcting the other contact, before sending the record again. Do not PATCH the value onto the linked contact: PATCH does not check for duplicates, so it would store the duplicate this refusal prevents. |
List contacts
GET /contacts
Returns non-archived contacts only.
Query parameters
| Parameter | Type | Default | Notes |
|---|---|---|---|
page | number | 0 | 0-indexed offset pagination. |
perPage | number | 50 | Items per page. |
Example request
Response
Uses offset-based pagination. The pagination object is included in the response.
Get a contact
GET /contacts/:id
Path parameters
| Parameter | Type | Description |
|---|---|---|
id | string | Contact ID (MongoDB ObjectId). |
Example request
Response
Errors
| Status | Message | Cause |
|---|---|---|
| 404 | Contact does not exist. | No contact with that ID in your org. |
Update a contact
PATCH /contacts/:id
Accepts the same body fields as create. Only provided fields are updated.
Path parameters
| Parameter | Type | Description |
|---|---|---|
id | string | Contact ID. |
Example request
Response
Standard success envelope with the updated contact in data.
Errors
| Status | Message | Cause |
|---|---|---|
| 404 | Contact does not exist. | No contact with that ID in your org. |
| 400 | Validation error message | Invalid field value (e.g. duplicate email, bad phone format). |
Delete a contact
DELETE /contacts/:id
This is async — the endpoint dispatches a cloud task and returns immediately. The contact will be deleted shortly after.
Path parameters
| Parameter | Type | Description |
|---|---|---|
id | string | Contact ID. |
Example request
Response
Errors
| Status | Message | Cause |
|---|---|---|
| 404 | Contact does not exist. | No contact with that ID in your org. |
Count contacts
POST /contacts/countTotal
Count contacts matching a filter. Accepts the same filter body as filter contacts.
Request body
| Field | Type | Notes |
|---|---|---|
fields | object | Custom field filter criteria. |
name | string | Regex match against full name (firstName + lastName). |
search | string | Multi-field search across name, email, twitterHandle, and phone. |
archived | boolean | Filter by archived status. |
Example request
Response
The data field contains the count as a number.
Filter contacts
POST /contacts/filter
Advanced filter and search with sorting and pagination.
Query parameters
| Parameter | Type | Default | Notes |
|---|---|---|---|
sort | string | firstName:asc,lastName:asc | Comma-separated field:direction pairs. Allowed fields: firstName, lastName, email, phone, organization, address, createdAt, updatedAt, birthDate, twitterHandle, tags, plus custom field IDs (fields.<id>). |
page | number | 0 | 0-indexed. |
perPage | number | 50 | Max 1000. |
Request body
Wrap filter criteria in a filter object:
| Field | Type | Notes |
|---|---|---|
filter.fields | object | Custom field filter criteria. |
filter.name | string | Regex match against full name. |
filter.search | string | Multi-field search: name, email, twitterHandle, phone. |
filter.archived | boolean | Filter by archived status. |
filter.ids | string[] | Filter to specific contact IDs. |
Example request
Response
Uses offset-based pagination. Note that totalPages is -1 for filter results (not computed).
Bulk edit contacts
POST /contacts/bulk-edit
Select contacts by ID list or filter, then apply updates. Uses a MongoDB transaction.
Request body
| Field | Type | Notes |
|---|---|---|
contactIds | string[] | List of contact IDs to update. |
filter | object | Alternative to contactIds — same filter format as filter contacts. |
update | object | Fields to update. |
update.archived | boolean | Set archived status. |
update.tags | string[] | Replace tags. |
update.fields | object | Set custom field values. |
Provide either contactIds or filter to select contacts, plus update with the changes.
Example request
Response
Standard success envelope confirming the bulk update.
Errors
| Status | Message | Cause |
|---|---|---|
| 400 | Contact Id invalid | One or more IDs are not valid ObjectIds. |
| 500 | Error message | Transaction failure. |
Export contacts
POST /contacts/export
Export contacts as CSV. Accepts the same filter body as filter contacts (without the filter wrapper). Returns a text/csv stream.
Request body
| Field | Type | Notes |
|---|---|---|
fields | object | Custom field filter criteria. |
name | string | Regex match against full name. |
search | string | Multi-field search. |
archived | boolean | Filter by archived status. |
ids | string[] | Filter to specific contact IDs. |
Example request
Response
Returns a text/csv file stream with a Content-Disposition header. CSV columns, in order: firstName, lastName, email, phone, twitterHandle, birthDate (MM/DD), organization, address, tags, custom field labels, createdAt, updatedAt. Read columns by header name: custom field columns vary per org, and new contact columns may be added.
Get contact stats
GET /contacts/:id/stats
Returns key stats for a contact: transaction data, response metrics, and last activity.
Path parameters
| Parameter | Type | Description |
|---|---|---|
id | string | Contact ID. |
Example request
Response
The response fields cover the last 90 days. In one conversation (one team member on one channel), messages sent to the contact less than 24 hours apart are one outreach; it is answered when the contact replies in that conversation within 3 days. Personal texts, emails not sent from a connected inbox (their replies are not recorded), automated messages and failed sends are not included, and messages the contact sends before being contacted are not answers.
responseRate: answered outreaches over counted ones, from 0 to 1. An outreach from the last 3 days is counted once answered or once 3 days have passed.nullwith fewer than 3 outreaches.responseAttempts: how many outreachesresponseRateis based on.medianResponseTime: median milliseconds from one of the team's messages to the contact's next reply, counting replies within 3 days.nullwhen there are none.averageResponseTime: deprecated; the same value asmedianResponseTime.lastActivity: when the contact's latest counted engagement event happened, of any type, including the messages your team sent and opt-outs, and not limited to the last 90 days. Failed deliveries do not move it.
Engagement counts (the contact's Key Statistics) and lastActivity leave out link-preview bots, security-scanner fetches, keyword replies (opt-out and opt-in keywords such as STOP and START, plus HELP and INFO), email auto-replies and automatic messages (opt-out and opt-in confirmations, out-of-office replies, form auto-responses). History is recounted per org with the same rules, so after an org's recount these counts can be lower, and lastActivity earlier, than before.
Errors
| Status | Message | Cause |
|---|---|---|
| 404 | Contact not found | No contact with that ID in your org. |
Get shared media
GET /contacts/:id/shared-media
Get media shared with a contact. Uses cursor-based pagination.
Path parameters
| Parameter | Type | Description |
|---|---|---|
id | string | Contact ID. |
Query parameters
| Parameter | Type | Default | Notes |
|---|---|---|---|
cursor | string | null | Cursor from a previous response for the next page. |
limit | number | 50 | 1–100. |
type | string | all | One of: all, direct, bulk. |
Example request
Response
Uses cursor-based pagination. Pass the nextCursor value as the cursor query parameter to fetch the next page.
Errors
| Status | Message | Cause |
|---|---|---|
| 404 | Contact not found | Contact ID does not exist in your org. |
Pagination patterns
Contacts uses two pagination patterns:
- Offset-based (
GET /contacts,POST /contacts/filter): Usespage(0-indexed) andperPagequery parameters. Response includes apaginationobject withpage,perPage,totalItems,totalPages. - Cursor-based (
GET /contacts/:id/shared-media): Usescursorandlimitquery parameters. Response includes acursorPaginationobject withnextCursorandhasMore.
Response envelope
All endpoints use the standard envelope:
Error responses:
data is null for a general failure, such as a database error or an upstream timeout. When the request is rejected because of a specific field, data carries the field(s) at fault instead:
Each issue carries a path (the dotted path into the request body) and a message. A label is added when the path is not self-describing, which today means custom fields only, for example "path": "fields.68b0a1f39c2e4d0012ab77e5", "label": "Class Year". issues usually holds a single entry, because contact validation stops at the first failure, but a body that fails schema validation can carry one entry per rejected field. Treat it as an array.