# Managing contacts

**Availability: 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`](/api/contacts#create-or-update-a-contact-by-phone-number) 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.

```bash
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"
  }
}'
```
```js
const response = await fetch("https://voice-api.relationsai.com/v1/contacts/upsert", {
  method: "POST",
  headers: {
    "Authorization": "Bearer <token>",
    "Content-Type": "application/json",
  },
  body: JSON.stringify({
    "phone_number": "+12025550100",
    "name": "Asha Rao",
    "language": "en",
    "custom_fields": {
      "policy_number": "PN-55012"
    }
  }),
});
const contact = await response.json();
```
```python

response = requests.post(
    "https://voice-api.relationsai.com/v1/contacts/upsert",
    headers={
        "Authorization": "Bearer <token>",
    },
    json={
        "phone_number": "+12025550100",
        "name": "Asha Rao",
        "language": "en",
        "custom_fields": {
            "policy_number": "PN-55012",
        },
    },
    timeout=30,
)
contact = response.json()
```

Complete runnable example: [cURL script](/examples/guides/managing-contacts/upsert-contact.sh) · [JavaScript](/examples/guides/managing-contacts/upsert-contact.mjs) · [Python](/examples/guides/managing-contacts/upsert-contact.py)

The response is the contact, with an `ETag` header:

```json
{
  "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:

| 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](/guides/consent-and-dnc). |

To create a contact only when none exists, use [`POST /contacts`](/api/contacts#create-a-contact); a duplicate number is [`contact_already_exists`](/errors/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
{
  "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`](/errors/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}`](/api/contacts#update-a-contact). 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`](/errors/version_conflict) (412) instead of silently overwriting it. Read it again, decide again, and retry. A PATCH without `If-Match` is [`precondition_required`](/errors/precondition_required) (428). PATCH does not need an Idempotency-Key. See [Concurrency](/fundamentals/concurrency).

Read the contact; the response carries its version in the `ETag` header (for example `"c3f1a9e2b7d4"`):

```bash
curl -X GET https://voice-api.relationsai.com/v1/contacts/ct_8Rw2Tq5Lm9Xc3Vb7Nz4Kp1 \
     -H "Authorization: Bearer <token>"
```
```js
const response = await fetch("https://voice-api.relationsai.com/v1/contacts/ct_8Rw2Tq5Lm9Xc3Vb7Nz4Kp1", {
  method: "GET",
  headers: {
    "Authorization": "Bearer <token>",
  },
});
const contact = await response.json();
```
```python

response = requests.get(
    "https://voice-api.relationsai.com/v1/contacts/ct_8Rw2Tq5Lm9Xc3Vb7Nz4Kp1",
    headers={
        "Authorization": "Bearer <token>",
    },
    timeout=30,
)
contact = response.json()
```

Send the change with that value in `If-Match`:

```bash
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"
}'
```
```js
const response = await fetch("https://voice-api.relationsai.com/v1/contacts/ct_8Rw2Tq5Lm9Xc3Vb7Nz4Kp1", {
  method: "PATCH",
  headers: {
    "Authorization": "Bearer <token>",
    "If-Match": "<etag>",
    "Content-Type": "application/json",
  },
  body: JSON.stringify({
    "language": "hi"
  }),
});
const contact = await response.json();
```
```python

response = requests.patch(
    "https://voice-api.relationsai.com/v1/contacts/ct_8Rw2Tq5Lm9Xc3Vb7Nz4Kp1",
    headers={
        "Authorization": "Bearer <token>",
        "If-Match": "<etag>",
    },
    json={
        "language": "hi",
    },
    timeout=30,
)
contact = response.json()
```

`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: [cURL script](/examples/guides/managing-contacts/update-contact.sh) · [JavaScript](/examples/guides/managing-contacts/update-contact.mjs) · [Python](/examples/guides/managing-contacts/update-contact.py)

Changing `phone_number` to a number another contact has is [`contact_already_exists`](/errors/contact_already_exists).

To delete a contact use [`DELETE /contacts/{contact_id}`](/api/contacts#delete-a-contact). 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`](/errors/contact_on_dnc_list)), so a deletion never removes a do-not-call protection.

## Listing and filtering

[`GET /contacts`](/api/contacts#list-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`](/errors/invalid_cursor).

```bash
curl -X GET "https://voice-api.relationsai.com/v1/contacts?limit=100&created_after=2026-10-01T00:00:00.000Z" \
     -H "Authorization: Bearer <token>"
```
```js
const response = await fetch("https://voice-api.relationsai.com/v1/contacts?limit=100&created_after=2026-10-01T00:00:00.000Z", {
  method: "GET",
  headers: {
    "Authorization": "Bearer <token>",
  },
});
const page = await response.json();
```
```python

response = requests.get(
    "https://voice-api.relationsai.com/v1/contacts",
    headers={
        "Authorization": "Bearer <token>",
    },
    params={
        "limit": 100,
        "created_after": "2026-10-01T00:00:00.000Z",
    },
    timeout=30,
)
page = response.json()
```

Response `200`:

```json
{
  "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`:

```bash
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>"
```
```js
const response = await fetch("https://voice-api.relationsai.com/v1/contacts?limit=100&created_after=2026-10-01T00:00:00.000Z&cursor=eyJ2IjoxLCJrIjoiY3RfOFJ3MlRxNUxtOVhjM1ZiN056NEtwMSJ9.9c1e4b", {
  method: "GET",
  headers: {
    "Authorization": "Bearer <token>",
  },
});
const page = await response.json();
```
```python

response = requests.get(
    "https://voice-api.relationsai.com/v1/contacts",
    headers={
        "Authorization": "Bearer <token>",
    },
    params={
        "limit": 100,
        "created_after": "2026-10-01T00:00:00.000Z",
        "cursor": "eyJ2IjoxLCJrIjoiY3RfOFJ3MlRxNUxtOVhjM1ZiN056NEtwMSJ9.9c1e4b",
    },
    timeout=30,
)
page = response.json()
```

Complete runnable example: [cURL script](/examples/guides/managing-contacts/list-contacts.sh) · [JavaScript](/examples/guides/managing-contacts/list-contacts.mjs) · [Python](/examples/guides/managing-contacts/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:

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](/guides/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](/guides/common-integration-patterns) 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`](/errors/invalid_phone_number) (400): The number is not E.164. Send `+`, country code and number, no spaces.
- [`validation_error`](/errors/validation_error) (400): A field is unknown or too long. Check `details.errors`.
- [`custom_field_not_found`](/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`](/errors/contact_already_exists) (409): Create or number change to an existing number. Upsert, or update `details.contact_id`.
- [`version_conflict`](/errors/version_conflict) (412): The contact changed since you read it. Read again and reapply.
- [`contact_on_dnc_list`](/errors/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`](/errors/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](/guides/consent-and-dnc).
