> ## Documentation Index
> Fetch the complete documentation index at: https://docs.callers.ai/llms.txt
> Use this file to discover all available pages before exploring further.

# Manage contacts

> Add, update, list, filter, archive, and restore an agent's contacts with the Callers API, including validation rules and duplicate handling.

Contacts are the people an outbound agent reaches. Each contact belongs to one agent and holds a name, one or more phone numbers (or an email address for Email agents), an optional time zone, and a `data` object whose keys become [script variables](https://docs.callers.ai/documentation/build-your-agent/agent-variables.md). This guide covers every contact operation in the API and the rules behind them.

> [!NOTE]
>
> Contacts can only be added to outbound agents. Inbound agents reject contact uploads with `400 Not allowed to create contacts on incoming campaigns.`

## Contact object

When you add a contact, you send:

**name** `string` *required*

The contact's name. Available in scripts as `{{name}}`.

**phoneNumber** `string`

Primary phone number in international format, for example `+14155552671`. Required for Voice and Text agents unless you send `phoneNumbers`.

**phoneNumbers** `string[]`

Several numbers for the same person. The first valid number becomes the primary number; the agent tries the numbers in order. Takes precedence over `phoneNumber`.

**timezone** `string`

IANA time zone, such as `Europe/London`. If omitted, Callers detects it from the phone number. Used when the agent calls in the contact's time zone.

**data** `object` *required*

Key/value pairs of strings that become script variables. Send `{}` if you have none. For Email agents, include `email`.

**archived** `boolean` (default: false)

Create or update the contact as archived. Archived contacts aren't called.

When you read contacts, each one includes `id`, `name`, `phoneNumber`, `status`, `attempts`, `lastAttempt`, `createdAt`, `failedReason`, `failedReasonDetails`, and both `campaignId` and `agentId` (the same value).

## Add a single contact

`POST /agents/{agentId}/contacts`

```bash cURL theme={"dark"}
curl -X POST "https://api.callers.ai/agents/$AGENT_ID/contacts" \
  -H "Authorization: $CALLERS_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "name": "Dana Levi",
    "phoneNumber": "+14155552671",
    "data": { "first_name": "Dana", "company": "Acme" }
  }'
```

```javascript Node.js theme={"dark"}
const res = await fetch(`https://api.callers.ai/agents/${agentId}/contacts`, {
  method: "POST",
  headers: {
    Authorization: process.env.CALLERS_API_KEY,
    "Content-Type": "application/json",
  },
  body: JSON.stringify({
    name: "Dana Levi",
    phoneNumber: "+14155552671",
    data: { first_name: "Dana", company: "Acme" },
  }),
});
const { data } = await res.json(); // { contactId, created, modified }
```

```python Python theme={"dark"}
res = requests.post(
    f"https://api.callers.ai/agents/{agent_id}/contacts",
    headers={"Authorization": os.environ["CALLERS_API_KEY"]},
    json={
        "name": "Dana Levi",
        "phoneNumber": "+14155552671",
        "data": {"first_name": "Dana", "company": "Acme"},
    },
)
data = res.json()["data"]  # {contactId, created, modified}
```

```json Response theme={"dark"}
{ "status": "success", "data": { "contactId": "6650c3a9a1b2c3d4e5f60801", "created": true, "modified": false } }
```

The single-contact endpoint is strict: if the contact can't be saved (invalid number, missing required variable), it returns `400` with the reason, for example `No contacts were added or modified. phoneNumber: Phone number is not valid: 225747466045`.

| Query parameter | Default | Description |
| - | - | - |
| `fixUSPhoneNumber` | `false` | Adds a leading `1` to 10-digit numbers. |
| `ignorePhoneNumberValidation` | `false` | Skips the international number check. Use only for numbers you know your carrier can dial. |

## Add contacts in bulk

`POST /agents/{agentId}/contacts/bulk` with `{ "contacts": [ ... ] }`.

```bash cURL theme={"dark"}
curl -X POST "https://api.callers.ai/agents/$AGENT_ID/contacts/bulk?verbose=true" \
  -H "Authorization: $CALLERS_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "contacts": [
      { "name": "Dana Levi", "phoneNumbers": ["+14155552671", "+14155559876"], "data": { "company": "Acme" } },
      { "name": "Sam Ortiz", "phoneNumber": "+442071234567", "timezone": "Europe/London", "data": {} }
    ]
  }'
