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

FieldTypeRequiredNotes
firstNamestringNo
lastNamestringNo
emailstringConditionalUnique per org. Required if phone and twitterHandle are absent.
phonestringConditionalE.164 format. Unique per org. Required if email and twitterHandle are absent.
twitterHandlestringConditionalUnique 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.
organizationstringNo
websitestringNo
addressstringNo
birthDatestringNoMM/DD format.
profileImgstringNoURL to profile image.
tagsstring[]NoTrimmed, lowercased, deduplicated.
fieldsobjectNoCustom field values keyed by field ID. See Custom field values.
preferredCommunicationChannelstringNoOne of: email, personal-sms, stacked-sms, web-chat, twitter.
notesstringNo

Custom field values

fields is keyed by custom field ID. The accepted value depends on the field's type:

TypeValue
textstring
numbernumber, or a numeric string
datetimestamp in milliseconds, or a date string
useruser ID
selectoption ID or option label
multi-selectarray 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.

{
  "fields": {
    "665a1f2b3c4d5e6f7a8b9c0d": "Senior",
    "665a1f2b3c4d5e6f7a8b9c0e": ["Football", "Track and Field"]
  }
}

Example request

curl -X POST https://<host>/api/contacts \
  -H "X-API-Key: your-api-key" \
  -H "Content-Type: application/json" \
  -d '{
    "firstName": "Jane",
    "lastName": "Doe",
    "email": "jane@example.com",
    "tags": ["vip", "alumni"]
  }'

Response

{
  "error": false,
  "success": true,
  "message": "Contact created successfully",
  "code": 200,
  "data": {
    "_id": "664a...",
    "firstName": "Jane",
    "lastName": "Doe",
    "email": "jane@example.com",
    "tags": ["vip", "alumni"],
    "archived": false,
    "createdAt": 1716000000000
  }
}

Errors

StatusMessageCause
400Contact must have a phone number, an email address, or a twitter handleNone of email, phone, or twitterHandle provided.
400email: Contact with the given email already exists.Duplicate email within the org.
400phone: Contact with the given phone already exists.Duplicate phone within the org.
400twitterHandle: Contact with the given twitter handle already exists.Duplicate twitterHandle within the org.
400phone: 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.
400phone: 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.
400Class 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.
400fields.<id>: No custom field exists with this idA 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:

  1. externalId, when sent together with source. This is the most reliable key because it keeps working after the contact's email or phone changes.
  2. email or twitterHandle, both compared case-insensitively. The handle is never required: a record still needs an email or a phone.
  3. 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:

FieldTypeRequiredNotes
sourcestringOnly with externalIdStable identifier for the system calling the API, for example my-crm.
externalIdstringNoYour own immutable ID for this record. Stored on first use and reused on later calls.
onInvalidFieldstringNowarn (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

curl -X POST https://<host>/api/contacts/upsert \
  -H "X-API-Key: your-api-key" \
  -H "Content-Type: application/json" \
  -d '{
    "source": "my-crm",
    "externalId": "1234",
    "firstName": "Jane",
    "lastName": "Doe",
    "email": "jane@example.com",
    "phone": "+19795550134",
    "tags": ["football"]
  }'

Response

201 when the contact was created, 200 when it was updated. The code field carries the same information.

{
  "error": false,
  "success": true,
  "message": "Contact created successfully",
  "code": "created",
  "warnings": [],
  "data": {
    "_id": "664a...",
    "firstName": "Jane",
    "lastName": "Doe",
    "email": "jane@example.com",
    "tags": ["football"],
    "archived": false
  }
}

warnings is always present on 200 and 201. Each entry names a field that was left out because its value was malformed:

"warnings": [
  { "field": "twitterHandle", "message": "Invalid twitter handle" }
]

field is one of twitterHandle, email or phone. Phone messages mask the middle of the number.

Errors

