Your AI Connector Docs

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 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), or page through all your contacts (see 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

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

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

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

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

{ "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 emailGET /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 mode instead.

cURL

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

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

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

{
  "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

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

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

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

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

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

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

{
  "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

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

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

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

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

# 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

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

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

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

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

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

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

{
  "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.


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. 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

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

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

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

{
  "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 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.
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

{ "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.

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

Response

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

Delete several tags at once

DELETE /tags

Field Description
tagIds Array of tag IDs to delete (max 1000).
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

{ "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

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

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

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

{
  "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

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

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

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

{
  "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):

{
  "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 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.
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

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

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)

{
  "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}

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

Response

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

status moves through queuedprocessingcompleted, 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.

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

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

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)

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

Poll the export job

GET /contacts/export/{jobId}

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

Response

{
  "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

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

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

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

{
  "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.


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

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

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

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

{
  "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.
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

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

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

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

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

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

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

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

{
  "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.


“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.

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.
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

{
  "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.

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

Response

{
  "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"
      }
    }
  ]
}

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.

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

Response

{ "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, refreshed.

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

Response

{
  "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.
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:

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

{ "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.

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

Response

{
  "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 below.

cURL

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

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

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

{
  "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

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

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

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

{
  "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

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

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

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

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

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).

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

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

{
  "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.

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

{
  "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" }
  ]
}
  • matchall (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 / falsetrue 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:

{ "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.

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

{
  "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

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

{
  "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.


Next steps

  • Messages API — send messages by channel identity and manage conversations.
  • API Reference — full endpoint list, including tags and lists.
  • API Access — authentication, rate limits, and error handling.