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
200and anerror_codeof409in the body, so branch onerror_coderather 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_codeof409, look it up with 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 the9after+54). The duplicate check on create andGET /contacts?phoneNumber=match across both spellings, so you get the existing contact back whichever form you send. Thephone_numberstored 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" }.
avatarUrlis 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’snullfor 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 calledavatar_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 setstier— 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 reasoncontact_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
csvStoragePathbefore 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 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.
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
statusis"completed"you getexport_idandcontact_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
422and an explanatoryerror.
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
404or403. 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.
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. |
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"
}
}
]
}
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.
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" }
]
}
match—all(every condition must be true) orany(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:
{ "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.