```

```javascript Node.js theme={"dark"}
const res = await fetch(
  `https://api.callers.ai/agents/${agentId}/contacts/bulk?verbose=true`,
  {
    method: "POST",
    headers: {
      Authorization: process.env.CALLERS_API_KEY,
      "Content-Type": "application/json",
    },
    body: JSON.stringify({
      contacts: [
        { name: "Dana Levi", phoneNumbers: ["+14155552671", "+14155559876"], data: { company: "Acme" } },
        { name: "Sam Ortiz", phoneNumber: "+442071234567", timezone: "Europe/London", data: {} },
      ],
    }),
  }
);
const { data } = await res.json();
```

```python Python theme={"dark"}
res = requests.post(
    f"https://api.callers.ai/agents/{agent_id}/contacts/bulk",
    headers={"Authorization": os.environ["CALLERS_API_KEY"]},
    params={"verbose": "true"},
    json={
        "contacts": [
            {"name": "Dana Levi", "phoneNumbers": ["+14155552671", "+14155559876"], "data": {"company": "Acme"}},
            {"name": "Sam Ortiz", "phoneNumber": "+442071234567", "timezone": "Europe/London", "data": {}},
        ]
    },
)
data = res.json()["data"]
```

**requested** `number`

Contacts in the request.

**added** `number`

New contacts created.

**modified** `number`

Existing contacts updated.

**contactIds** `string[]`

IDs of created or updated contacts. Returned when `verbose` is `false` (default).

**contactDetails** `object[]`

Returned when `verbose=true`.

**properties**

**contactId** `string`

Contact ID.

**phoneNumber** `string`

Normalized primary phone number.

**status** `string`

`created` or `updated`.

Bulk uploads are lenient about individual contacts: contacts with no name, no valid phone number, or missing required variables are skipped, and the rest are saved. Compare `added + modified` with `requested` to detect skips. Invalid `data` is the exception: if any contact's `data` breaks the rules below, the whole request is rejected with `400`.

Request bodies are limited to 1 MB. Split large lists into batches and send them one after another.

## Validation rules

### Phone numbers

* 7–20 characters: digits, `(`, `)`, `-`, optional leading `+`. Stored as digits only.
* Must be a valid international number including the country code, unless the agent uses local dialing or you pass `ignorePhoneNumberValidation=true` (single contact only).
* Duplicates within one contact's `phoneNumbers` are ignored.

### The `data` object

| Rule | Limit | Error `params.code` |
| - | - | - |
| Total size (all keys and values) | 4,000 characters | `data_too_large` |
| Number of keys | 100 | `too_many_keys` |
| Key length | 120 characters | `key_too_long` |
| Key format | Starts with a letter, `$`, or `_`; then letters, digits, `$`, `_` | `invalid_key` |
| Characters | No control characters except tab and line breaks | `invalid_characters` |

Values must be strings. Time zone keys inside `data` are ignored; use the top-level `timezone` field.

Each validation problem appears in the error's `data` array with a `path` (including the contact index for bulk) and a machine-readable `params.code`:

```json theme={"dark"}
{
  "status": "error",
  "message": "Validation failed",
  "data": [
    {
      "code": "custom",
      "path": ["data"],
      "message": "total size 7,860 characters (max 4,000; largest: contactDataDescription 7,725, notes 310, email 24)",
      "params": { "code": "data_too_large", "totalLength": 7860, "max": 4000 }
    }
  ]
}
```

## Duplicates and updates

Within an agent, a contact is identified by its primary phone number (for Email agents without a phone number, by its email address). When you add a contact that already exists:

* `name`, `data`, phone numbers, time zone, and `archived` are replaced with what you send.
* `status`, `attempts`, and call history are kept.
* The response reports it as `modified` / `updated`.

This makes uploads safe to retry and lets you update a contact's variables by re-sending it. Note that `data` is replaced, not merged: send every key you want to keep.

> [!WARNING]
>
> If the agent has **Allow Duplicate Phone Numbers** turned on, every upload creates new contacts and nothing is updated. That setting can't be turned off once enabled. See [Phone numbers and caller ID](https://docs.callers.ai/documentation/contacts/phone-numbers-and-caller-id.md).

## List and filter contacts

`GET /agents/{agentId}/contacts`

| Query parameter | Description |
| - | - |
| `page`, `perPage` | Zero-based page and page size (default `0` and `50`). |
| `statuses[]` | Contact statuses to include. Repeat for several: `statuses[]=INTERESTED&statuses[]=CALL_ME_LATER`. |
| `goals[]` | Success criterion names to include. |
| `sortAttribute` | `NAME`, `ATTEMPTS`, `LAST_ATTEMPT`, `LAST_CALL_DURATION`, `PHONE_NUMBER`, `STATUS`, or `CREATED_AT`. |
| `sortDirection` | `ASC` (default) or `DESC`. |
| `archived` | `true` to list archived contacts instead of active ones. Default `false`. |
| `lastAttemptFrom`, `lastAttemptTo` | ISO 8601 UTC timestamps, for example `2026-10-01T00:00:00.000Z`. |

Contact statuses: `NEW`, `PENDING`, `PENDING_RETRY`, `SCHEDULED`, `IN_PROGRESS`, `COMPLETED`, `INTERESTED`, `NOT_INTERESTED`, `CALL_ME_LATER`, `DO_NOT_CALL`, `WRONG_NUMBER`, `VOICE_MAIL`, `UNREACHED`, `FAILED`, `SKIPPED`. See [Statuses and outcomes](https://docs.callers.ai/documentation/using-callers/call-statuses.md) for what each means.

```bash cURL theme={"dark"}
curl -g "https://api.callers.ai/agents/$AGENT_ID/contacts?statuses[]=INTERESTED&statuses[]=CALL_ME_LATER&lastAttemptFrom=2026-10-01T00:00:00.000Z&perPage=200" \
  -H "Authorization: $CALLERS_API_KEY"
