Skip to content
RelationsAI
Guides

Managing contacts

Available

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:read and contacts: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.

curl -X POST https://voice-api.relationsai.com/v1/contacts/upsert \ -H "Authorization: Bearer <token>" \ -H "Content-Type: application/json" \ -d '{ "phone_number": "+12025550100", "name": "Asha Rao", "language": "en", "custom_fields": { "policy_number": "PN-55012" } }'
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.

The response is the contact, with an ETag header:

JSONJSON
{ "id": "ct_8Rw2Tq5Lm9Xc3Vb7Nz4Kp1", "object": "contact", "livemode": false, "phone_number": "+12025550100", "name": "Asha Rao", "language": "en", "country_code": "US", "consent_granted": true, "do_not_call": false, "custom_fields": { "policy_number": "PN-55012", "due_date": "2026-11-05" }, "created_at": "2026-10-21T08:30:00.000Z" }

How upsert treats each field:

FieldOn upsert
phone_numberIdentifies 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_codeSet when sent; null clears; omitted leaves the existing value
consent_grantedtrue records that consent was given, false records a withdrawal, omitted leaves it unchanged. Send it only as your records really say.
custom_fieldsMerged: the names you send are set (null clears one), others are left unchanged
do_not_callRead-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:

JSONJSON
{ "phone_number": "+12025550100", "custom_fields": { "policy_number": "PN-55012", "due_date": "2026-11-05" } }
  • 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.field names it). Nothing is half-written.
  • When a call's to matches a contact, the contact's name and custom fields are added to the call's variables automatically, and an agent can use {{policy_number}} in its instructions. Values you send in variables on 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"):

curl -X GET https://voice-api.relationsai.com/v1/contacts/ct_8Rw2Tq5Lm9Xc3Vb7Nz4Kp1 \ -H "Authorization: Bearer <token>"

Send the change with that value in If-Match:

curl -X PATCH https://voice-api.relationsai.com/v1/contacts/ct_8Rw2Tq5Lm9Xc3Vb7Nz4Kp1 \ -H "Authorization: Bearer <token>" \ -H 'If-Match: <etag>' \ -H "Content-Type: application/json" \ -d '{ "language": "hi" }'

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.

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.

curl -X GET "https://voice-api.relationsai.com/v1/contacts?limit=100&created_after=2026-10-01T00:00:00.000Z" \ -H "Authorization: Bearer <token>"

Response 200:

JSONJSON
{ "object": "list", "data": [ { "id": "ct_8Rw2Tq5Lm9Xc3Vb7Nz4Kp1", "object": "contact", "livemode": false, "phone_number": "+12025550100", "name": "Asha Rao", "language": "en", "country_code": "US", "consent_granted": true, "do_not_call": false, "custom_fields": { "policy_number": "PN-55012", "due_date": "2026-11-05" }, "created_at": "2026-10-21T08:30:00.000Z" } ], "has_more": true, "next_cursor": "eyJ2IjoxLCJrIjoiY3RfOFJ3MlRxNUxtOVhjM1ZiN056NEtwMSJ9.9c1e4b" }

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:

curl -X GET "https://voice-api.relationsai.com/v1/contacts?limit=100&created_after=2026-10-01T00:00:00.000Z&cursor=eyJ2IjoxLCJrIjoiY3RfOFJ3MlRxNUxtOVhjM1ZiN056NEtwMSJ9.9c1e4b" \ -H "Authorization: Bearer <token>"
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.

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:

  1. Upsert on every change in your system (create, edit, consent change), with your record's phone number as the key.
  2. Send consent and do-not-call changes in the same job, from your records (see Consent and DNC).
  3. Pace bulk syncs to the write rate limit (120 requests per minute per key, 240 per workspace) and retry 429 after Retry-After. The nightly batch pattern shows a paced loop.
  4. 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. Check details.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 update details.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.

Next steps

Consent and DNC.