API v2 Contacts and Leads
List contacts and leads, upsert one contact by external ID, and bulk import contacts for an agent.
Contacts associate your own stable end-user IDs with profile fields and private custom attributes. Leads are read-only submissions captured by an agent. Every endpoint on this page requires Authorization: Bearer YOUR_API_KEY and access to {agentId}.
For signed identity claims in embedded chat, see Identity Verification. See the error catalog for shared authentication and validation errors.
Contact Object
| Field | Type | Notes |
|---|---|---|
id | string | Contact UUID. |
externalId | string | Your stable end-user ID. |
name | string or null | Contact name. |
email | string or null | Contact email address. |
phone | string or null | Contact phone number. |
customAttributes | object or null | Private JSON attributes stored on the contact. |
createdAt | integer or null | Unix timestamp in seconds. |
updatedAt | integer or null | Unix timestamp in seconds. |
lastSeenAt | integer or null | Unix timestamp in seconds. |
List Contacts
Path: /api/v2/agents/{agentId}/contacts
GET /api/v2/agents/{agentId}/contacts
Authentication: Bearer API key with access to {agentId}.
| Query | Required | Constraints |
|---|---|---|
limit | No | Integer from 1 to 100; defaults to 20. |
cursor | No | Opaque cursor returned by the preceding page. Replay it unchanged. |
search | No | Trimmed, case-insensitive substring search (ILIKE) across external ID, email, name, and phone. Percent signs (%) are stripped, but the underscore (_) is not escaped and acts as a single-character wildcard — e.g. acct_1 also matches acctX1. An empty result after trimming is ignored. |
curl 'https://your-domain.com/api/v2/agents/955f28f1-8515-40bb-802c-f3f730bf0343/contacts?search=alice&limit=20' \ -H 'Authorization: Bearer YOUR_API_KEY'
Success: 200 OK
{
"data": [
{
"id": "f9878f31-c2b3-469f-a82e-26e996e67721",
"externalId": "customer_123",
"name": "Alice Example",
"email": "[email protected]",
"phone": "+1 555 0100",
"customAttributes": { "plan": "pro", "seats": 4 },
"createdAt": 1784332800,
"updatedAt": 1784332800,
"lastSeenAt": null
}
],
"pagination": {
"cursor": null,
"hasMore": false,
"total": 1
}
}
Invalid limits or cursors return 400 VALIDATION_INVALID_BODY.
Lead Object
| Field | Type | Notes |
|---|---|---|
id | string | Lead UUID. |
name | string or null | Submitted name. |
email | string or null | Submitted email address. |
phone | string or null | Submitted phone number. |
conversationId | string or null | Public conversation reference ID when the lead is linked to a conversation. |
createdAt | integer | Unix timestamp in seconds. |
List Leads
Path: /api/v2/agents/{agentId}/leads
GET /api/v2/agents/{agentId}/leads
Authentication: Bearer API key with access to {agentId}.
| Query | Required | Constraints |
|---|---|---|
limit | No | Integer from 1 to 100; defaults to 20. |
cursor | No | Opaque cursor returned by the preceding page. Replay it unchanged. |
createdAfter | No | ISO 8601 datetime with Z or a UTC offset. The lower bound is inclusive. |
createdBefore | No | ISO 8601 datetime with Z or a UTC offset. The upper bound is inclusive. |
When both date filters are present, createdAfter must be less than or equal to createdBefore when compared as instants. Equal bounds are valid.
curl 'https://your-domain.com/api/v2/agents/955f28f1-8515-40bb-802c-f3f730bf0343/leads?createdAfter=2026-07-18T08:00:00Z&limit=20' \ -H 'Authorization: Bearer YOUR_API_KEY'
Success: 200 OK
{
"data": [
{
"id": "ad47f673-6bfb-43a0-a296-d79c84870a32",
"name": "Alice Example",
"email": "[email protected]",
"phone": "+1 555 0100",
"conversationId": "a1b2c3d4e5f6g7h8",
"createdAt": 1784361600
}
],
"pagination": {
"cursor": null,
"hasMore": false,
"total": 1
}
}
Leads are ordered by submission time from newest to oldest. Invalid limits, cursors, datetimes, or inverted date bounds return 400 VALIDATION_INVALID_BODY.
Create or Update One Contact
POST /api/v2/agents/{agentId}/contacts
Authentication: Bearer API key with access to {agentId}.
This endpoint upserts on the pair {agentId, externalId}. When a matching contact exists, only profile fields present in the request are updated. An omitted field preserves its stored value; an explicit null or empty string clears it.
| Body field | Required | Type and constraints |
|---|---|---|
externalId | Yes | String, trimmed, 1 to 255 characters. |
name | No | String trimmed to at most 255 characters, or null. |
email | No | Valid email address trimmed to at most 320 characters, an empty string, or null. |
phone | No | String trimmed to at most 50 characters, an empty string, or null. |
customAttributes | No | Object containing JSON values. Omit it to preserve stored attributes. |
curl -X POST 'https://your-domain.com/api/v2/agents/955f28f1-8515-40bb-802c-f3f730bf0343/contacts' \
-H 'Authorization: Bearer YOUR_API_KEY' \
-H 'Content-Type: application/json' \
-d '{"externalId":"customer_123","name":"Alice Example","email":"[email protected]","customAttributes":{"plan":"pro","seats":4}}'
Success: 201 Created for both inserts and conflict updates. The contact object is returned bare.
{
"id": "f9878f31-c2b3-469f-a82e-26e996e67721",
"externalId": "customer_123",
"name": "Alice Example",
"email": "[email protected]",
"phone": null,
"customAttributes": { "plan": "pro", "seats": 4 },
"createdAt": 1784332800,
"updatedAt": 1784332800,
"lastSeenAt": null
}
Invalid bodies return 400 VALIDATION_INVALID_BODY with validation details.
Import Contacts
Path: /api/v2/agents/{agentId}/contacts/import
POST /api/v2/agents/{agentId}/contacts/import
Authentication: Bearer API key with access to {agentId}.
The request uses snake_case field names. rows must contain 1 to 1,000 entries.
| Row field | Required | Type and constraints |
|---|---|---|
external_id | Yes | String, trimmed, 1 to 255 characters. |
name | No | String trimmed to at most 255 characters, or null. |
email | No | Valid email address trimmed to at most 320 characters, an empty string, or null. |
phone | No | String trimmed to at most 50 characters, or null; an empty string is also accepted and stored as null. |
customAttributes | No | Object containing JSON values. Omit it to preserve stored attributes for that row. |
Rows are deduplicated by external_id within the request; the last occurrence wins. Unlike the single-contact partial upsert, the import constructs every profile field for each retained row, so an omitted, null, or empty profile value is stored as null on conflict.
curl -X POST 'https://your-domain.com/api/v2/agents/955f28f1-8515-40bb-802c-f3f730bf0343/contacts/import' \
-H 'Authorization: Bearer YOUR_API_KEY' \
-H 'Content-Type: application/json' \
-d '{"rows":[{"external_id":"customer_123","name":"Alice Example","email":"[email protected]","phone":"+1 555 0100","customAttributes":{"plan":"pro"}},{"external_id":"customer_456","name":"Bob Example","email":"[email protected]","phone":null}]}'
Success: 200 OK
{
"processed": 2,
"skipped": 0
}
processed is the number of unique external IDs upserted. skipped is the number of duplicate rows removed from the submitted batch. Invalid bodies, including empty batches and batches over 1,000 rows, return 400 VALIDATION_INVALID_BODY.