Managing contacts
Contacts are the people your agents call. This guide shows how to create and update contacts keyed by phone number, store custom fields that agents can use as variables, and keep contacts in sync with your own records.
Contacts matter for three reasons: campaign recipients are always contacts, a contact's name and custom fields become call variables automatically, and consent is recorded on the contact.
Before you begin
- A key with
contacts:readandcontacts:write. - Custom fields, if you use them, defined in the console. The API writes values for existing fields; it does not create field definitions.
Phone numbers are unique within a workspace and mode: a test-mode contact and a live contact with the same number are two separate contacts, and test-mode contacts are never visible to live keys.
Upsert by phone number
POST/contacts/upsert creates the contact with this phone number, or updates it if one exists, in one request. It answers 201 when it created a contact and 200 when it updated one. This is the recommended way to sync contacts from your own system: you never need to look a contact up first, and repeating the same upsert is harmless.
Complete runnable example
Create or update the contact with this phone number.
The same steps as a complete script, with error handling, tested against test mode on every docs build. Each script reads your test key from an environment variable named in its first lines.
- cURL script (bash)
upsert-contact.sh - JavaScript (Node.js 20+)
upsert-contact.mjs - Python 3.9+ (requests)
upsert-contact.py
The response is the contact, with an ETag header:
JSON
How upsert treats each field:
| Field | On upsert |
|---|---|
phone_number | Identifies the contact. Matched the same way the do-not-call check matches numbers (by digits, and by the last ten digits), so a contact entered in the console without + and country code is found too. Send E.164. |
name, language, country_code | Set when sent; null clears; omitted leaves the existing value |
consent_granted | true records that consent was given, false records a withdrawal, omitted leaves it unchanged. Send it only as your records really say. |
custom_fields | Merged: the names you send are set (null clears one), others are left unchanged |
do_not_call | Read-only here. Use the do-not-call endpoints. |
To create a contact only when none exists, use POST/contacts; a duplicate number is contact_already_exists with the existing contact's id in details.contact_id.
Custom fields
Custom fields hold the per-person facts an agent needs, for example a policy number or a due date. Define the fields in the console, then send values by field name:
JSON
- Values are strings up to 500 characters; up to 100 fields per request. Format dates and numbers the way you want the agent to read them.
- An unknown field name is refused with
custom_field_not_found(details.fieldnames it). Nothing is half-written. - When a call's
tomatches a contact, the contact's name and custom fields are added to the call'svariablesautomatically, and an agent can use{{policy_number}}in its instructions. Values you send invariableson the call win.
Updating safely
Change one contact with PATCH/contacts/{contact_id}. Contacts use optimistic concurrency: read the contact, keep its ETag header, and send it back in If-Match. If the contact changed in between (an upsert from another job, an edit in the console), the update is refused with version_conflict (412) instead of silently overwriting it. Read it again, decide again, and retry. A PATCH without If-Match is precondition_required (428). PATCH does not need an Idempotency-Key. See Concurrency.
Read the contact; the response carries its version in the ETag header (for example "c3f1a9e2b7d4"):
Send the change with that value in If-Match:
200 returns the updated contact and its new ETag. 412 version_conflict means it changed after your read: read it again, decide again, and send the new ETag.
Complete runnable example
Change a contact safely: read its ETag, send it in If-Match, and re-read on a conflict.
The same steps as a complete script, with error handling, tested against test mode on every docs build. Each script reads your test key from an environment variable named in its first lines.
- cURL script (bash)
update-contact.sh - JavaScript (Node.js 20+)
update-contact.mjs - Python 3.9+ (requests)
update-contact.py
Changing phone_number to a number another contact has is contact_already_exists.
To delete a contact use DEL/contacts/{contact_id}. It permanently deletes the contact, its custom field values and its consent history, and removes it from campaign recipient lists (a campaign that has already started keeps calling the recipients its run began with). Its calls stay in the call list. A contact on the do-not-call list cannot be deleted (contact_on_dnc_list), so a deletion never removes a do-not-call protection.
Listing and filtering
GET/contacts lists contacts newest first, with cursor pagination. Filters: phone_number (matched like upsert), consent_granted, do_not_call, created_after, created_before. Pass limit (1 to 100) and, for each next page, cursor set to the previous next_cursor with the same filters: a cursor used with different filters is invalid_cursor.
Response 200:
JSON
has_more is true, so ask for the next page: the same request with the same filters, plus cursor set to next_cursor. Repeat until has_more is false:
Complete runnable example
Read every contact created after a date, page by page.
The same steps as a complete script, with error handling, tested against test mode on every docs build. Each script reads your test key from an environment variable named in its first lines.
- cURL script (bash)
list-contacts.sh - JavaScript (Node.js 20+)
list-contacts.mjs - Python 3.9+ (requests)
list-contacts.py
Some contacts created in the console before creation times were recorded have created_at: null; they are listed after the contacts that have one.
Keeping contacts in sync
A reliable sync from your system:
- Upsert on every change in your system (create, edit, consent change), with your record's phone number as the key.
- Send consent and do-not-call changes in the same job, from your records (see Consent and DNC).
- Pace bulk syncs to the
writerate limit (120 requests per minute per key, 240 per workspace) and retry429afterRetry-After. The nightly batch pattern shows a paced loop. - Keep the
ct_...id next to your record for later updates and campaign recipients.
What can go wrong
invalid_phone_number(400): The number is not E.164. Send+, country code and number, no spaces.validation_error(400): A field is unknown or too long. Checkdetails.errors.custom_field_not_found(422): A custom field name is not defined. Define it in the console or fix the name.contact_already_exists(409): Create or number change to an existing number. Upsert, or updatedetails.contact_id.version_conflict(412): The contact changed since you read it. Read again and reapply.contact_on_dnc_list(409): Deleting a contact on the do-not-call list. Keep it, or remove the entry first if it really was a mistake.contact_limit_reached(409): The contact store is at its current capacity. Delete contacts you no longer need, or contact support.