
# Contacts API

A contact is a single person you message — their name, phone number, email, channel, tags, custom fields, and the lists and campaigns they belong to. The Contacts API lets you create contacts, look them up, update them, tag them, import them in bulk, and remove them, all without using the dashboard.

All paths on this page are relative to the base URL:

```
https://api.youraiconnector.com/v1
```

So `/contacts` means `https://api.youraiconnector.com/v1/contacts`.

> **New to the API?** Read [API Access](../integrations/api-access.md) first — it covers how to generate your API key, the three ways to authenticate, rate limits, and the error format. Everything on this page assumes you already have a working API key.

---

## About contact IDs

Every contact has a unique ID. The ID you get back when you **create** a contact (in `data.contactId`) is the same ID you use everywhere else — to fetch, update, tag, send a message, or delete that contact. Save it once and reuse it.

You don't have to create a contact to get its ID. You can also look one up by phone number or email (see [Get a contact](#get-a-contact-by-phone-or-email)), or page through all your contacts (see [List contacts](#list-contacts)). Each of those returns the same ID.

---

## Create a contact

`POST /contacts`

Adds a new contact to your account. A **phone number with country code is required** — an email alone is not enough. Everything else is optional.

You can optionally drop the new contact straight into one or more lists with `listId` (a single list) or `listIds` (an array). If both are sent, `listIds` wins.

Any field you send that isn't one of the standard create fields listed in the **Create a contact** field table below (`phoneNumber`, `firstName`, `lastName`, `email`, `channel`, `is_bot_active`, `is_private`, `lead_profile`, `listId`, `listIds`, `custom_fields`) is automatically stored as a **custom field** — so a flat payload from a tool like Make or Zapier works without nesting. You can also pass an explicit `custom_fields` object.

| Field | Required | Description |
|---|---|---|
| `phoneNumber` | Yes | The contact's phone number, with country code (e.g. `+15551234567`). |
| `firstName` | No | First name. |
| `lastName` | No | Last name. |
| `email` | No | Email address. |
| `channel` | No | Messaging channel. One of `whatsapp`, `sms`, `whatsapp_web`. Defaults to `whatsapp`. |
| `is_bot_active` | No | Whether the AI assistant replies to this contact. Defaults to `true`. |
| `is_private` | No | Mark the contact as private. When `true`, the AI assistant is turned off for them. Defaults to `false`. |
| `lead_profile` | No | Free-text notes about the lead. |
| `listId` | No | A single list ID to add the contact to. |
| `listIds` | No | An array of list IDs to add the contact to (takes precedence over `listId`). |
| `custom_fields` | No | An object of your own key/value fields. You can also pass these as top-level keys. |

**cURL**

```bash
curl -X POST "https://api.youraiconnector.com/v1/contacts?apiKey=YOUR_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "phoneNumber": "+15551234567",
    "firstName": "Jane",
    "lastName": "Smith",
    "email": "jane@example.com",
    "is_bot_active": true,
    "listIds": ["list123", "list456"]
  }'
```

**JavaScript**

```javascript
const res = await fetch("https://api.youraiconnector.com/v1/contacts", {
  method: "POST",
  headers: {
    "X-API-Key": "YOUR_API_KEY",
    "Content-Type": "application/json",
  },
  body: JSON.stringify({
    phoneNumber: "+15551234567",
    firstName: "Jane",
    lastName: "Smith",
    email: "jane@example.com",
    is_bot_active: true,
    listIds: ["list123", "list456"],
  }),
});
const data = await res.json();
console.log(data.data.contactId);
```

**Python**

```python
import requests

res = requests.post(
    "https://api.youraiconnector.com/v1/contacts",
    headers={"X-API-Key": "YOUR_API_KEY"},
    json={
        "phoneNumber": "+15551234567",
        "firstName": "Jane",
        "lastName": "Smith",
        "email": "jane@example.com",
        "is_bot_active": True,
        "listIds": ["list123", "list456"],
    },
)
print(res.json()["data"]["contactId"])
```

**Response**

```json
{
  "success": true,
  "data": {
    "message": "Successfully created new contact",
    "contactId": "contact_abc123",
    "listsAdded": ["list123", "list456"]
  }
}
```

The new contact's ID is at `data.contactId`. The lists it was added to are echoed back in `data.listsAdded`.

> **Duplicates aren't created.** If a contact with the same phone number already exists, the create call does **not** create or return it. The response comes back with HTTP status `200` and an `error_code` of `409` in the body, so branch on `error_code` rather than on the HTTP status:
>
> ```json
> { "success": false, "error_code": 409, "error": "A contact with this phone number already exists for the current user." }
> ```
>
> To work with an existing contact after an `error_code` of `409`, look it up with [Get a contact by phone or email](#get-a-contact-by-phone-or-email) — `GET /contacts?phoneNumber=...` — and reuse the ID it returns.

> **Equivalent WhatsApp spellings count as the same number.** Some countries have two valid spellings for the same mobile line and WhatsApp may report either one: Mexico (`+52…` and the legacy `+521…`), Brazil (with or without the ninth digit) and Argentina (with or without the `9` after `+54`). The duplicate check on create and `GET /contacts?phoneNumber=` match across both spellings, so you get the existing contact back whichever form you send. The `phone_number` stored on the contact is never rewritten.

---

## Get a contact by phone or email

`GET /contacts?phoneNumber=...` or `GET /contacts?email=...`

Looks up a single contact and returns the full, enriched contact object — including its lists, tags, and campaigns resolved to `{ id, name }` pairs, plus the last message exchanged.

Pass **either** `phoneNumber` (in international format) **or** `email`. If you pass neither, this same endpoint switches to [List contacts](#list-contacts) mode instead.

**cURL**

```bash
curl "<span data-t="apiBaseUrl">https://api.youraiconnector.com</span>/v1/contacts?phoneNumber=%2B15551234567&apiKey=YOUR_API_KEY"
```

**JavaScript**

```javascript
const phone = encodeURIComponent("+15551234567");
const res = await fetch(`https://api.youraiconnector.com/v1/contacts?phoneNumber=${phone}`, {
  headers: { "X-API-Key": "YOUR_API_KEY" },
});
const data = await res.json();
console.log(data.contact);
```

**Python**

```python
import requests

res = requests.get(
    "<span data-t="apiBaseUrl">https://api.youraiconnector.com</span>/v1/contacts",
    headers={"X-API-Key": "YOUR_API_KEY"},
    params={"phoneNumber": "+15551234567"},
)
print(res.json()["contact"])
```

**Response**

```json
{
  "success": true,
  "contactId": "contact_abc123",
  "contact": {
    "id": "contact_abc123",
    "firstName": "Jane",
    "lastName": "Smith",
    "email": "jane@example.com",
    "phoneNumber": "+15551234567",
    "channel": "whatsapp",
    "isBotActive": true,
    "isPrivate": false,
    "doNotDisturb": false,
    "lead_profile": null,
    "avatarUrl": "https://example.com/photo.jpg",
    "customFields": {},
    "lists": [{ "id": "list123", "name": "VIP customers" }],
    "tags": [{ "id": "tagHotLead", "name": "Hot lead" }],
    "campaigns": [{ "id": "campaign789", "name": "Spring promo" }],
    "currentCampaign": { "id": "campaign789", "name": "Spring promo" },
    "lastMessage": {
      "direction": "inbound",
      "body": "Sounds good, thanks!",
      "status": "received",
      "timestamp": "2026-06-09T10:21:00.000Z"
    }
  }
}
```

The contact ID is returned both at the top level (`contactId`) and inside the object (`contact.id`). If nothing matches, you get a `404` with `{ "success": false, "message": "Contact not found" }`.

> **`avatarUrl`** is the contact's profile photo, taken from WhatsApp or Meta when they message you. It's read-only: you can't set it, and it's `null` for contacts who have no photo or who reach you on a channel that doesn't share one. Treat the link as temporary rather than storing it, since some of these photo links expire and are refreshed automatically. (In the list endpoint below, the same value is called `avatar_url`.)

> **Phone numbers in URLs.** A `+` sign in a query string must be URL-encoded as `%2B`, otherwise it's read as a space. The examples above do this for you.

---

## Get a contact by ID

`GET /contacts/{contactId}`

When you already have a contact's ID, fetch it directly. The response shape is identical to the lookup above.

**cURL**

```bash
curl "<span data-t="apiBaseUrl">https://api.youraiconnector.com</span>/v1/contacts/contact_abc123?apiKey=YOUR_API_KEY"
```

**JavaScript**

```javascript
const res = await fetch("<span data-t="apiBaseUrl">https://api.youraiconnector.com</span>/v1/contacts/contact_abc123", {
  headers: { "X-API-Key": "YOUR_API_KEY" },
});
const data = await res.json();
console.log(data.contact);
```

**Python**

```python
import requests