```

```javascript Node.js theme={"dark"}
const params = new URLSearchParams();
params.append("statuses[]", "INTERESTED");
params.append("statuses[]", "CALL_ME_LATER");
params.set("lastAttemptFrom", "2026-10-01T00:00:00.000Z");
params.set("perPage", "200");

async function* allContacts(agentId) {
  for (let page = 0; ; page++) {
    params.set("page", String(page));
    const res = await fetch(
      `https://api.callers.ai/agents/${agentId}/contacts?${params}`,
      { headers: { Authorization: process.env.CALLERS_API_KEY } }
    );
    const { data } = await res.json();
    yield* data;
    if (data.length < 200) return;
  }
}
```

```python Python theme={"dark"}
def all_contacts(agent_id):
    page = 0
    while True:
        res = requests.get(
            f"https://api.callers.ai/agents/{agent_id}/contacts",
            headers={"Authorization": os.environ["CALLERS_API_KEY"]},
            params={
                "statuses[]": ["INTERESTED", "CALL_ME_LATER"],
                "lastAttemptFrom": "2026-10-01T00:00:00.000Z",
                "perPage": 200,
                "page": page,
            },
        )
        batch = res.json()["data"]
        yield from batch
        if len(batch) < 200:
            return
        page += 1
```

> [!TIP]
>
> Use the `[]` suffix for list filters, even with a single value (`statuses[]=INTERESTED`). A plain `statuses=INTERESTED` is rejected as invalid.

Other read endpoints:

| Endpoint | Returns |
| - | - |
| `GET /agents/{agentId}/contacts/{contactId}` | One contact. |
| `GET /agents/{agentId}/contacts/{contactId}/calls` | That contact's calls, paginated: `{ data, total, page, perPage }`. |
| `GET /agents/{agentId}/calls` | All of the agent's calls as a plain array, newest first; `page` and `perPage` (default `50`). |
| `GET /agents/{agentId}/calls/{callId}/webhook-payload` | The full [webhook payload](https://docs.callers.ai/documentation/webhook/webhook-payload.md) for one call. |
| `GET /agents/{agentId}/calls/{callId}/pcap` | SIP packet capture for a call on your own [SIP trunk](https://docs.callers.ai/documentation/integrations/sip.md) (`application/vnd.tcpdump.pcap`). SIP calls only, recent calls only. |
| `GET /agents/{agentId}/report` | Contact counts by status. Add `archived=true` for archived contacts. |

### Calls across the agent

To reconcile outcomes without a contact ID, list calls for the whole agent and page through them:

```bash cURL theme={"dark"}
curl "https://api.callers.ai/agents/$AGENT_ID/calls?page=0&perPage=100" \
  -H "Authorization: $CALLERS_API_KEY"

# SIP trunk calls only: download the signaling trace
curl "https://api.callers.ai/agents/$AGENT_ID/calls/$CALL_ID/pcap" \
  -H "Authorization: $CALLERS_API_KEY" \
  -o call.pcap
```

```javascript Node.js theme={"dark"}
const headers = { Authorization: process.env.CALLERS_API_KEY };

const calls = await fetch(
  `https://api.callers.ai/agents/${agentId}/calls?page=0&perPage=100`,
  { headers }
).then((r) => r.json()).then((body) => body.data);

// SIP trunk calls only: download the signaling trace
const pcap = await fetch(
  `https://api.callers.ai/agents/${agentId}/calls/${callId}/pcap`,
  { headers }
).then((r) => r.arrayBuffer());
```

```python Python theme={"dark"}
headers = {"Authorization": os.environ["CALLERS_API_KEY"]}

calls = requests.get(
    f"https://api.callers.ai/agents/{agent_id}/calls",
    headers=headers,
    params={"page": 0, "perPage": 100},
).json()["data"]