StatusMessageCodeCause
400email: Contact must have either an email or a phone numberNeither identity field was provided. The message names email regardless of which field is missing.
400phone: 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.
400phone: 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.
400email must be a valid emailOnly when email was the only identity sent and it is malformed. Otherwise the email is left out and reported in warnings.
400twitterHandle: 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).
400The first malformed field's error, as in the rows aboveonInvalidField 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.
400source: `source` is required when `externalId` is providedexternalId sent on its own.
400onInvalidField: Invalid option: expected one of "warn"|"reject"onInvalidField is not warn or reject.
400Class 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.
400fields.<id>: No custom field exists with this idA 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.
409More than one contact matches...ambiguous-match/multiple-contactsTwo or more contacts matched. data.candidateContactIds lists them; pick one and use PATCH /contacts/:id.
409A contact matches this phone number...ambiguous-match/phone-onlyOnly 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.
409The contact this externalId is linked to would get a ...external-id-conflictThe 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

ParameterTypeDefaultNotes
pagenumber00-indexed offset pagination.
perPagenumber50Items per page.

Example request

curl https://<host>/api/contacts?page=0&perPage=25 \
  -H "X-API-Key: your-api-key"

Response

{
  "success": true,
  "data": [...],
  "pagination": {
    "page": 0,
    "perPage": 25,
    "totalItems": 150,
    "totalPages": 6
  }
}

Uses offset-based pagination. The pagination object is included in the response.


Get a contact

GET /contacts/:id

Path parameters

ParameterTypeDescription
idstringContact ID (MongoDB ObjectId).

Example request

curl https://<host>/api/contacts/664a... \
  -H "X-API-Key: your-api-key"

Response

{
  "success": true,
  "data": {
    "_id": "664a...",
    "firstName": "Jane",
    "lastName": "Doe",
    "email": "jane@example.com",
    "tags": ["vip"],
    "archived": false
  }
}

Errors

StatusMessageCause
404Contact 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

ParameterTypeDescription
idstringContact ID.

Example request

curl -X PATCH https://<host>/api/contacts/664a... \
  -H "X-API-Key: your-api-key" \
  -H "Content-Type: application/json" \
  -d '{ "organization": "Acme Inc" }'

Response

Standard success envelope with the updated contact in data.

Errors

StatusMessageCause
404Contact does not exist.No contact with that ID in your org.
400Validation error messageInvalid 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

ParameterTypeDescription
idstringContact ID.

Example request

curl -X DELETE https://<host>/api/contacts/664a... \
  -H "X-API-Key: your-api-key"

Response

{
  "error": false,
  "success": true,
  "message": "Delete contact task dispatched, contact will be deleted soon.",
  "code": 200,
  "data": null
}

Errors

StatusMessageCause
404Contact 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

FieldTypeNotes
fieldsobjectCustom field filter criteria.
namestringRegex match against full name (firstName + lastName).
searchstringMulti-field search across name, email, twitterHandle, and phone.
archivedbooleanFilter by archived status.

Example request

curl -X POST https://<host>/api/contacts/countTotal \
  -H "X-API-Key: your-api-key" \
  -H "Content-Type: application/json" \
  -d '{ "search": "jane" }'

Response

{
  "error": false,
  "success": true,
  "message": "Success",
  "code": 200,
  "data": 42
}

The data field contains the count as a number.


Filter contacts

POST /contacts/filter

Advanced filter and search with sorting and pagination.

Query parameters

ParameterTypeDefaultNotes
sortstringfirstName:asc,lastName:ascComma-separated field:direction pairs. Allowed fields: firstName, lastName, email, phone, organization, address, createdAt, updatedAt, birthDate, twitterHandle, tags, plus custom field IDs (fields.<id>).
pagenumber00-indexed.
perPagenumber50Max 1000.

Request body

Wrap filter criteria in a filter object:

FieldTypeNotes
filter.fieldsobjectCustom field filter criteria.
filter.namestringRegex match against full name.
filter.searchstringMulti-field search: name, email, twitterHandle, phone.
filter.archivedbooleanFilter by archived status.
filter.idsstring[]Filter to specific contact IDs.

Example request

curl -X POST https://<host>/api/contacts/filter?page=0&perPage=100 \
  -H "X-API-Key: your-api-key" \
  -H "Content-Type: application/json" \
  -d '{ "filter": { "search": "jane", "archived": false } }'

Response

{
  "error": false,
  "success": true,
  "message": "Success",
  "code": 200,
  "data": [...],
  "pagination": {
    "page": 0,
    "perPage": 100,
    "totalItems": 3,
    "totalPages": -1
  }
}

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