res = requests.get(
    "<span data-t="apiBaseUrl">https://api.youraiconnector.com</span>/v1/contacts/contact_abc123",
    headers={"X-API-Key": "YOUR_API_KEY"},
)
print(res.json()["contact"])
```

A contact ID that doesn't exist on your account returns a `404`.

---

## Get contact stats

`GET /contacts/{contactId}/stats`

Returns aggregate message stats for one contact: totals, AI vs human replies, credits spent, and first/last message timestamps.

**cURL**

```bash
curl "<span data-t="apiBaseUrl">https://api.youraiconnector.com</span>/v1/contacts/contact_abc123/stats?apiKey=YOUR_API_KEY"
```

**JavaScript**

```javascript
const res = await fetch("<span data-t="apiBaseUrl">https://api.youraiconnector.com</span>/v1/contacts/contact_abc123/stats", {
  headers: { "X-API-Key": "YOUR_API_KEY" },
});
const data = await res.json();
console.log(data.totalMessages, data.creditsUsed);
```

**Python**

```python
import requests

res = requests.get(
    "<span data-t="apiBaseUrl">https://api.youraiconnector.com</span>/v1/contacts/contact_abc123/stats",
    headers={"X-API-Key": "YOUR_API_KEY"},
)
data = res.json()
print(data["totalMessages"], data["creditsUsed"])
```

**Response**

```json
{
  "success": true,
  "totalMessages": 48,
  "sent": 21,
  "received": 27,
  "aiReplies": 18,
  "humanReplies": 3,
  "creditsUsed": 34,
  "botMessageCount": 18,
  "firstMessageAt": "2026-05-01T09:00:00.000Z",
  "lastMessageAt": "2026-06-09T10:21:00.000Z"
}
```

`botMessageCount` is the same AI-message counter the in-app "reset" button on a contact zeroes. `creditsUsed` is the running credit total for this contact, not just this response's numbers. A contact ID that doesn't exist on your account returns a `404`.

---

## List contacts

`GET /contacts`

Call `GET /contacts` with **neither** `phoneNumber` nor `email` to page through all your contacts, newest first. Each page returns compact contact summaries (lists, tags, and campaigns come back as ID arrays rather than full objects) and a `next_cursor`.

| Query parameter | Description |
|---|---|
| `limit` | Page size. Defaults to 50, maximum 100. |
| `cursor` | The `next_cursor` value from the previous page. Omit it on the first page. |
| `listId` | Optional. Only return contacts that belong to this list. |

To walk every page: make the first call without a cursor, then keep passing the returned `next_cursor` back as `cursor`. **Stop when `next_cursor` is `null`** — that means there are no more results.

**cURL**

```bash
curl "<span data-t="apiBaseUrl">https://api.youraiconnector.com</span>/v1/contacts?limit=50&apiKey=YOUR_API_KEY"

# next page:
curl "<span data-t="apiBaseUrl">https://api.youraiconnector.com</span>/v1/contacts?limit=50&cursor=contact_abc123&apiKey=YOUR_API_KEY"
```

**JavaScript**

```javascript
async function listAllContacts() {
  const all = [];
  let cursor = null;
  do {
    const url = new URL("<span data-t="apiBaseUrl">https://api.youraiconnector.com</span>/v1/contacts");
    url.searchParams.set("limit", "100");
    if (cursor) url.searchParams.set("cursor", cursor);
    const res = await fetch(url, { headers: { "X-API-Key": "YOUR_API_KEY" } });
    const data = await res.json();
    all.push(...data.contacts);
    cursor = data.next_cursor;
  } while (cursor);
  return all;
}
```

**Python**

```python
import requests

def list_all_contacts():
    all_contacts = []
    cursor = None
    while True:
        params = {"limit": 100}
        if cursor:
            params["cursor"] = cursor
        res = requests.get(
            "<span data-t="apiBaseUrl">https://api.youraiconnector.com</span>/v1/contacts",
            headers={"X-API-Key": "YOUR_API_KEY"},
            params=params,
        )
        data = res.json()
        all_contacts.extend(data["contacts"])
        cursor = data["next_cursor"]
        if not cursor:
            break
    return all_contacts
```

**Response**

```json
{
  "success": true,
  "contacts": [
    {
      "id": "contact_abc123",
      "first_name": "Jane",
      "last_name": "Smith",
      "email": "jane@example.com",
      "phone_number": "+15551234567",
      "channel": "whatsapp",
      "is_bot_active": true,
      "is_private": false,
      "do_not_disturb": false,
      "avatar_url": "https://example.com/photo.jpg",
      "custom_fields": {},
      "created_at": "2026-06-01T09:00:00.000Z",
      "list_ids": ["list123"],
      "tag_ids": ["tagHotLead"],
      "campaign_ids": ["campaign789"],
      "current_campaign_id": "campaign789"
    }
  ],
  "next_cursor": "contact_abc123"
}
```

::: note
**Note:** Filtering by a `listId` that doesn't exist on your account returns a `404`. An invalid `cursor` returns a `400`.
:::


---

## Count contacts

`GET /contacts/count`

Returns how many contacts match a filter, plus a per-channel split, without paging through them. This is the right call for any "how many" question — a dashboard tile, an automation, or asking Champ. All filters are optional, and combining several narrows the count (a contact has to match every one you send).

| Query parameter | Description |
|---|---|
| `agentId` | Only contacts assigned to this AI agent. Pass `none` for contacts with no assigned agent (those are answered by the channel's default agent). |
| `channel` | Only contacts on this channel, e.g. `whatsapp`, `messenger`, `instagram`, `sms`, `email`, `chat_widget`. |
| `tag` | Only contacts carrying this tag, by tag **name** (upper/lower case doesn't matter). A tag name you don't have returns a `404`. |
| `listId` | Only contacts on this list. |
| `botActive` | `true` or `false` — only contacts whose AI assistant is on, or off. |
| `status` | Only contacts with this status, e.g. `Lead`. |
| `rules` | A URL-encoded JSON rules object, using the same shape as a smart list (see [The `smart_rules` shape](#the-smart_rules-shape) further down). Can't be combined with the other filters. |

Send no filter at all and you get the total number of contacts on your account.

**cURL**

```bash
# everything
curl "<span data-t="apiBaseUrl">https://api.youraiconnector.com</span>/v1/contacts/count?apiKey=YOUR_API_KEY"

# only the contacts one agent handles on Messenger
curl "<span data-t="apiBaseUrl">https://api.youraiconnector.com</span>/v1/contacts/count?agentId=agent_xyz789&channel=messenger&apiKey=YOUR_API_KEY"
```

**JavaScript**

```javascript
const url = new URL("<span data-t="apiBaseUrl">https://api.youraiconnector.com</span>/v1/contacts/count");
url.searchParams.set("agentId", "agent_xyz789");
url.searchParams.set("channel", "messenger");

const res = await fetch(url, { headers: { "X-API-Key": "YOUR_API_KEY" } });
const data = await res.json();
console.log(data.total);
```

**Python**

```python
import requests

