Contacts
Contacts are the people you interact with across all channels. Each contact can have custom attributes, multiple identities, consent preferences, tags, and a score.
List Contacts
GET /api/workspaces/{workspaceId}/contacts
Paginated list of contacts with optional filtering.
Auth: Session required
Query Parameters:
| Param | Type | Default | Description |
|---|---|---|---|
search | string | — | Full-text search across name, email |
tagId | string | — | Filter by tag ID |
segmentId | string | — | Filter by segment membership |
companyId | string | — | Filter by company |
locale | string | — | Filter by locale |
country, region, city | string | — | Filter by location (exact match); the dashboard sets these when you click a country or city on the map |
anonymous | string | — | true or false |
automationPaused | string | — | true or false |
page | number | 1 | Page number |
limit | number | 20 | Items per page (max 100) |
sortBy | string | createdAt | createdAt, updatedAt, score or email |
sortOrder | string | desc | asc or desc |
Response:
{
"data": [
{
"id": "ct_abc123",
"email": "jane@example.com",
"firstName": "Jane",
"lastName": "Smith",
"score": 42,
"attributes": { "plan": "pro" },
"tags": [{ "id": "tag_1", "name": "VIP" }],
"createdAt": "2026-01-15T10:00:00.000Z"
}
],
"pagination": {
"page": 1,
"limit": 50,
"total": 1234,
"totalPages": 25
}
}Create Contact
POST /api/workspaces/{workspaceId}/contacts
Auth: Session required
Request Body:
{
"email": "jane@example.com",
"phone": "+1234567890",
"firstName": "Jane",
"lastName": "Smith",
"externalId": "ext_123",
"attributes": { "plan": "pro", "company": "Acme" },
"tags": ["VIP", "Enterprise"]
}| Field | Type | Required | Description |
|---|---|---|---|
email | string | No | Contact email address |
phone | string | No | Contact phone number |
firstName | string | No | First name |
lastName | string | No | Last name |
externalId | string | No | Your system’s unique ID |
locale, language, country | string | No | See Language and country |
region, city, timezone | string | No | Where the person is; timezone must be an IANA name such as Europe/Rome |
latitude, longitude | number | No | Sent together, or not at all |
attributes | object | No | Custom key-value attributes. position is shown as the contact’s job title in the dashboard. |
tags | string[] | No | Tag names (created if new) |
Response (201): Contact object with tags and consents. A contact.created event is logged on the contact’s timeline.
Get Contact
GET /api/workspaces/{workspaceId}/contacts/{contactId}
Returns the full contact profile including identities, consents, tags, recent events (50), recent messages (50), active segment memberships, and push subscriptions.
Auth: Session required
Update Contact
PATCH /api/workspaces/{workspaceId}/contacts/{contactId}
Partially updates a contact’s profile fields.
Auth: Session required
Request Body: Any subset of email, phone, firstName, lastName, externalId, locale, language, country, region, city, timezone, latitude, longitude, attributes, tags, ownerId, companyId, anonymous, automationPaused. Send null to clear a field; an attribute set to null is removed.
Every field that actually changed is written to the contact’s timeline as a contact.updated event whose metadata.changes lists { field, from, to } for each one (profile fields, attributes.<key> and tags). An update that changes nothing logs nothing. The dashboard’s Edit contact form uses this endpoint; the main fields are shown first and More fields reveals language, country, city, region, time zone, coordinates and external ID, followed by the workspace’s defined custom fields and free attributes.
Delete Contact
DELETE /api/workspaces/{workspaceId}/contacts/{contactId}
Permanently deletes a contact and all associated data.
Auth: Session required
Ingest (one call per event from your app)
POST /api/workspaces/{workspaceId}/contacts/ingest
Creates or updates a contact, records consent, joins static segments, starts the automations they trigger and logs an event, all from one request. This is the endpoint to call from your own product or from an automation tool such as n8n. It is described in full in Connect your app.
Import Contacts (Bulk)
POST /api/workspaces/{workspaceId}/contacts/import
Bulk import contacts with deduplication.
Auth: Session required
Request Body:
{
"contacts": [
{
"email": "jane@example.com",
"firstName": "Jane",
"lastName": "Smith",
"tags": ["imported"]
},
{
"email": "john@example.com",
"firstName": "John"
}
],
"deduplicateBy": "email",
"updateExisting": true
}| Field | Type | Required | Description |
|---|---|---|---|
contacts | array | Yes | Array of contact objects |
deduplicateBy | string | Yes | Field to deduplicate on (e.g., email) |
updateExisting | boolean | Yes | Whether to update existing contacts |
Response:
{
"total": 100,
"created": 85,
"updated": 10,
"skipped": 5,
"errors": [
{ "index": 23, "error": "Invalid email format" }
]
}Merge Contacts
POST /api/workspaces/{workspaceId}/contacts/merge
Merges two contacts — the source contact’s data is transferred to the target, and the source is deleted.
Auth: Session required
Request Body:
{
"sourceContactId": "ct_source",
"targetContactId": "ct_target"
}Transfers: events, messages, enrollments, segment memberships, inbox threads, identities, tags, and attributes.
Contact Timeline
GET /api/workspaces/{workspaceId}/contacts/{contactId}/timeline
Returns a unified reverse-chronological activity timeline.
Query Parameters:
| Param | Type | Default | Description |
|---|---|---|---|
limit | number | 50 | Max 200 |
cursor | string | — | Pagination cursor |
Response:
{
"timeline": [
{
"id": "evt_123",
"type": "page_view",
"category": "event",
"timestamp": "2026-01-15T10:00:00.000Z",
"data": { "pageUrl": "/pricing" }
},
{
"id": "msg_456",
"type": "SENT",
"category": "message",
"timestamp": "2026-01-15T09:30:00.000Z",
"data": { "channel": "EMAIL", "subject": "Welcome!" }
}
],
"hasMore": true
}Consent Management
GET /api/workspaces/{workspaceId}/contacts/{contactId}/consent
Lists all consent records for a contact.
PUT /api/workspaces/{workspaceId}/contacts/{contactId}/consent
Creates or updates consent status for a specific channel.
Request Body:
{
"channel": "EMAIL",
"status": "OPTED_IN",
"source": "preference-center"
}| Channel | Values |
|---|---|
EMAIL | OPTED_IN, OPTED_OUT, PENDING, DOUBLE_OPT_IN_PENDING |
PUSH | Same as above |
TELEGRAM | Same as above |
ONSITE | Same as above |
SMS | Same as above |
Data Export (DSR)
GET /api/workspaces/{workspaceId}/contacts/{contactId}/export
“Export My Data” — Returns all data associated with a contact (identities, consents, tags, events, messages, segments, enrollments, push subscriptions, threads).
POST /api/workspaces/{workspaceId}/contacts/{contactId}/export
“Forget Me” — Anonymizes the contact by nulling PII, deleting identities and push subscriptions. Requires ADMIN role. Logs an audit event.
Response:
{
"success": true,
"message": "Contact data has been anonymized"
}Language and country
A contact carries a locale (a BCP-47 tag such as en, en-CA or pt-BR) and a 2-letter country.
Any endpoint that creates or updates contacts accepts the pieces in whatever shape you have them, and stores them normalised (lowercase language, uppercase region):
| You send | Stored locale | Stored country |
|---|---|---|
{ "locale": "en_ca" } | en-CA | CA |
{ "language": "EN", "country": "ca" } | en-CA | CA |
{ "locale": "pt-BR" } | pt-BR | BR |
{ "country": "IT" } | unchanged | IT |
{ "locale": "fr", "country": "BE" } | fr-BE | BE |
This works on POST /contacts, PATCH /contacts/{id}, the import endpoint, form submissions (locale, language or country fields), the inbound webhook and the tracking script, which derives both from the browser language. A PATCH that sends only one part keeps the other.
Any real language is accepted, not a fixed list: the language must be an ISO 639 code and the region, when present, an ISO 3166 country (pt, pt-BR, sw-KE, zh-Hant-TW). A tag that is not one (xx, pt-ZZ, Portuguese) is rejected with 422 and a message saying which part is wrong. The same rule applies to a template’s default language and translations and to the workspace default language; the dashboard offers suggestions while you type but takes any valid tag.
Segment rules can filter on locale (the full tag), language (the tag without the region) and country.
Existing rows can be normalised with pnpm contacts:normalize-locales --apply.
A region the person chose with their language wins over the country they are in: a contact sent as { "locale": "pt-BR", "country": "MT" } keeps pt-BR and gets country MT. The country only completes a bare language, so { "locale": "es", "country": "MX" } reads Mexican Spanish. The sender applies the same rule when it picks a template language.
Location
Besides country, a contact has region, city, latitude, longitude and timezone. They are set by the ingest endpoint and the contact update endpoint, shown on the contact page, counted by GET …/contacts/geo and drawn on the contacts map. Coordinates are stored only as a pair; a value that cannot be used is dropped rather than failing the request.