# SIP trunk calls only: download the signaling trace
pcap = requests.get(
    f"https://api.callers.ai/agents/{agent_id}/calls/{call_id}/pcap",
    headers=headers,
).content
```

Each call carries `contactId`, so you can join calls back to contacts. For the complete outcome of a call in the same shape your webhook receives, fetch `/webhook-payload`; see [Backfill missed webhooks](https://docs.callers.ai/api-reference/guides/launch-outbound-calls.md#backfill-missed-webhooks).

The report for an outbound agent returns `totalContacts`, `totalCallsCount`, `answeredCallsCount`, and `contactsByStatuses` (a count for each status: `NEW`, `PENDING_RETRY`, `SCHEDULED`, `IN_PROGRESS`, `FAILED`, `UNREACHED`, `NOT_INTERESTED`, `INTERESTED`, `COMPLETED`, `SKIPPED`, `CALL_ME_LATER`, `WRONG_NUMBER`, `DO_NOT_CALL`). See [Launch outbound calls](https://docs.callers.ai/api-reference/guides/launch-outbound-calls.md#step-5-track-progress) for a full example.

## Archive contacts

Archiving removes contacts from the calling queue but keeps them and their history. All archive endpoints accept an optional `archiveReason`.

| Endpoint | Body | Archives |
| - | - | - |
| `DELETE /agents/{agentId}/contacts/{contactId}` | `{ "archiveReason"? }` | One contact by ID. |
| `DELETE /agents/{agentId}/contacts/phoneNumber/{phoneNumber}` | `{ "archiveReason"? }` | One contact by phone number. |
| `DELETE /agents/{agentId}/contacts/bulk` | `{ "contactIds": [...], "archiveReason"? }` | Several contacts by ID. |
| `DELETE /agents/{agentId}/contacts/phone-number/bulk` | `{ "phoneNumbers": [...], "archiveReason"? }` | Several contacts by phone number. Accepts `fixUSPhoneNumber`. |
| `DELETE /agents/{agentId}/contacts` | `{ "archiveReason"? }` | Every contact in the agent. |

Bulk archive endpoints return `{ "archivedCount": n }`.

```bash cURL theme={"dark"}
curl -X DELETE "https://api.callers.ai/agents/$AGENT_ID/contacts/bulk" \
  -H "Authorization: $CALLERS_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"contactIds": ["6650c3a9a1b2c3d4e5f60801"], "archiveReason": "Closed won"}'
```

```javascript Node.js theme={"dark"}
await fetch(`https://api.callers.ai/agents/${agentId}/contacts/bulk`, {
  method: "DELETE",
  headers: {
    Authorization: process.env.CALLERS_API_KEY,
    "Content-Type": "application/json",
  },
  body: JSON.stringify({ contactIds: ["6650c3a9a1b2c3d4e5f60801"], archiveReason: "Closed won" }),
});
```

```python Python theme={"dark"}
requests.delete(
    f"https://api.callers.ai/agents/{agent_id}/contacts/bulk",
    headers={"Authorization": os.environ["CALLERS_API_KEY"]},
    json={"contactIds": ["6650c3a9a1b2c3d4e5f60801"], "archiveReason": "Closed won"},
)
```

### Restore an archived contact

Add the contact again with the same phone number. Because uploads update existing contacts and `archived` defaults to `false`, the contact is restored with its history, and the archive reason is cleared.

## Do Not Call

Archiving stops one agent from calling a contact. To make sure nobody in your account calls a number, add it to the account's Do Not Call list in the dashboard. See [Do Not Call](https://docs.callers.ai/documentation/contacts/do-not-call.md).

## Troubleshooting

> **'Contact has missing required fields: …'**
>
> The agent marks those variables as required. Add them to `data`, or make them optional in the agent's variables settings.

> **'Phone number is not valid'**
>
> The number lacks a country code or isn't a real number for its country. Send `+` and the country code, or use `fixUSPhoneNumber=true` for 10-digit US numbers.

> **My list filter returns 400**
>
> Use `statuses[]=VALUE` (with brackets), and check the values are uppercase status names.

> **A variable is empty during calls**
>
> The key in `data` must match the variable name exactly, including case. Keys with spaces are rejected; use `first_name`, not `first name`.

## Related

> **[Launch outbound calls](https://docs.callers.ai/api-reference/guides/launch-outbound-calls.md)**
>
> The end-to-end outbound workflow.

> **[Contact memory](https://docs.callers.ai/api-reference/guides/contact-memory.md)**
>
> Store facts the agent remembers across conversations.

> **[Upload contacts in the dashboard](https://docs.callers.ai/documentation/using-callers/uploading-contacts.md)**
>
> CSV import and manual entry.

> **[Variables](https://docs.callers.ai/documentation/build-your-agent/agent-variables.md)**
>
> How contact data reaches your script.


This documentation is built and hosted on [Mintlify](https://mintlify.com), a developer documentation platform.