res = requests.get(
    "<span data-t="apiBaseUrl">https://api.youraiconnector.com</span>/v1/contacts/count",
    headers={"X-API-Key": "YOUR_API_KEY"},
    params={"agentId": "agent_xyz789", "channel": "messenger"},
)
data = res.json()
print(data["total"])
```

**Response**

```json
{
  "success": true,
  "total": 3423,
  "by_channel": { "messenger": 2744, "instagram": 667, "none": 12 },
  "filters": { "agentId": "agent_xyz789" }
}
```

`by_channel` splits the same total per channel; contacts that aren't on any channel are counted under `none`. `filters` echoes back the filters that were applied, so you can check the call did what you meant.

::: note
**Note:** Sending `rules` together with any other filter, or a `rules` value that isn't valid JSON, returns a `400`. A tag name or list ID that doesn't exist on your account returns a `404`.
:::


---

## Update a contact

`PUT /contacts/{contactId}`

Updates an existing contact. Only the fields you include are changed — leave out anything you don't want to touch. You must send at least one field, or you get a `400` ("No fields to update").

| Field | Description |
|---|---|
| `firstName` | First name. |
| `lastName` | Last name. |
| `email` | Email address. |
| `is_bot_active` | Whether the AI assistant replies to this contact. |
| `is_private` | Mark private. Setting this to `true` also turns the AI assistant off. |
| `do_not_disturb` | Pause automated outreach to this contact. Also stops the AI from replying. |
| `follow_ups_disabled` | Stop all automated follow-ups for this contact (quick, cycle and cold-lead) while the AI keeps replying to messages they send. Useful once someone has bought. Stays off until you set it back to `false`. |
| `lead_profile` | Free-text lead notes. |
| `custom_fields` | An object of custom fields. **Merged per key** — only the keys you send are written, the rest of the existing custom fields are kept. You can also pass custom field keys at the top level. |

**cURL**

```bash
curl -X PUT "<span data-t="apiBaseUrl">https://api.youraiconnector.com</span>/v1/contacts/contact_abc123?apiKey=YOUR_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{ "firstName": "Jane", "do_not_disturb": true }'
```

**JavaScript**

```javascript
const res = await fetch("<span data-t="apiBaseUrl">https://api.youraiconnector.com</span>/v1/contacts/contact_abc123", {
  method: "PUT",
  headers: {
    "X-API-Key": "YOUR_API_KEY",
    "Content-Type": "application/json",
  },
  body: JSON.stringify({ firstName: "Jane", do_not_disturb: true }),
});
const data = await res.json();
console.log(data.message);
```

**Python**

```python
import requests

res = requests.put(
    "<span data-t="apiBaseUrl">https://api.youraiconnector.com</span>/v1/contacts/contact_abc123",
    headers={"X-API-Key": "YOUR_API_KEY"},
    json={"firstName": "Jane", "do_not_disturb": True},
)
print(res.json()["message"])
```

**Response**

```json
{
  "success": true,
  "message": "Contact updated successfully"
}
```

> **Custom fields are merged, not replaced.** Sending `{ "custom_fields": { "tier": "gold" } }` only sets `tier` — any other custom fields on the contact stay exactly as they were. To remove a custom field entirely across all contacts, use [Delete a custom field](#delete-a-custom-field).

---

## Add or remove tags

`POST /contacts/{contactId}/tags`

Adds and/or removes tags on a single contact in one call. Pass tag **IDs** in `addTagIds` and `removeTagIds`. At least one of the two must be non-empty.

The tags must already exist on your account — create them first through the [tags endpoint](reference.md). If the contact or any referenced tag doesn't exist, you get a `404`.

| Field | Description |
|---|---|
| `addTagIds` | Array of tag IDs to add to the contact. |
| `removeTagIds` | Array of tag IDs to remove from the contact. |

**cURL**

```bash
curl -X POST "<span data-t="apiBaseUrl">https://api.youraiconnector.com</span>/v1/contacts/contact_abc123/tags?apiKey=YOUR_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{ "addTagIds": ["tagHotLead"], "removeTagIds": ["tagColdLead"] }'
```

**JavaScript**

```javascript
const res = await fetch("<span data-t="apiBaseUrl">https://api.youraiconnector.com</span>/v1/contacts/contact_abc123/tags", {
  method: "POST",
  headers: {
    "X-API-Key": "YOUR_API_KEY",
    "Content-Type": "application/json",
  },
  body: JSON.stringify({
    addTagIds: ["tagHotLead"],
    removeTagIds: ["tagColdLead"],
  }),
});
const data = await res.json();
console.log(data.added, data.removed);
```

**Python**

```python
import requests

res = requests.post(
    "<span data-t="apiBaseUrl">https://api.youraiconnector.com</span>/v1/contacts/contact_abc123/tags",
    headers={"X-API-Key": "YOUR_API_KEY"},
    json={"addTagIds": ["tagHotLead"], "removeTagIds": ["tagColdLead"]},
)
data = res.json()
print(data["added"], data["removed"])
```

**Response**

```json
{
  "success": true,
  "contact_id": "contact_abc123",
  "added": 1,
  "removed": 1
}
```

---

## Manage your tag library

These endpoints manage a tag itself — renaming or deleting it on your account — as opposed to applying or removing a tag on one contact (see [Add or remove tags](#add-or-remove-tags) above). Every tag on your account has an ID (`tagId`): the one shown in your dashboard's tag manager, and the one returned as `data.tag_id` when you create a tag with `POST /tags` and a JSON body of `{ "name": "..." }` (no `phoneNumber`, `email`, or `contactId`).

### Update a tag

`PUT /tags/{tagId}`

Send only the fields you're changing.

| Field | Description |
|---|---|
| `name` | The tag's name. |

```bash
curl -X PUT "<span data-t="apiBaseUrl">https://api.youraiconnector.com</span>/v1/tags/tagHotLead?apiKey=YOUR_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{ "name": "Hot lead (Q3)" }'
```

**Response**

```json
{ "success": true, "tag_id": "tagHotLead" }
```

A `tagId` that doesn't exist on your account returns a `404`.

### Delete a tag

`DELETE /tags/{tagId}`

Deletes one tag by ID. **This cannot be undone** — contacts carrying the tag simply lose it. Deleting a tag that's already gone (or never existed) returns `200` with `deleted: 0` rather than a `404`, since there's nothing to enumerate.

```bash
curl -X DELETE "<span data-t="apiBaseUrl">https://api.youraiconnector.com</span>/v1/tags/tagColdLead?apiKey=YOUR_API_KEY"
```

**Response**

```json
{ "success": true, "deleted": 1 }
```

### Delete several tags at once

`DELETE /tags`

| Field | Description |
|---|---|
| `tagIds` | Array of tag IDs to delete (max 1000). |

```bash
curl -X DELETE "<span data-t="apiBaseUrl">https://api.youraiconnector.com</span>/v1/tags?apiKey=YOUR_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{ "tagIds": ["tagColdLead", "tagUnsubscribed"] }'
```

**Response**

```json
{ "success": true, "deleted": 2 }
```

IDs that don't exist, or belong to another account, are silently skipped and not counted in `deleted`.

---

## Bulk set a flag

`POST /contacts/bulk-flag`

Sets one boolean flag on many contacts at once. Up to 500 contact IDs per request. IDs that don't exist on your account are skipped and counted in `skipped`.

| Field | Description |
|---|---|
| `contactIds` | Array of contact IDs to update (max 500). |
| `field` | Which flag to set. One of `bot_active` (AI assistant on/off), `dnd` (pause automated outreach), `spam`, `private`. |
| `value` | The boolean value to set the flag to. |

**cURL**

```bash
curl -X POST "<span data-t="apiBaseUrl">https://api.youraiconnector.com</span>/v1/contacts/bulk-flag?apiKey=YOUR_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "contactIds": ["contactId1", "contactId2"],
    "field": "bot_active",
    "value": false
  }'