FieldTypeNotes
contactIdsstring[]List of contact IDs to update.
filterobjectAlternative to contactIds — same filter format as filter contacts.
updateobjectFields to update.
update.archivedbooleanSet archived status.
update.tagsstring[]Replace tags.
update.fieldsobjectSet custom field values.

Provide either contactIds or filter to select contacts, plus update with the changes.

Example request

curl -X POST https://<host>/api/contacts/bulk-edit \
  -H "X-API-Key: your-api-key" \
  -H "Content-Type: application/json" \
  -d '{
    "contactIds": ["664a...", "664b..."],
    "update": { "tags": ["vip"], "archived": false }
  }'

Response

Standard success envelope confirming the bulk update.

Errors

StatusMessageCause
400Contact Id invalidOne or more IDs are not valid ObjectIds.
500Error messageTransaction 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

FieldTypeNotes
fieldsobjectCustom field filter criteria.
namestringRegex match against full name.
searchstringMulti-field search.
archivedbooleanFilter by archived status.
idsstring[]Filter to specific contact IDs.

Example request

curl -X POST https://<host>/api/contacts/export \
  -H "X-API-Key: your-api-key" \
  -H "Content-Type: application/json" \
  -d '{ "search": "doe" }' \
  -o contacts.csv

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

ParameterTypeDescription
idstringContact ID.

Example request

curl https://<host>/api/contacts/664a.../stats \
  -H "X-API-Key: your-api-key"

Response

{
  "error": false,
  "success": true,
  "message": "Success",
  "code": 200,
  "data": {
    "totalTransactionsCount": 5,
    "totalTransactionsAmount": 2500,
    "lastTransactionDate": "2026-01-15T00:00:00.000Z",
    "lastTransactionAmount": 500,
    "averageResponseTime": 3600000,
    "responseRate": 0.85,
    "responseAttempts": 20,
    "medianResponseTime": 3600000,
    "lastActivity": "2026-03-20T14:30:00.000Z"
  }
}

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. null with fewer than 3 outreaches.
  • responseAttempts: how many outreaches responseRate is based on.
  • medianResponseTime: median milliseconds from one of the team's messages to the contact's next reply, counting replies within 3 days. null when there are none.
  • averageResponseTime: deprecated; the same value as medianResponseTime.
  • 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

StatusMessageCause
404Contact not foundNo 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

ParameterTypeDescription
idstringContact ID.

Query parameters

ParameterTypeDefaultNotes
cursorstringnullCursor from a previous response for the next page.
limitnumber501–100.
typestringallOne of: all, direct, bulk.

Example request

curl "https://<host>/api/contacts/664a.../shared-media?limit=25&type=direct" \
  -H "X-API-Key: your-api-key"

Response

{
  "error": false,
  "success": true,
  "message": "Success",
  "code": 200,
  "data": [...],
  "cursorPagination": {
    "nextCursor": "abc123...",
    "hasMore": true
  }
}

Uses cursor-based pagination. Pass the nextCursor value as the cursor query parameter to fetch the next page.

Errors

StatusMessageCause
404Contact not foundContact ID does not exist in your org.

Pagination patterns

Contacts uses two pagination patterns:

  • Offset-based (GET /contacts, POST /contacts/filter): Uses page (0-indexed) and perPage query parameters. Response includes a pagination object with page, perPage, totalItems, totalPages.
  • Cursor-based (GET /contacts/:id/shared-media): Uses cursor and limit query parameters. Response includes a cursorPagination object with nextCursor and hasMore.

Response envelope

All endpoints use the standard envelope:

{
  "error": false,
  "success": true,
  "message": "...",
  "code": 200,
  "data": "...",
  "pagination": {}
}

Error responses:

{
  "error": true,
  "success": false,
  "message": "...",
  "code": 400,
  "data": null
}

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:

{
  "error": true,
  "success": false,
  "message": "phone: Phone parse failed: The string supplied did not seem to be a phone number (input: <masked>)",
  "code": 400,
  "data": {
    "issues": [
      {
        "path": "phone",
        "message": "Phone parse failed: The string supplied did not seem to be a phone number (input: <masked>)"
      }
    ]
  }
}

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.

On this page