```

**JavaScript**

```javascript
const res = await fetch("<span data-t="apiBaseUrl">https://api.youraiconnector.com</span>/v1/contacts/bulk-flag", {
  method: "POST",
  headers: {
    "X-API-Key": "YOUR_API_KEY",
    "Content-Type": "application/json",
  },
  body: JSON.stringify({
    contactIds: ["contactId1", "contactId2"],
    field: "bot_active",
    value: false,
  }),
});
const data = await res.json();
console.log(data.updated, data.skipped);
```

**Python**

```python
import requests

res = requests.post(
    "<span data-t="apiBaseUrl">https://api.youraiconnector.com</span>/v1/contacts/bulk-flag",
    headers={"X-API-Key": "YOUR_API_KEY"},
    json={
        "contactIds": ["contactId1", "contactId2"],
        "field": "bot_active",
        "value": False,
    },
)
data = res.json()
print(data["updated"], data["skipped"])
```

**Response**

```json
{
  "success": true,
  "updated": 2,
  "skipped": 0
}
```

---

## Bulk import contacts

`POST /contacts/import`

Creates up to 500 contacts in one call from a JSON array. Each record needs a `phone_number` in international format; everything else is optional. Records with invalid phone numbers or unsupported channels are **skipped** (not created), and every skipped record is reported with its index and reason — so you can fix just the failures and retry.

Phone numbers that already exist on your account are skipped as `duplicate` by default. Send `updateExisting: true` to **update** those contacts instead: the fields present in the record overwrite the contact's (`first_name`, `last_name`, `email`, `lead_profile`, and `custom_fields` merged key by key), `tags` are added, and the contact is added to `listId`. Channel, phone number and bot flags are never changed on an existing contact.

You can optionally add every imported (or updated) contact to a list with `listId`, set a `defaultChannel` for records that don't specify one, and tag records with `tags` (tag names — missing tags are created, existing ones matched case-insensitively).

**Top-level fields**

| Field | Required | Description |
|---|---|---|
| `contacts` | Yes | Array of contact records (max 500). |
| `listId` | No | List to add every imported (and updated) contact to. Must be a list on your account. |
| `defaultChannel` | No | Channel applied to records that omit `channel`. One of `whatsapp`, `sms`, `whatsapp_web`. Defaults to `whatsapp`. |
| `updateExisting` | No | `true` to update contacts whose phone number already exists instead of skipping them as `duplicate`. Defaults to `false`. |

**Per-record fields**

| Field | Required | Description |
|---|---|---|
| `phone_number` | Yes | Phone number in international format (a leading `+` is added if missing). |
| `first_name` | No | First name. |
| `last_name` | No | Last name. |
| `email` | No | Email address. |
| `channel` | No | One of `whatsapp`, `sms`, `whatsapp_web`. Falls back to `defaultChannel`. |
| `is_bot_active` | No | Whether the AI assistant replies. Defaults to `true`. |
| `is_private` | No | Mark private. Defaults to `false`. |
| `lead_profile` | No | Free-text lead notes. |
| `custom_fields` | No | Object of custom field keys and values. |
| `tags` | No | Array of tag names (a single `"a; b"` string also works). Tags that don't exist are created; existing ones are matched ignoring case. Max 25 per record. |

**cURL**

```bash
curl -X POST "<span data-t="apiBaseUrl">https://api.youraiconnector.com</span>/v1/contacts/import?apiKey=YOUR_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "contacts": [
      { "phone_number": "+12025551234", "first_name": "Ann", "last_name": "Lee", "tags": ["vip", "newsletter"] },
      { "phone_number": "+12025551235", "first_name": "Bob" }
    ],
    "listId": "list123",
    "defaultChannel": "whatsapp_web",
    "updateExisting": true
  }'
```

**JavaScript**

```javascript
const res = await fetch("<span data-t="apiBaseUrl">https://api.youraiconnector.com</span>/v1/contacts/import", {
  method: "POST",
  headers: {
    "X-API-Key": "YOUR_API_KEY",
    "Content-Type": "application/json",
  },
  body: JSON.stringify({
    contacts: [
      { phone_number: "+12025551234", first_name: "Ann", last_name: "Lee", tags: ["vip", "newsletter"] },
      { phone_number: "+12025551235", first_name: "Bob" },
    ],
    listId: "list123",
    defaultChannel: "whatsapp_web",
    updateExisting: true,
  }),
});
const data = await res.json();
console.log(`Imported ${data.imported}, updated ${data.updated}, skipped ${data.skipped.length}`);
```

**Python**

```python
import requests

res = requests.post(
    "<span data-t="apiBaseUrl">https://api.youraiconnector.com</span>/v1/contacts/import",
    headers={"X-API-Key": "YOUR_API_KEY"},
    json={
        "contacts": [
            {"phone_number": "+12025551234", "first_name": "Ann", "last_name": "Lee", "tags": ["vip", "newsletter"]},
            {"phone_number": "+12025551235", "first_name": "Bob"},
        ],
        "listId": "list123",
        "defaultChannel": "whatsapp_web",
        "updateExisting": True,
    },
)
data = res.json()
print(f"Imported {data['imported']}, updated {data['updated']}, skipped {len(data['skipped'])}")
```

**Response**

```json
{
  "success": true,
  "imported": 2,
  "contact_ids": ["contact_abc123", "contact_def456"],
  "updated": 0,
  "updated_contact_ids": [],
  "skipped": []
}
```

If some records can't be created, they appear in `skipped` with the reason (here without `updateExisting`, so the existing number is skipped):

```json
{
  "success": true,
  "imported": 1,
  "contact_ids": ["contact_abc123"],
  "updated": 0,
  "updated_contact_ids": [],
  "skipped": [
    { "index": 1, "phone_number": "+12025551235", "reason": "duplicate" }
  ]
}
```

With `updateExisting: true` the same request reports the existing contact under `updated` / `updated_contact_ids` instead.

Possible skip reasons: `invalid_record`, `missing_phone_number`, `invalid_phone_number`, `invalid_channel`, `duplicate_in_request`, `duplicate`, `contact_limit_reached`, `create_failed`.

> **Plan limits.** If your plan's contact limit doesn't allow this many new contacts, the whole request is rejected up front with a `403`. If the limit is reached partway through, the remaining records come back as skipped with reason `contact_limit_reached`.

---

## Import contacts from a CSV file

For imports bigger than [bulk import](#bulk-import-contacts) supports (up to roughly 50,000 rows), enqueue an async import job against a CSV file already sitting in your account's storage, then poll it until it completes.

### Start the import

`POST /contacts/import-csv`

| Field | Required | Description |
|---|---|---|
| `csvStoragePath` | Yes | Storage path of the CSV file, under `users/{your account id}/imports/`, ending in `.csv`. |
| `listName` | Yes | Creates (or reuses) a list with this name and adds every imported contact to it. |
| `existingListRefs` | No | Array of existing list IDs to also add every imported contact to. |
| `defaultChannel` | No | Channel applied to rows that don't specify one. |

```bash
curl -X POST "<span data-t="apiBaseUrl">https://api.youraiconnector.com</span>/v1/contacts/import-csv?apiKey=YOUR_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "csvStoragePath": "users/abc123/imports/leads.csv",
    "listName": "Webinar signups"
  }'
```

**JavaScript**

```javascript
const res = await fetch("<span data-t="apiBaseUrl">https://api.youraiconnector.com</span>/v1/contacts/import-csv", {
  method: "POST",
  headers: {
    "X-API-Key": "YOUR_API_KEY",
    "Content-Type": "application/json",
  },
  body: JSON.stringify({
    csvStoragePath: "users/abc123/imports/leads.csv",
    listName: "Webinar signups",
  }),
});
const data = await res.json();
console.log(data.job_id);
```

**Python**

```python
import requests

res = requests.post(
    "<span data-t="apiBaseUrl">https://api.youraiconnector.com</span>/v1/contacts/import-csv",
    headers={"X-API-Key": "YOUR_API_KEY"},
    json={
        "csvStoragePath": "users/abc123/imports/leads.csv",
        "listName": "Webinar signups",
    },
)
job_id = res.json()["job_id"]
```

**Response** (`202` — the import is queued, not finished yet)

```json
{
  "success": true,
  "job_id": "csvimp_abc123",
  "status": "queued"
}
```

> **Getting the file into storage.** This endpoint starts and tracks the import job; it does not accept an upload itself. The CSV file needs to already be at `csvStoragePath` before you call it — the dashboard's own CSV importer does this as its first step.

### Poll the import job

`GET /contacts/import-csv/{jobId}`

```bash
curl "<span data-t="apiBaseUrl">https://api.youraiconnector.com</span>/v1/contacts/import-csv/csvimp_abc123?apiKey=YOUR_API_KEY"
```

**Response**

```json
{
  "success": true,
  "job_id": "csvimp_abc123",
  "status": "completed",
  "imported": 812,
  "updated": 0,
  "skipped": 14,
  "errors": [],
  "error_message": null
}
```

`status` moves through `queued` → `processing` → `completed`, or `failed` with the reason in `error_message`. A `jobId` that doesn't exist on your account returns a `404`.

---

## Export contacts

Kicks off an async CSV export of your contacts and returns a job you poll for completion.

### Start the export

`POST /contacts/export`

| Field | Required | Description |
|---|---|---|
| `listId` | No | Only export contacts that belong to this list. |
| `contactIds` | No | Only export these specific contact IDs. |

Leaving both out exports every contact on your account.

```bash
curl -X POST "<span data-t="apiBaseUrl">https://api.youraiconnector.com</span>/v1/contacts/export?apiKey=YOUR_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{ "listId": "list123" }'
```

**JavaScript**

```javascript
const res = await fetch("<span data-t="apiBaseUrl">https://api.youraiconnector.com</span>/v1/contacts/export", {
  method: "POST",
  headers: {
    "X-API-Key": "YOUR_API_KEY",
    "Content-Type": "application/json",
  },
  body: JSON.stringify({ listId: "list123" }),
});
const data = await res.json();
console.log(data.job_id);
```

**Python**

```python
import requests

res = requests.post(
    "<span data-t="apiBaseUrl">https://api.youraiconnector.com</span>/v1/contacts/export",
    headers={"X-API-Key": "YOUR_API_KEY"},
    json={"listId": "list123"},
)
job_id = res.json()["job_id"]
```

**Response** (`202` — the export is queued)

```json
{
  "success": true,
  "job_id": "export_abc123",
  "status": "queued"
}
```

### Poll the export job

`GET /contacts/export/{jobId}`

```bash
curl "<span data-t="apiBaseUrl">https://api.youraiconnector.com</span>/v1/contacts/export/export_abc123?apiKey=YOUR_API_KEY"
```

**Response**

```json
{
  "success": true,
  "job_id": "export_abc123",
  "status": "completed",
  "export_id": "exp_xyz789",
  "contact_count": 812,
  "error_message": null
}
```

> Once `status` is `"completed"` you get `export_id` and `contact_count`. Downloading the generated CSV file happens from your dashboard's Exports page.

---

## Send a message to a contact

`POST /contacts/{contactId}/send-message`

Sends a message to an existing contact on whichever channel they're already on. The message is queued and delivered in the background — the response confirms it was accepted, not that it's been delivered yet.

| Field | Required | Description |
|---|---|---|
| `body` | Yes | The text of the message to send. |
| `mediaUrl` | No | URL of a media file to attach. |
| `mediaContentType` | No | MIME type of the attached media (e.g. `image/jpeg`). |

**cURL**

```bash
curl -X POST "<span data-t="apiBaseUrl">https://api.youraiconnector.com</span>/v1/contacts/contact_abc123/send-message?apiKey=YOUR_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{ "body": "Hi! Your appointment is confirmed." }'
```

**JavaScript**

```javascript
const res = await fetch("<span data-t="apiBaseUrl">https://api.youraiconnector.com</span>/v1/contacts/contact_abc123/send-message", {
  method: "POST",
  headers: {
    "X-API-Key": "YOUR_API_KEY",
    "Content-Type": "application/json",
  },
  body: JSON.stringify({ body: "Hi! Your appointment is confirmed." }),
});
const data = await res.json();
console.log(data.messageId);
```

**Python**

```python
import requests

res = requests.post(
    "<span data-t="apiBaseUrl">https://api.youraiconnector.com</span>/v1/contacts/contact_abc123/send-message",
    headers={"X-API-Key": "YOUR_API_KEY"},
    json={"body": "Hi! Your appointment is confirmed."},
)
print(res.json()["messageId"])
```

**Response**

```json
{
  "success": true,
  "messageId": "aB3dE5fG7hI9jK1lM2nO",
  "contactId": "contact_abc123",
  "channel": "whatsapp",
  "message": "Message created successfully. Delivery is being processed."
}
```

> **Can't send right now?** If the contact has do-not-disturb or private mode enabled, or isn't on a channel that can receive outbound messages, the request is rejected with a `422` and an explanatory `error`.

For sending by phone number, Instagram ID, or other channel identity instead of a contact ID — and for more on messaging in general — see the [Messages API](messages.md).

---

## Assign an AI agent to a contact

`POST /contacts/{contactId}/assign-agent`

Moves an existing conversation to a different AI agent, from the next message onwards. It is the same thing as **Assign AI Agent** in a chat's menu, and the same step the **Assign AI agent or campaign** action in Automations uses.

| Field | Required | Description |
|---|---|---|
| `agentId` | Yes | The ID of the AI agent that should take over, or `null` to clear the assignment so the conversation goes back to your team inbox. |
| `triggerAIResponse` | No | `true` makes the newly assigned agent reply to the contact's latest unanswered messages right away. Defaults to `false`. |

> **Careful with `triggerAIResponse: true`** — it sends the contact a message there and then, so only use it when you want them messaged now. On Messenger and Instagram that message fails if the contact last wrote to you more than 24 hours ago.

**cURL**

```bash
curl -X POST "<span data-t="apiBaseUrl">https://api.youraiconnector.com</span>/v1/contacts/contact_abc123/assign-agent?apiKey=YOUR_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{ "agentId": "agent_xyz789" }'
```

**JavaScript**

```javascript
const res = await fetch("<span data-t="apiBaseUrl">https://api.youraiconnector.com</span>/v1/contacts/contact_abc123/assign-agent", {
  method: "POST",
  headers: {
    "X-API-Key": "YOUR_API_KEY",
    "Content-Type": "application/json",
  },
  body: JSON.stringify({ agentId: "agent_xyz789" }),
});
const data = await res.json();
console.log(data.data.agentId);
```

**Python**

```python
import requests

res = requests.post(
    "<span data-t="apiBaseUrl">https://api.youraiconnector.com</span>/v1/contacts/contact_abc123/assign-agent",
    headers={"X-API-Key": "YOUR_API_KEY"},
    json={"agentId": "agent_xyz789"},
)
print(res.json()["data"]["agentId"])
```

**Response**

```json
{
  "success": true,
  "data": {
    "contactId": "contact_abc123",
    "agentId": "agent_xyz789",
    "aiResponseTriggered": false
  }
}
```

> The agent must belong to the same account as the contact; otherwise the request is rejected with a `404` or `403`. Find agent IDs on the AI Agents page (each agent's URL ends with its ID).

---

## Assign an AI agent to many contacts

`POST /contacts/bulk-assign-agent`

Moves many conversations to a different AI agent in one call — or clears the assignment for all of them with `null`. It is purely a routing change: **no message is sent and the agent doesn't reply to anyone**. Each contact simply gets the new agent the next time they write. (That's why there is no `triggerAIResponse` here.)

| Field | Required | Description |
|---|---|---|
| `agentId` | Yes | The AI agent that should take over, or `null` to clear the assignment. |
| `contactIds` | One of the three | Up to 500 contact IDs to move. |
| `filter` | One of the three | Pick the contacts on the server instead of listing them, newest first. Takes the same keys as the count endpoint's filters: `agentId` (or `none`), `channel`, `tag`, `listId`, `botActive`, `status`. |
| `rules` | One of the three | A smart-list rules object — see [The `smart_rules` shape](#the-smart_rules-shape). |
| `limit` | No | How many contacts to move in this call when you select with `filter` or `rules`. 1 to 500, defaults to 500. |

Send exactly one of `contactIds`, `filter` or `rules`.

**cURL**

```bash
curl -X POST "<span data-t="apiBaseUrl">https://api.youraiconnector.com</span>/v1/contacts/bulk-assign-agent?apiKey=YOUR_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "agentId": "agent_xyz789",
    "filter": { "agentId": "agent_abc123", "channel": "messenger" }
  }'
```

**JavaScript**

```javascript
const res = await fetch("<span data-t="apiBaseUrl">https://api.youraiconnector.com</span>/v1/contacts/bulk-assign-agent", {
  method: "POST",
  headers: {
    "X-API-Key": "YOUR_API_KEY",
    "Content-Type": "application/json",
  },
  body: JSON.stringify({
    agentId: "agent_xyz789",
    filter: { agentId: "agent_abc123", channel: "messenger" },
  }),
});
const data = await res.json();
console.log(data.updated, data.remaining);
```

**Python**

```python
import requests

res = requests.post(
    "<span data-t="apiBaseUrl">https://api.youraiconnector.com</span>/v1/contacts/bulk-assign-agent",
    headers={"X-API-Key": "YOUR_API_KEY"},
    json={
        "agentId": "agent_xyz789",
        "filter": {"agentId": "agent_abc123", "channel": "messenger"},
    },
)
data = res.json()
print(data["updated"], data["remaining"])
```

**Response**

```json
{
  "success": true,
  "agentId": "agent_xyz789",
  "matched": 3415,
  "updated": 500,
  "skipped": 0,
  "remaining": 2915,
  "filters": { "agentId": "agent_abc123" }
}
```

`matched` is how many contacts the selection found in total, `updated` how many were moved by this call, `skipped` how many of the IDs you sent weren't found on your account, and `remaining` how many still match now this call is done.

**Moving everyone.** Because a call moves at most 500 contacts, a big group takes a few calls. Use a filter that stops matching a contact once it has moved — for example `filter: { "agentId": "agent_abc123" }` while assigning to `agent_xyz789` — and repeat the exact same call until `remaining` comes back as `0`. When you pass `contactIds` instead, `remaining` is always `0`.

---

## Assign a contact to a department

`POST /contacts/{contactId}/department`

"Assign this lead to Sales" — files a contact under a named department and, by default, hands it to whoever on that department currently has the fewest contacts. This is separate from [assigning an AI agent](#assign-an-ai-agent-to-a-contact): a department answers "which team owns this," an agent answers "which AI answers this," and setting one never clears the other.

| Field | Required | Description |
|---|---|---|
| `department_id` | Yes | The department to file the contact under. Pass `null` to clear it. |
| `hand_to_member` | No | Also hand the contact to the least-loaded person on that department. Defaults to `true`. Never reassigns a contact someone already owns. |

**cURL**

```bash
curl -X POST "<span data-t="apiBaseUrl">https://api.youraiconnector.com</span>/v1/contacts/contact_abc123/department?apiKey=YOUR_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{ "department_id": "dept_sales" }'
```

**JavaScript**

```javascript
const res = await fetch("<span data-t="apiBaseUrl">https://api.youraiconnector.com</span>/v1/contacts/contact_abc123/department", {
  method: "POST",
  headers: {
    "X-API-Key": "YOUR_API_KEY",
    "Content-Type": "application/json",
  },
  body: JSON.stringify({ department_id: "dept_sales" }),
});
const data = await res.json();
console.log(data.assigned_to);
```

**Python**

```python
import requests

res = requests.post(
    "<span data-t="apiBaseUrl">https://api.youraiconnector.com</span>/v1/contacts/contact_abc123/department",
    headers={"X-API-Key": "YOUR_API_KEY"},
    json={"department_id": "dept_sales"},
)
print(res.json()["assigned_to"])
```

**Response**

```json
{
  "success": true,
  "department_id": "dept_sales",
  "assigned_to": "member_uid_123"
}
```

`assigned_to` is `null` when the contact was already owned by someone, or you passed `hand_to_member: false`.

---

## Link a contact across channels

"Continue on WhatsApp" (or SMS) finds or creates this person's contact on another phone-based channel and links the two together, so the rest of the app recognizes them as the same person.

### Link to another channel

`POST /contacts/{contactId}/link-channel`

| Field | Required | Description |
|---|---|---|
| `channel` | Yes | The channel to link to. One of `whatsapp`, `whatsapp_web`, `sms`. |
| `phoneNumber` | No | Phone number to use on the new channel. Defaults to the source contact's own number. |

```bash
curl -X POST "<span data-t="apiBaseUrl">https://api.youraiconnector.com</span>/v1/contacts/contact_abc123/link-channel?apiKey=YOUR_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{ "channel": "sms" }'
```

**Response**

```json
{
  "success": true,
  "data": {
    "contact_id": "contact_def456",
    "person_id": "person_xyz789",
    "created": true
  }
}
```

`created` tells you whether a new contact was minted for the target channel or an existing one was found and linked. Calling this a second time is safe — it returns the same `contact_id` with `created: false` rather than creating a duplicate.

A `422` means the account can't do this link right now: the contact is already on that channel family, it has no phone number to use, or there's no connected sender for the target channel. A `409` means the two contacts are already linked to two different people — unlink one first.

### List a contact's linked conversations

`GET /contacts/{contactId}/linked`

Returns the other conversations that are the same person as this contact. An unlinked contact returns an empty array, not a `404` — "this person has no other channels" is a normal state.

```bash
curl "<span data-t="apiBaseUrl">https://api.youraiconnector.com</span>/v1/contacts/contact_abc123/linked?apiKey=YOUR_API_KEY"
```

**Response**

```json
{
  "success": true,
  "data": [
    {
      "contact_id": "contact_def456",
      "channel": "sms",
      "custom_channel": null,
      "first_name": "Jane",
      "last_name": "Smith",
      "phone_number": "+15551234567",
      "last_message": "Sounds good, thanks!",
      "last_message_timestamp": "2026-06-09T10:21:00.000Z",
      "linked_from": {
        "contact_id": "contact_abc123",
        "channel": "whatsapp",
        "linked_at": "2026-06-01T09:00:00.000Z",
        "reason": "continue_on_channel"
      }
    }
  ]
}
```

### Unlink a contact

`DELETE /contacts/{contactId}/link`

Removes this contact from its person, one-sidedly — any other contacts still linked to that person keep their link, so unlinking one out of three doesn't dissolve the group.

```bash
curl -X DELETE "<span data-t="apiBaseUrl">https://api.youraiconnector.com</span>/v1/contacts/contact_abc123/link?apiKey=YOUR_API_KEY"
```

**Response**

```json
{ "success": true }
```

---

## Fetch a contact's profile picture

`POST /contacts/{contactId}/profile-pic`

Fetches (and caches) the contact's WhatsApp or Meta profile photo on demand — the same photo returned as `avatarUrl` on [Get a contact](#get-a-contact-by-phone-or-email), refreshed.

```bash
curl -X POST "<span data-t="apiBaseUrl">https://api.youraiconnector.com</span>/v1/contacts/contact_abc123/profile-pic?apiKey=YOUR_API_KEY"
```

**Response**

```json
{
  "success": true,
  "avatar_url": "https://example.com/photo.jpg",
  "cached": false
}
```

`cached: true` means the URL came from a recent fetch rather than a fresh provider lookup — pictures are cached for 7 days, and a contact the provider reports has no reachable photo is cached as unavailable for 24 hours. When there's no picture to fetch, `avatar_url` is omitted and `message` explains why.

---

## Auto-tag contacts with AI

Runs your account's tag rules over one or more contacts' full conversation history and applies (or removes) tags exactly like the realtime tagging that runs during a live chat — same rules, same per-tag credit cost.

### Start a run

`POST /contacts/auto-tag`

| Field | Required | Description |
|---|---|---|
| `scope` | Yes | `"contacts"` to tag specific contacts, or `"agent"` to tag every conversation currently handled by one AI agent. |
| `contact_ids` | Required when `scope` is `"contacts"` | Array of contact IDs, 1 to 500. |
| `agent_id` | Required when `scope` is `"agent"` | The AI agent whose conversations to tag. When `scope` is `"contacts"`, this is optional and just narrows which of the agent's tag rules run. |

```bash
curl -X POST "<span data-t="apiBaseUrl">https://api.youraiconnector.com</span>/v1/contacts/auto-tag?apiKey=YOUR_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{ "scope": "contacts", "contact_ids": ["contact_abc123", "contact_def456"] }'
```

A **single** contact runs inline and returns the result right away:

```json
{ "success": true, "result": { "tags_applied": 2, "tags_removed": 0 } }
```

**Two or more** contacts (or `scope: "agent"`) run as a background job and return `202` immediately:

```json
{ "success": true, "run_id": "m1x2y3-a1b2c3d4", "total": 214 }
```

### Poll a run

`GET /contacts/auto-tag/run`

Returns the account's current (or most recent) run, so you can poll progress without tracking `run_id` yourself.

```bash
curl "<span data-t="apiBaseUrl">https://api.youraiconnector.com</span>/v1/contacts/auto-tag/run?apiKey=YOUR_API_KEY"
```

**Response**

```json
{
  "success": true,
  "run": {
    "run_id": "m1x2y3-a1b2c3d4",
    "status": "running",
    "total": 214,
    "processed": 58,
    "tagged_contacts": 12,
    "tags_applied": 15,
    "tags_removed": 2,
    "credits_charged": 15
  }
}
```

`run` is `null` when the account has never started one. `status` moves from `"running"` to `"completed"` or `"failed"`.

Only one bulk run can be in progress per account at a time — starting a second one while another is running returns `409` with `error_code: "auto_tag_run_in_progress"`. Running out of credits on a single-contact run returns `402` with `error_code: "insufficient_credits"`; a bulk run instead stops itself early and reports how far it got in `run`.

---

## Delete a contact

`DELETE /contacts/{contactId}`

Permanently deletes one contact by ID, along with its message history. **This cannot be undone.** To delete several contacts in a single call, use [Delete contacts](#delete-contacts) below.

**cURL**

```bash
curl -X DELETE "<span data-t="apiBaseUrl">https://api.youraiconnector.com</span>/v1/contacts/contact_abc123?apiKey=YOUR_API_KEY"
```

**JavaScript**

```javascript
const res = await fetch("<span data-t="apiBaseUrl">https://api.youraiconnector.com</span>/v1/contacts/contact_abc123", {
  method: "DELETE",
  headers: { "X-API-Key": "YOUR_API_KEY" },
});
const data = await res.json();
console.log(data.success);
```

**Python**

```python
import requests

res = requests.delete(
    "<span data-t="apiBaseUrl">https://api.youraiconnector.com</span>/v1/contacts/contact_abc123",
    headers={"X-API-Key": "YOUR_API_KEY"},
)
print(res.json()["success"])
```

**Response**

```json
{
  "success": true
}
```

A contact ID that doesn't exist on your account, or belongs to a different account, returns a `404`.

---

## Delete contacts

`DELETE /contacts`

Permanently deletes one or more contacts by ID in a single call (up to 500 IDs). IDs that don't exist on your account are skipped and counted in `skipped`. **This cannot be undone.**

| Field | Description |
|---|---|
| `contactIds` | Array of contact IDs to delete (max 500). |

**cURL**

```bash
curl -X DELETE "<span data-t="apiBaseUrl">https://api.youraiconnector.com</span>/v1/contacts?apiKey=YOUR_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{ "contactIds": ["contactId1", "contactId2"] }'
```

**JavaScript**

```javascript
const res = await fetch("<span data-t="apiBaseUrl">https://api.youraiconnector.com</span>/v1/contacts", {
  method: "DELETE",
  headers: {
    "X-API-Key": "YOUR_API_KEY",
    "Content-Type": "application/json",
  },
  body: JSON.stringify({ contactIds: ["contactId1", "contactId2"] }),
});
const data = await res.json();
console.log(`Deleted ${data.deleted}, skipped ${data.skipped}`);
```

**Python**

```python
import requests

res = requests.delete(
    "<span data-t="apiBaseUrl">https://api.youraiconnector.com</span>/v1/contacts",
    headers={"X-API-Key": "YOUR_API_KEY"},
    json={"contactIds": ["contactId1", "contactId2"]},
)
data = res.json()
print(f"Deleted {data['deleted']}, skipped {data['skipped']}")
```

**Response**

```json
{
  "success": true,
  "deleted": 2,
  "skipped": 0
}
```

---

## Delete a custom field

`DELETE /contacts/custom-fields/{fieldKey}`

Removes one custom field key from **every** contact on your account. Use this to clean up after renaming or retiring a custom field. The key may contain letters, numbers, underscores, and hyphens only. Returns how many contacts were updated. **This cannot be undone.**

**cURL**

```bash
curl -X DELETE "<span data-t="apiBaseUrl">https://api.youraiconnector.com</span>/v1/contacts/custom-fields/webinar_date_nh?apiKey=YOUR_API_KEY"
```

**JavaScript**

```javascript
const res = await fetch("<span data-t="apiBaseUrl">https://api.youraiconnector.com</span>/v1/contacts/custom-fields/webinar_date_nh", {
  method: "DELETE",
  headers: { "X-API-Key": "YOUR_API_KEY" },
});
const data = await res.json();
console.log(`Removed from ${data.updated} contacts`);
```

**Python**

```python
import requests

res = requests.delete(
    "<span data-t="apiBaseUrl">https://api.youraiconnector.com</span>/v1/contacts/custom-fields/webinar_date_nh",
    headers={"X-API-Key": "YOUR_API_KEY"},
)
print(f"Removed from {res.json()['updated']} contacts")
```

**Response**

```json
{
  "success": true,
  "updated": 42
}
```

::: note
**Note:** A field key with unsupported characters returns a `400`.
:::


---

## Lists

Lists group contacts. A list is either **static** (you decide who's on it) or **smart** (membership is computed from rules and kept up to date automatically — see [Organizing Lists & Contacts](../get-started/list-and-contact-management.md#smart-lists-auto-updating)).

| Field | Description |
|---|---|
| `name` | Required on create. Up to 100 characters. |
| `status` | `live` (default) or `draft`. Lowercase. |
| `contact_ids` | Array of contact IDs to put on the list. **Static lists only.** |
| `type` | `static` (default) or `smart`. |
| `smart_rules` | The rule set — required when `type` is `smart`. See below. |

### Create a list

`POST /lists`

```bash
curl -X POST "<span data-t="apiBaseUrl">https://api.youraiconnector.com</span>/v1/lists?apiKey=YOUR_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
        "name": "Hot leads (active)",
        "type": "smart",
        "smart_rules": {
          "match": "all",
          "conditions": [
            { "field": "tags", "op": "has_any", "value": ["tagHotLead"] },
            { "field": "last_activity_at", "op": "within_last", "value": { "amount": 90, "unit": "days" } }
          ]
        }
      }'
```

**Response**

```json
{
  "success": true,
  "list_id": "list_abc123",
  "evaluation": { "added": 3, "removed": 0, "total": 3 }
}
```

A smart list is evaluated **inline**, in the same request, so `evaluation` tells you exactly who ended up on it. On a static list `evaluation` is `null`.

### Update a list

`PUT /lists/{listId}`

Send only the fields you're changing. Changing `smart_rules` re-evaluates the list immediately and returns the same `evaluation` object.

```bash
curl -X PUT "<span data-t="apiBaseUrl">https://api.youraiconnector.com</span>/v1/lists/list_abc123?apiKey=YOUR_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{ "smart_rules": { "match": "any", "conditions": [ { "field": "tags", "op": "has_any", "value": ["tagHotLead", "tagWebinar"] } ] } }'
```

You can switch a list between the two kinds:

- **Static → smart**: send `{ "type": "smart", "smart_rules": { … } }`. The rules take over on the spot.
- **Smart → static**: send `{ "type": "static" }`. The rules are dropped and whoever is on the list stays on it.

### The `smart_rules` shape

```json
{
  "match": "all",
  "conditions": [
    { "field": "tags", "op": "has_any", "value": ["tagHotLead"] },
    { "field": "channel", "op": "is_any", "value": ["whatsapp", "sms"] },
    { "field": "last_incoming_message_at", "op": "not_within_last", "value": { "amount": 7, "unit": "days" } },
    { "field": "created_at", "op": "after", "value": "2026-01-01" },
    { "field": "is_bot_active", "op": "is", "value": true },
    { "field": "email", "op": "is_set" },
    { "field": "custom_field", "key": "Plan", "op": "eq", "value": "pro" }
  ]
}
```

- `match` — `all` (every condition must be true) or `any` (at least one).
- `conditions` — 1 to 20 conditions, each at most 100 values, strings up to 200 characters.

| `field` | `op` | `value` |
|---|---|---|
| `tags` | `has_any`, `has_all`, `has_none` | array of tag IDs |
| `lists` | `in_any`, `not_in_any` | array of list IDs (**static lists only** — a smart list cannot be built from another smart list) |
| `channel` | `is_any`, `is_none` | array of channels |
| `status` | `is_any`, `is_none` | array of contact statuses |
| `created_at`, `last_activity_at`, `last_incoming_message_at`, `last_outgoing_message_at`, `first_ai_interaction_at`, `last_ai_interaction_at` | `within_last`, `not_within_last` | `{ "amount": 1–3650, "unit": "hours" \| "days" }` |
| same date fields | `before`, `after` | ISO date (`"2026-01-01"`, compared as whole days) or full ISO date-time (`"2026-01-01T14:30:00Z"`, compared to the exact moment) |
| same date fields | `is_set`, `not_set` | — |
| `has_interacted_with_ai` | `is` | `true` / `false` — `true` matches contacts the AI has messaged at least once (ever) |
| `is_bot_active`, `do_not_disturb`, `is_private`, `has_ever_responded` | `is` | `true` / `false` |
| `email`, `phone_number`, `first_name`, `last_name` | `is_set`, `not_set`, `contains`, `not_contains` | string for the `contains` forms |
| `current_campaign_id`, `assigned_agent` | `is_any`, `is_none`, `is_set`, `not_set` | array of IDs for the `is_any` / `is_none` forms |
| `custom_field` (plus a `key`) | `eq`, `neq`, `contains`, `not_contains`, `is_set`, `not_set` | string for the value forms |

`not_within_last` also matches contacts the date was never set on ("more than N ago, **or never**"), and text comparisons ignore upper/lower case.

**AI engagement.** `has_interacted_with_ai` is the lifetime flag: `true` for every contact your AI has sent at least one message to, `false` for everyone else (including contacts only your team ever answered). It is stamped on the AI's first message to a contact and never cleared, so switching the contact's AI replies off or moving them to another campaign does not reset it. For a *period* — "the contacts my AI handled this month", the usual billing question — range over `last_ai_interaction_at` instead:

```json
{ "field": "last_ai_interaction_at", "op": "within_last", "value": { "amount": 30, "unit": "days" } }
```

Do not confuse either with `is_bot_active` (the AI is *allowed* to reply, not that it has) or `has_ever_responded` (the *contact* wrote back, to anyone). The same two stamps are returned on every contact as `first_ai_interaction_at` / `last_ai_interaction_at`, and the whole rule set works on `GET /contacts?rules=` too, so you can count matches without creating a list.

### Preview a rule set

`POST /lists/preview`

Counts and samples the contacts a rule set would match, without creating or changing anything. Use it to sanity-check rules before you save them.

```bash
curl -X POST "<span data-t="apiBaseUrl">https://api.youraiconnector.com</span>/v1/lists/preview?apiKey=YOUR_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{ "smart_rules": { "match": "all", "conditions": [ { "field": "tags", "op": "has_any", "value": ["tagHotLead"] } ] } }'
```

**Response**

```json
{
  "success": true,
  "count": 3,
  "sample": [
    {
      "id": "contact_abc123",
      "first_name": "Sofia",
      "last_name": "Martinez",
      "phone_number": "+31600000000",
      "email": "sofia@example.com",
      "channel": "whatsapp"
    }
  ]
}
```

`sample` holds up to 10 contacts, most recently active first.

### Re-run a smart list now

`POST /lists/{listId}/evaluate`

Forces an immediate re-evaluation (the same thing **Refresh now** does in the dashboard). Smart lists already update when a contact changes, and every 15 minutes for time-based rules, so this is only needed when you want the result *right now*.

**Response**

```json
{
  "success": true,
  "list_id": "list_abc123",
  "evaluation": { "added": 2, "removed": 1, "total": 4 }
}
```

`evaluation.skipped: true` means another evaluation of the same list was already running and this call did nothing.

### Smart lists refuse hand-picked members

Membership endpoints return **`409`** with `"This is a smart list — its members are computed from its rules. Edit the rules instead."` when the target list is smart. That covers `POST /contacts/lists`, `DELETE /contacts/lists`, `POST /contacts/lists/batch`, `contact_ids` on `POST /lists` and `PUT /lists/{listId}`, and choosing a smart list as a CSV import target. Change the rules instead.

Calling `POST /lists/{listId}/evaluate` on a **static** list is also a `409` — it has no rules to run.

---

## Contacts API errors

Contact endpoints return the standard error envelope:

```json
{
  "success": false,
  "error": "Contact not found"
}
```

Some endpoints also include `error_code`, which usually matches the HTTP status — the one exception is the duplicate-contact case below, where the HTTP status is `200` and only `error_code` carries the `409`. The codes specific to contact endpoints:

| Code | When it happens on a contact endpoint |
|---|---|
| `400` | Bad request — a missing/invalid field, empty body, bad cursor, or over 500 IDs in a batch. |
| `402` | Not enough credits to complete an AI-tagging run on one contact (`error_code: "insufficient_credits"`). |
| `404` | The contact, list, or tag wasn't found on your account. |
| `409` | A contact with that phone number already exists (on create). Returned as `error_code` in the body with an HTTP status of `200`, so branch on `error_code` here. Also returned when a bulk auto-tag run is already in progress (`error_code: "auto_tag_run_in_progress"`), or when linking a contact to another channel would join two contacts already linked to two different people. |
| `422` | The contact can't receive a message right now (do-not-disturb, private, or unsupported channel). On the channel-link endpoint, also covers no phone number, an unsupported channel pairing, or no connected sender for the target channel. |

A `403` on a contact endpoint can also mean a contact-limit or list-permission problem rather than plan access. The shared codes every endpoint can return — `401`, `403` (your plan does not include API access), `429` (rate limit) and `500` — are listed with retry guidance in [Errors & Pagination](errors-and-pagination.md).

---

## Next steps

- [Messages API](messages.md) — send messages by channel identity and manage conversations.
- [API Reference](reference.md) — full endpoint list, including tags and lists.
- [API Access](../integrations/api-access.md) — authentication, rate limits, and error handling.
