Messages & Conversations
The Messages API lets you send a message to any contact, read back a conversation, correct or remove a message you already sent, react to one, pull a full chat-session thread, export a transcript, and mark chats read or unread — all without opening the inbox.
All paths on this page are relative to the base URL https://api.youraiconnector.com/v1. Every request needs your API key — see Authentication for the full list of ways to send it. The examples below use the X-API-Key header, with one cURL example showing the ?apiKey= query form too.
How delivery works: Sending a message does not wait for it to arrive. The API accepts your message, returns immediately with a message ID, and then delivers it in the background on the contact’s channel (WhatsApp, SMS, Instagram, and so on). To track whether a message was actually delivered or read, listen for status updates with Webhooks — don’t poll. The send response only confirms the message was accepted.
Send a message
There are two ways to send. Pick whichever fits how you already identify the contact:
- Send by contact ID — you already know the contact’s ID (for example, you created the contact through the API or got it from a webhook). Use
POST /contacts/{contactId}/send-message. - Send by contact identity — you know the contact’s phone number, Instagram ID, etc., but not their internal ID. Use
POST /contacts/sendand let the platform find the right contact.
Both queue the message the same way and deliver it on whichever channel the contact is on. You don’t pick a transport — the platform routes WhatsApp contacts over WhatsApp, SMS contacts over SMS, and so on.
Both also only find contacts that already exist. Neither call creates one: an ID or an identity nothing matches comes back as a 404, it does not become a new contact. So if you’re messaging someone for the first time, either create the contact first with POST /contacts, or, on WhatsApp Web, use Send to a WhatsApp Web number directly below, which creates the contact in the same call.
Send by contact ID
POST /contacts/{contactId}/send-message
| Field | Required | Description |
|---|---|---|
body |
Yes | The message text to send, up to 65,536 characters. A body longer than the channel allows in one message (1,500 on the WhatsApp Business API and SMS, 1,000 on Instagram, 2,000 on Messenger, 4,096 on Telegram and LinkedIn, 5,000 on LINE, 6,000 on TikTok, 7,000 on Viber, 65,536 on WhatsApp Web; no limit on email, the chat widget and custom channels) is delivered as several messages in order, split at paragraph or sentence breaks. The response then carries messageIds for every part and autoSplit: true. |
mediaUrl |
No | URL of a media file (image, audio, video, document) to attach. |
mediaContentType |
No | MIME type of the attached media, e.g. image/jpeg. Worked out from the URL’s file extension when omitted (.mp3 → audio/mpeg, .pdf → application/pdf); set it for a URL without an extension. |
sendAsVoiceNote |
No | WhatsApp Web only, audio only: deliver the audio as a voice note (the play bubble). Audio that is not OGG/Opus is converted at send time. Defaults to false. |
pauseBot |
No | true pauses the AI for this contact as the message is sent — for a human taking over. See Pause or resume the AI. |
clearIncompleteReply |
No | true discards a half-finished bot reply so it doesn’t resume after your message. |
human_reply |
No | true marks the message as written by a person on your team rather than by an integration. The AI then treats it as a teammate’s reply in the conversation, and a reply the AI had queued for the contact’s last message is cancelled so the contact doesn’t get two answers. It does not pause the AI; use pauseBot for that. Defaults to false. |
On LinkedIn, a message you send this way, or from the inbox, is delivered even while a person is handling the thread on the LinkedIn side. The AI’s own replies and its follow-ups are held back on such a thread. A held reply is removed from the chat rather than shown as failed, because nothing was sent and no credit is used. The AI’s last reply before it hands a thread to a person is still delivered.
cURL
curl -X POST "https://api.youraiconnector.com/v1/contacts/contact123/send-message" \
-H "X-API-Key: YOUR_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"body": "Hi! Your appointment is confirmed for tomorrow at 10:00."
}'
JavaScript
const res = await fetch(
"https://api.youraiconnector.com/v1/contacts/contact123/send-message",
{
method: "POST",
headers: {
"X-API-Key": "YOUR_API_KEY",
"Content-Type": "application/json",
},
body: JSON.stringify({
body: "Hi! Your appointment is confirmed for tomorrow at 10:00.",
}),
}
);
const data = await res.json();
console.log(data.messageId);
Python
import requests
res = requests.post(
"https://api.youraiconnector.com/v1/contacts/contact123/send-message",
headers={
"X-API-Key": "YOUR_API_KEY",
"Content-Type": "application/json",
},
json={"body": "Hi! Your appointment is confirmed for tomorrow at 10:00."},
)
print(res.json()["messageId"])
Response (200 OK):
{
"success": true,
"messageId": "aB3dE5fG7hI9jK1lM2nO",
"contactId": "contact123",
"channel": "whatsapp",
"message": "Message created successfully. Delivery is being processed."
}
Send by contact identity
POST /contacts/send
Use this when you don’t have the contact’s internal ID. Provide the message body plus either a contact_id, or a channel together with the identity field that matches that channel.
| Field | Required | Description |
|---|---|---|
body |
Yes | The message text to send, up to 65,536 characters. A body longer than the channel allows in one message (1,500 on the WhatsApp Business API and SMS, 1,000 on Instagram, 2,000 on Messenger, 4,096 on Telegram and LinkedIn, 5,000 on LINE, 6,000 on TikTok, 7,000 on Viber, 65,536 on WhatsApp Web; no limit on email, the chat widget and custom channels) is delivered as several messages in order, split at paragraph or sentence breaks. The response then carries messageIds for every part and autoSplit: true. |
contact_id |
No | ID of an existing contact. When set, the identity fields below are not needed. |
channel |
No | Channel to send on. Required when contact_id is not given. One of the 14 outbound-sendable channels: whatsapp, whatsapp_web, sms, instagram, instagram_private, messenger, telegram, chat-widget, custom, email, line, imessage, linkedin, viber. |
phone_number |
No | Contact’s phone number in international format. Used with whatsapp, whatsapp_web, and sms. |
instagram_id |
No | Contact’s Instagram user ID. Used with instagram. |
messenger_id |
No | Contact’s Messenger user ID. Used with messenger. |
telegram_user_id |
No | Contact’s Telegram user ID. Used with telegram. |
media_url |
No | URL of a media file to attach. |
media_content_type |
No | MIME type of the attached media, e.g. image/jpeg. |
Which channels can be resolved by identity. Only six of the 14 accept an identity field instead of a contact_id: whatsapp, whatsapp_web and sms are looked up by phone_number, instagram by instagram_id, messenger by messenger_id, and telegram by telegram_user_id. The other eight — instagram_private, chat-widget, custom, email, line, imessage, linkedin and viber — have no public identity to look up, so sending on those channels requires contact_id; passing channel alone returns a 400 telling you contact_id is required.
cURL (using the ?apiKey= query form)
curl -X POST "https://api.youraiconnector.com/v1/contacts/send?apiKey=YOUR_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"channel": "whatsapp",
"phone_number": "+31612345678",
"body": "Hi! Your appointment is confirmed."
}'
JavaScript
const res = await fetch("https://api.youraiconnector.com/v1/contacts/send", {
method: "POST",
headers: {
"X-API-Key": "YOUR_API_KEY",
"Content-Type": "application/json",
},
body: JSON.stringify({
channel: "whatsapp",
phone_number: "+31612345678",
body: "Hi! Your appointment is confirmed.",
}),
});
const data = await res.json();
console.log(data.message_id, data.channel);
Python
import requests
res = requests.post(
"https://api.youraiconnector.com/v1/contacts/send",
headers={
"X-API-Key": "YOUR_API_KEY",
"Content-Type": "application/json",
},
json={
"channel": "whatsapp",
"phone_number": "+31612345678",
"body": "Hi! Your appointment is confirmed.",
},
)
data = res.json()
print(data["message_id"], data["channel"])
Response (201 Created):
{
"success": true,
"message_id": "aB3dE5fG7hI9jK1lM2nO",
"contact_id": "contact123",
"channel": "whatsapp"
}
Why a message might be rejected: A contact with do-not-disturb or private mode turned on cannot receive outbound messages — the request fails with a
422. If no contact matches the ID or identity you provided, you get a404.
Sending on WhatsApp Web: variations and burst protection
Two things apply to body when the contact is on WhatsApp Web.
Spintax braces are expanded per send. A body of {Hi|Hello|Hey} there, quick question goes out as one of the three greetings, chosen for that send — the braces never reach the contact. Use it to give every contact a different opening message from the same integration code. Text without braces is sent exactly as written.
Bursts of identical opening messages are refused. If ten near-identical opening messages go out from one WhatsApp Web number within an hour, to contacts who have never written to you, further ones are not delivered: the send is accepted as usual (you still get a messageId), and the message is then left with status: "failed" and error_code: "opener_burst", with a customer-readable reason on it. Retrying the same text does not help — vary the text, wait an hour, and send again. Replies inside existing conversations are never affected. The reasoning is in Identical openers get numbers restricted.
Send to a WhatsApp Web number directly
POST /whatsapp-web/send
Send to a phone number on WhatsApp Web without looking a contact up first. If the number isn’t a contact yet, the contact is created in the same call, with the AI switched off on it, so the message reads as one you sent by hand and no assistant takes the conversation over afterwards. An existing contact is reused as it is.
| Field | Required | Description |
|---|---|---|
phoneNumber |
Yes | The number to message, in international format (+31612345678). |
message |
No | The text to send. Either message or mediaUrl must be there. |
mediaUrl |
No | Public URL of a file to send. Either message or mediaUrl must be there. |
mediaContentType |
No | MIME type of that file, for example image/jpeg. Worked out from the URL when you leave it off. |
sendAsVoiceNote |
No | true sends an audio file as a voice note rather than as an attachment. |
fromPhoneNumber |
No | Which of your connected WhatsApp Web numbers to send from, in international format. Defaults to the number the contact already belongs to. |
campaignId |
No | Take the active hours from this Agent instead of the account’s default. Must be an Agent on the same account. |
ignoreActiveHours |
No | true sends straight away, whatever the active hours say. |
sub_account_id |
No | For agencies: send on behalf of one of your client accounts. |
curl -X POST "https://api.youraiconnector.com/v1/whatsapp-web/send?apiKey=YOUR_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"phoneNumber": "+31612345678",
"message": "Hi Alex, your quote is ready."
}'
A successful call answers 202:
{ "success": true, "queued": true, "contactId": "abc123" }
202 means accepted, not delivered yet. Outside active hours the send is held and goes out in the next allowed slot, unless you passed ignoreActiveHours. Read the message’s own status back with List a contact’s messages using the contactId you got here.
When it doesn’t go through:
| Status | Code | What happened |
|---|---|---|
400 |
INVALID_PHONE_FORMAT |
The number isn’t in international format, or phoneNumber is missing, or both message and mediaUrl are. |
400 |
CONTACT_DND |
That contact is on Do not disturb, so nothing outbound is sent to them. |
400 |
CONTACT_PRIVATE |
That contact is marked private. |
404 |
CAMPAIGN_NOT_FOUND |
No Agent matches the campaignId you passed. |
403 |
CAMPAIGN_FORBIDDEN |
That Agent belongs to another account. |
402 |
The account has no credits left, or no WhatsApp Web number is connected. |
A second call for the same contact while one held send is still waiting answers 409, so a queue never stacks up behind one person.
Log a note into a conversation
POST /contacts/notes
Adds a line to a contact’s conversation without sending it. Use it for context your team and the AI should have but the contact should never receive: a summary of a phone call your voice agent just made, a remark from your CRM, or a note from a colleague. It works on every channel (WhatsApp, SMS, Instagram, Messenger, Telegram, the chat widget, custom channels and so on).
What happens to a note:
- It shows in the chat in the inbox as a yellow Note card, with the author if you sent one.
- The AI reads it as part of the conversation history, marked as an internal note, so its next reply takes it into account.
- It is never sent to the contact, costs no message credits, and triggers nothing: no AI reply, no automation, no webhook, no notification, no unread badge. It does not pause the AI and does not count as a reply from your team.
Find the contact the same way as Send by contact identity: either its ID, or a channel plus the matching identifier. A note only goes to a contact that already exists; nothing matching is a 404.
| Field | Required | Description |
|---|---|---|
contact_id |
One of the two | The contact’s ID. |
channel + identifier |
One of the two | channel plus phone_number (WhatsApp, WhatsApp Web, SMS), instagram_id, messenger_id or telegram_user_id, as in Send by contact identity. |
body |
Yes | The note text, up to 4,000 characters. Line breaks are kept. |
author |
No | Who or what wrote it, up to 80 characters, for example Voice agent or Sarah (sales). Shown on the card and to the AI. |
timestamp |
No | When it happened, in epoch milliseconds. Defaults to now. Use the call’s end time so the note sits in the right place in the thread. It cannot be more than 5 minutes in the future; a later time is rejected with a 400. |
external_id |
No | Your own ID for this note, up to 200 characters, for example the call ID. Sending the same external_id for the same contact again does not add a second note; the response then carries duplicate: true. Use it so a retried webhook cannot log the same call twice. |
cURL
curl -X POST "https://api.youraiconnector.com/v1/contacts/notes" \
-H "X-API-Key: YOUR_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"channel": "whatsapp",
"phone_number": "+31612345678",
"author": "Voice agent",
"body": "Voice call, 3:12. Asked about knee surgery waiting times, prefers a call back on Tuesday morning.",
"external_id": "call_8f2k1"
}'
Response (201, or 200 with duplicate: true when the external_id was already logged)
{
"success": true,
"messageId": "note_4c1f0e...",
"contactId": "contact123"
}
A note appears in List a contact’s messages with type: "note".
Generate a reply draft with the AI
POST /contacts/{contactId}/draft-reply
Asks the AI agent that handles a conversation to write the next reply, and gives you the text back. Nothing is sent. It is the same draft the Generate Reply button in the inbox writes: the agent uses its setup, its knowledge base and the conversation so far, and answers in the contact’s language. Use it to put a human (or your own app) in between the AI and the contact: fetch the draft, show it for approval, then send it with Send by contact ID, changed or as it is.
- The draft is written by the agent assigned to the contact. A contact that has no agent yet gets the agent that would answer its next message.
- While drafting, the agent does not run any tools or functions, so no booking, tag, hand off or other action happens.
- Nothing is stored in the conversation, and the contact sees nothing.
- A draft costs the same credits as one AI reply from that agent in this conversation, and nothing when your own AI key is connected.
- The conversation needs at least one message.
All body fields are optional; an empty body (or no body) drafts the next reply.
| Field | Required | Description |
|---|---|---|
instruction |
No | A one-time direction for this draft, up to 2,000 characters, the same as long-pressing Generate Reply in the inbox. For example Offer Tuesday at 3pm or Ask for their email address. |
campaign_id |
No | Draft under this campaign instead of the contact’s agent. |
purpose |
No | chat_reply (default) writes a message to the contact. faq_answer writes a short answer to the contact’s question that works on its own, for a knowledge base FAQ. |
cURL
curl -X POST "https://api.youraiconnector.com/v1/contacts/contact123/draft-reply" \
-H "X-API-Key: YOUR_API_KEY" \
-H "Content-Type: application/json" \
-d '{ "instruction": "Offer Tuesday at 3pm for the consultation" }'
Response (200)
{
"success": true,
"contact_id": "contact123",
"draft": "Hi Sara, Tuesday at 3pm works for the consultation. Shall I put you down for it?"
}
A 400 means the conversation has no messages yet, your credits ran out, or the contact has no agent or campaign to draft from (error says which). A 404 means the contact does not exist, or a team member’s key is limited to contacts it cannot see. The key or team member needs permission to edit messages.
Using an AI client over MCP? The same action is available as the
generate_reply_drafttool.
List a contact’s messages
GET /contacts/{contactId}/messages
Returns a contact’s messages, newest first, with cursor-based pagination.
| Query parameter | Required | Description |
|---|---|---|
limit |
No | Page size. Default 50, maximum 100. |
cursor |
No | The next_cursor value from a previous response. Returns messages older than the cursor. |
filter |
No | Filter by content type: all (default), text, media, or tool_use. |
direction |
No | Filter by direction: all (default), inbound (received from the contact), or outbound (sent by you). |
Note on filtering and pagination: The
filteranddirectionfilters are applied to each page after it’s read, so a filtered page can contain fewer items thanlimit. Thenext_cursorstill advances through the full conversation, so keep paging untilnext_cursorisnull.
cURL
curl "https://api.youraiconnector.com/v1/contacts/contact123/messages?limit=50&direction=inbound" \
-H "X-API-Key: YOUR_API_KEY"
JavaScript
const params = new URLSearchParams({ limit: "50", direction: "inbound" });
const res = await fetch(
`https://api.youraiconnector.com/v1/contacts/contact123/messages?${params}`,
{ headers: { "X-API-Key": "YOUR_API_KEY" } }
);
const data = await res.json();
console.log(data.messages, data.next_cursor);
Python
import requests
res = requests.get(
"https://api.youraiconnector.com/v1/contacts/contact123/messages",
headers={"X-API-Key": "YOUR_API_KEY"},
params={"limit": 50, "direction": "inbound"},
)
data = res.json()
print(data["messages"], data["next_cursor"])
Response (200 OK):
{
"success": true,
"contact_id": "contact123",
"messages": [
{
"id": "aB3dE5fG7hI9jK1lM2nO",
"body": "Hi! Thanks for reaching out.",
"direction": "inbound",
"channel": "whatsapp",
"status": "delivered",
"type": null,
"timestamp": "2026-06-01T10:00:00.000Z",
"media_url": null,
"media_content_type": null,
"bot_reply": false
}
],
"next_cursor": "cD4eF6gH8iJ0kL2mN3oP"
}
Message fields
| Field | Description |
|---|---|
id |
Unique ID of the message. |
body |
Text content of the message. For a message with an attachment, body starts with a bracketed note of what arrived, followed by any caption, for example [THE CONTACT UPLOADED AN IMAGE] Here is my CV or [THE CONTACT UPLOADED A VOICE NOTE] - [TRANSCRIPTION: ...]. Voice notes and videos include the transcription. |
direction |
inbound (received from the contact) or outbound (sent by your account). |
channel |
Channel the message was sent or received on (e.g. whatsapp, sms, instagram). |
status |
Current delivery status, e.g. Created, sent, delivered, read, failed. |
type |
Message type. Plain text messages have a null type; automated assistant tool activity is marked tool_use. |
timestamp |
ISO 8601 time the message was created. |
media_url |
URL of an attached media file, if any. This link stops working 7 days after the message arrived, on every channel (WhatsApp Business API, WhatsApp Web, Instagram, Messenger, Telegram, chat widget and the rest). Get a fresh one with Download a message’s attachment. |
media_content_type |
MIME type of the attached media, if any. |
bot_reply |
true when the message was generated by the AI assistant. |
score |
Your rating of the message: 1 thumbs up, -1 thumbs down, 0 when it has not been rated. See Rate or star a message. |
is_important |
true when the message has been starred. |
is_deleted |
true when the message was deleted. Deleted messages stay in the list but their body and media_url are empty. |
reactions |
Emoji reactions on the message, from either side. Always an array — empty when there are none. Each entry has emoji, from_phone_number, from_me (true when the reaction is yours) and reacted_at. |
faq_ids_used |
IDs of the Knowledge Base FAQs the AI had in front of it when it wrote this reply: the ones picked for the latest message plus the ones kept from earlier in the conversation. The same list is on every message of a reply that was sent in several parts. Always an array, empty for inbound messages, messages sent by people, automatic follow-ups and AI replies sent before 25 September 2026. |
faqs_used |
The same FAQs with their question: each entry has id and question. question is null when the FAQ has been deleted since. |
Download a message’s attachment
POST /chat-exports/media/download-url
Gives you a fresh download link for the file attached to a message: an image, voice note, video or document. The link works for 1 hour. Use it when you want to save attachments somewhere else (for example, filing CVs or voice notes a contact sent), or when the media_url on a message or a New Message webhook no longer opens.
To find messages with attachments, list a contact’s messages with filter=media. Each message’s id and the contact’s ID make up the messagePath.
| Body field | Required | Description |
|---|---|---|
messagePath |
Yes | Which message, in the form contacts/{contactId}/messages/{messageId}. |
cURL
curl -X POST "https://api.youraiconnector.com/v1/chat-exports/media/download-url" \
-H "X-API-Key: YOUR_API_KEY" \
-H "Content-Type: application/json" \
-d '{"messagePath": "contacts/contact123/messages/aB3dE5fG7hI9jK1lM2nO"}'
JavaScript
const res = await fetch("https://api.youraiconnector.com/v1/chat-exports/media/download-url", {
method: "POST",
headers: { "X-API-Key": "YOUR_API_KEY", "Content-Type": "application/json" },
body: JSON.stringify({ messagePath: "contacts/contact123/messages/aB3dE5fG7hI9jK1lM2nO" }),
});
const { data } = await res.json();
console.log(data.url); // download the file within the hour
Python
import requests
res = requests.post(
"https://api.youraiconnector.com/v1/chat-exports/media/download-url",
headers={"X-API-Key": "YOUR_API_KEY"},
json={"messagePath": "contacts/contact123/messages/aB3dE5fG7hI9jK1lM2nO"},
)
file_url = res.json()["data"]["url"]
Response (200 OK):
{
"success": true,
"data": {
"url": "https://storage.googleapis.com/...",
"expiresInMs": 3600000
}
}
| Field | Description |
|---|---|
data.url |
The download link. Fetch the file from it directly, no API key needed. |
data.expiresInMs |
How long the link works, in milliseconds (3600000 = 1 hour). Call the endpoint again for a new link. |
| Status | When |
|---|---|
400 |
messagePath is missing or not in the contacts/{contactId}/messages/{messageId} form, or the message has no attachment that can be downloaded this way. Some older attachments are stored at the channel provider and cannot be re-issued, and a shared Instagram reel or post is a link to Instagram rather than a file. |
403 |
The contact does not belong to your account. |
404 |
No message with that ID exists on that contact. |
Using an AI client over MCP? The same action is available as the
get_chat_media_download_urltool.
List chat sessions
A chat session is one conversation window with a contact. It opens on the first incoming message when no session is open, and it closes automatically 48 hours after the contact’s last incoming message (every new incoming message pushes the close out by another 48 hours). The AI concluding a chat does not close the session; it only fires the Chat Concluded webhook. Sessions are how you page a long history into readable conversations instead of one endless list.
Recent sessions across all contacts
GET /chat-sessions/recent
Returns the sessions that started in the last X hours, newest first, across every contact on the account.
| Query parameter | Required | Description |
|---|---|---|
hours |
Yes | How many hours to look back. Must be a positive whole number. |
status |
No | Only return sessions with this status: ChatSessionOpened or ChatSessionClosed. |
limit |
No | Maximum number of sessions to return. Default 100, maximum 100. |
includeMessages |
No | true adds a messages array to every session. Off by default because it makes the response much larger. |
cURL
curl "https://api.youraiconnector.com/v1/chat-sessions/recent?hours=24&status=ChatSessionClosed" \
-H "X-API-Key: YOUR_API_KEY"
JavaScript
const params = new URLSearchParams({ hours: "24", status: "ChatSessionClosed" });
const res = await fetch(`https://api.youraiconnector.com/v1/chat-sessions/recent?${params}`, {
headers: { "X-API-Key": "YOUR_API_KEY" },
});
const data = await res.json();
console.log(data.data.total_sessions, data.data.sessions);
Python
import requests
res = requests.get(
"https://api.youraiconnector.com/v1/chat-sessions/recent",
headers={"X-API-Key": "YOUR_API_KEY"},
params={"hours": 24, "status": "ChatSessionClosed"},
)
data = res.json()["data"]
print(data["total_sessions"], data["sessions"])
Response (200 OK):
{
"success": true,
"data": {
"hours_ago": 24,
"total_sessions": 2,
"sessions": [
{
"session_id": "session456",
"contact_id": "contact123",
"contact_name": "Jane Doe",
"contact_phone": "+31612345678",
"contact_email": "jane@example.com",
"start_date_time": "2026-06-01T09:55:00.000Z",
"end_date_time": "2026-06-01T10:20:00.000Z",
"status": "ChatSessionClosed",
"tag": "Booking enquiry"
}
]
}
}
All sessions for one contact
GET /chat-sessions/{contactId}
Returns every chat session for a single contact. Same status, limit and includeMessages parameters as above — hours does not apply here.
cURL
curl "https://api.youraiconnector.com/v1/chat-sessions/contact123?limit=20" \
-H "X-API-Key: YOUR_API_KEY"
Response (200 OK):
{
"success": true,
"data": {
"contact_id": "contact123",
"contact_name": "Jane Doe",
"total_sessions": 2,
"sessions": [
{
"id": "session456",
"start_date_time": "2026-06-01T09:55:00.000Z",
"end_date_time": "2026-06-01T10:20:00.000Z",
"status": "ChatSessionClosed",
"tag": "Booking enquiry"
}
]
}
}
Session ID field names differ between the two endpoints. The recent-sessions list calls it
session_id(it also carries the contact’s details, since sessions come from many contacts); the per-contact list calls itid. Either value is what you pass as{sessionId}when fetching the full thread below.
When includeMessages=true, each session gains a messages array whose entries carry id, body, direction, timestamp, type, channel and status.
Fetch a chat-session thread
GET /contacts/{contactId}/chat-sessions/{sessionId}/messages
A chat session groups a contact’s messages into one conversation window. This endpoint returns the full thread of a single session, oldest first, along with the session’s metadata. You can find session IDs for a contact through the chat sessions endpoints.
cURL
curl "https://api.youraiconnector.com/v1/contacts/contact123/chat-sessions/session456/messages" \
-H "X-API-Key: YOUR_API_KEY"
JavaScript
const res = await fetch(
"https://api.youraiconnector.com/v1/contacts/contact123/chat-sessions/session456/messages",
{ headers: { "X-API-Key": "YOUR_API_KEY" } }
);
const data = await res.json();
console.log(data.session, data.messages);
Python
import requests
res = requests.get(
"https://api.youraiconnector.com/v1/contacts/contact123/chat-sessions/session456/messages",
headers={"X-API-Key": "YOUR_API_KEY"},
)
data = res.json()
print(data["session"], data["messages"])
Response (200 OK):
{
"success": true,
"contact_id": "contact123",
"session": {
"id": "session456",
"status": "ChatSessionClosed",
"start_date_time": "2026-06-01T09:55:00.000Z",
"end_date_time": "2026-06-01T10:20:00.000Z",
"tag": "Booking enquiry"
},
"messages": [
{
"id": "aB3dE5fG7hI9jK1lM2nO",
"body": "Hi! Thanks for reaching out.",
"direction": "inbound",
"channel": "whatsapp",
"status": "delivered",
"type": null,
"timestamp": "2026-06-01T09:55:00.000Z",
"media_url": null,
"media_content_type": null,
"bot_reply": false
}
]
}
The session object reports status (ChatSessionOpened while active, ChatSessionClosed once ended), start_date_time, end_date_time, and a human-readable tag. The messages array uses the same message fields as the list endpoint.
Edit, delete and react to messages
These endpoints change a message after it has been sent. Two of them reach out to the contact’s channel as well as your own copy, so read the section intro before wiring them up — what is possible depends entirely on the channel the conversation is on.
What each channel allows
| Action | Channels that can change the contact’s copy | Time limit |
|---|---|---|
| Edit a sent message | Chat widget, WhatsApp Web (text messages only), Telegram, LinkedIn | None on the chat widget, 15 minutes on WhatsApp Web, 48 hours on Telegram, 60 minutes on LinkedIn |
| Delete for everyone | Chat widget, WhatsApp Web, Telegram, LinkedIn | 60 minutes on LinkedIn; the others have no published limit |
| React with an emoji | WhatsApp Web, Telegram | None |
On every other channel — the WhatsApp Business API, SMS, Instagram, Messenger, email, LINE, custom channels — a delete still removes the message from your inbox, but the contact keeps their copy, and editing or reacting is not possible at all.
Edit a message
POST /contacts/{contactId}/messages/{messageId}/edit
Rewrites a message you already sent, on the contact’s device and in your copy.
| Field | Required | Description |
|---|---|---|
body |
Yes | The new message text. Must not be empty and no longer than the channel allows in one message (the same per-channel limits as Send by contact ID; 4,096 when the message has no channel). |
Unlike deleting, this fails loudly when the channel refuses: you get a 409 and your copy is left exactly as the contact has it, because showing an edit they never received would put the two sides out of step. The edit_reason field tells you why — the channel’s edit window has closed, the channel is disconnected, the message has an attachment that the channel cannot edit (media_not_editable: on WhatsApp Web only text messages can be edited, not the text under a photo, video or file), or something else went wrong.
cURL
curl -X POST "https://api.youraiconnector.com/v1/contacts/contact123/messages/msg_1/edit" \
-H "X-API-Key: YOUR_API_KEY" \
-H "Content-Type: application/json" \
-d '{ "body": "Sorry - I meant Thursday at 3pm." }'
JavaScript
const res = await fetch(
"https://api.youraiconnector.com/v1/contacts/contact123/messages/msg_1/edit",
{
method: "POST",
headers: {
"X-API-Key": "YOUR_API_KEY",
"Content-Type": "application/json",
},
body: JSON.stringify({ body: "Sorry - I meant Thursday at 3pm." }),
}
);
const data = await res.json();
console.log(data.edited, data.edit_reason);
Python
import requests
res = requests.post(
"https://api.youraiconnector.com/v1/contacts/contact123/messages/msg_1/edit",
headers={
"X-API-Key": "YOUR_API_KEY",
"Content-Type": "application/json",
},
json={"body": "Sorry - I meant Thursday at 3pm."},
)
data = res.json()
print(data.get("edited"), data.get("edit_reason"))
Response (200 OK):
{
"success": true,
"contact_id": "contact123",
"message_id": "msg_1",
"edited": true,
"edit_reason": "edit_dispatched"
}
If the channel would not take the edit you get a 409 instead, and nothing was changed:
{
"success": false,
"error": "The message could not be edited",
"edit_reason": "channel_disconnected"
}
A message that has already been deleted, a channel that cannot edit at all, and a message that is too old for its channel all return 400 — the request never reaches the channel.
Delete one message
DELETE /contacts/{contactId}/messages/{messageId}
Removes the message from your conversation and, where the channel allows it, pulls the contact’s copy back too. No request body.
This always answers 200 when the message existed, even if the contact’s copy could not be retracted — your copy is gone, so an error would be misleading. Read the three fields in the response to tell the user what actually happened:
| Field | Description |
|---|---|
revoke_supported |
Whether this channel can retract messages at all. |
revoked |
Whether the copy on the contact’s device was removed. |
revoke_reason |
Why it was not removed, when revoked is false — for example revoke_window_closed or already_deleted. |
cURL
curl -X DELETE "https://api.youraiconnector.com/v1/contacts/contact123/messages/msg_1" \
-H "X-API-Key: YOUR_API_KEY"
JavaScript
const res = await fetch(
"https://api.youraiconnector.com/v1/contacts/contact123/messages/msg_1",
{ method: "DELETE", headers: { "X-API-Key": "YOUR_API_KEY" } }
);
const data = await res.json();
console.log(data.revoked, data.revoke_reason);
Python
import requests
res = requests.delete(
"https://api.youraiconnector.com/v1/contacts/contact123/messages/msg_1",
headers={"X-API-Key": "YOUR_API_KEY"},
)
data = res.json()
print(data["revoked"], data["revoke_reason"])
Response (200 OK):
{
"success": true,
"contact_id": "contact123",
"message_id": "msg_1",
"revoke_supported": true,
"revoked": true,
"revoke_reason": "revoke_dispatched"
}
Deleted messages are not removed from the conversation history. They stay in
GET /contacts/{contactId}/messageswithis_deleted: trueand an emptybodyandmedia_url.
Delete several messages at once
POST /contacts/{contactId}/messages/bulk-delete
Clears a batch of messages from your side only. The bodies and attachments are emptied, but nothing is retracted on the contact’s device — to also pull a message back, delete it one at a time with the single-message endpoint above.
| Field | Required | Description |
|---|---|---|
message_ids |
Yes | A non-empty array of message IDs, up to 500 per request. messageIds is accepted as an alias. |
cURL
curl -X POST "https://api.youraiconnector.com/v1/contacts/contact123/messages/bulk-delete" \
-H "X-API-Key: YOUR_API_KEY" \
-H "Content-Type: application/json" \
-d '{ "message_ids": ["msg_1", "msg_2"] }'
JavaScript
const res = await fetch(
"https://api.youraiconnector.com/v1/contacts/contact123/messages/bulk-delete",
{
method: "POST",
headers: {
"X-API-Key": "YOUR_API_KEY",
"Content-Type": "application/json",
},
body: JSON.stringify({ message_ids: ["msg_1", "msg_2"] }),
}
);
console.log((await res.json()).deleted);
Python
import requests
res = requests.post(
"https://api.youraiconnector.com/v1/contacts/contact123/messages/bulk-delete",
headers={
"X-API-Key": "YOUR_API_KEY",
"Content-Type": "application/json",
},
json={"message_ids": ["msg_1", "msg_2"]},
)
print(res.json()["deleted"])
Response (200 OK):
{
"success": true,
"contact_id": "contact123",
"deleted": 2
}
React to a message
POST /contacts/{contactId}/messages/{messageId}/react
Puts your own emoji reaction on a message, or takes it back by sending an empty string. The contact’s own reactions are never touched.
| Field | Required | Description |
|---|---|---|
emoji |
Yes | The emoji to react with, or "" to remove your reaction. Must be a single string with no spaces, at most 16 characters. |
Like editing, this fails rather than showing a reaction the contact never got, and the failure tells you whether retrying is worth it:
422— it can never be delivered on this conversation: the channel does not support reactions, the message has no channel-side ID, or the emoji is outside the set that channel allows.409— the channel was momentarily unreachable. A retry may work.
cURL
curl -X POST "https://api.youraiconnector.com/v1/contacts/contact123/messages/msg_1/react" \
-H "X-API-Key: YOUR_API_KEY" \
-H "Content-Type: application/json" \
-d '{ "emoji": "👍" }'
JavaScript
const res = await fetch(
"https://api.youraiconnector.com/v1/contacts/contact123/messages/msg_1/react",
{
method: "POST",
headers: {
"X-API-Key": "YOUR_API_KEY",
"Content-Type": "application/json",
},
body: JSON.stringify({ emoji: "👍" }),
}
);
const data = await res.json();
console.log(data.reactions);
Python
import requests
res = requests.post(
"https://api.youraiconnector.com/v1/contacts/contact123/messages/msg_1/react",
headers={
"X-API-Key": "YOUR_API_KEY",
"Content-Type": "application/json",
},
json={"emoji": "👍"},
)
print(res.json()["reactions"])
Response (200 OK):
{
"success": true,
"contact_id": "contact123",
"message_id": "msg_1",
"reaction_supported": true,
"reaction_reason": "reaction_dispatched",
"reactions": [
{
"emoji": "👍",
"from_phone_number": "+31612345678",
"from_me": true,
"reacted_at": "2026-06-01T10:05:00.000Z"
}
]
}
The reactions array is the full set of reactions now on the message, yours and the contact’s. On a 409 or 422 it is returned unchanged, so a client rendering straight from it never shows a reaction that was not delivered.
Rate or star a message
PATCH /contacts/{contactId}/messages/{messageId}
Rates a message thumbs up or thumbs down and/or stars it as important. This is bookkeeping on your side only — nothing is sent to the contact.
| Field | Required | Description |
|---|---|---|
score |
No | 1 thumbs up, -1 thumbs down, 0 clears the rating. |
is_important |
No | true stars the message, false unstars it. Must be a real boolean, not the string "true". |
Send at least one of the two, or you get a 400. Only what you send is written, so starring a message never clears its rating and vice versa — and the response echoes back only the fields you sent.
cURL
curl -X PATCH "https://api.youraiconnector.com/v1/contacts/contact123/messages/msg_1" \
-H "X-API-Key: YOUR_API_KEY" \
-H "Content-Type: application/json" \
-d '{ "score": 1, "is_important": true }'
JavaScript
await fetch("https://api.youraiconnector.com/v1/contacts/contact123/messages/msg_1", {
method: "PATCH",
headers: {
"X-API-Key": "YOUR_API_KEY",
"Content-Type": "application/json",
},
body: JSON.stringify({ score: 1, is_important: true }),
});
Python
import requests
requests.patch(
"https://api.youraiconnector.com/v1/contacts/contact123/messages/msg_1",
headers={"X-API-Key": "YOUR_API_KEY"},
json={"score": 1, "is_important": True},
)
Response (200 OK):
{
"success": true,
"contact_id": "contact123",
"message_id": "msg_1",
"score": 1,
"is_important": true
}
Mark messages as read
You can clear the unread state either for specific messages or for the whole conversation.
Mark specific messages as read
POST /contacts/{contactId}/messages/mark-read
Pass the IDs of the messages to mark as read.
| Field | Required | Description |
|---|---|---|
message_ids |
Yes | A non-empty array of message IDs (up to 500 per request). |
cURL
curl -X POST "https://api.youraiconnector.com/v1/contacts/contact123/messages/mark-read" \
-H "X-API-Key: YOUR_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"message_ids": ["aB3dE5fG7hI9jK1lM2nO", "cD4eF6gH8iJ0kL2mN3oP"]
}'
JavaScript
const res = await fetch(
"https://api.youraiconnector.com/v1/contacts/contact123/messages/mark-read",
{
method: "POST",
headers: {
"X-API-Key": "YOUR_API_KEY",
"Content-Type": "application/json",
},
body: JSON.stringify({
message_ids: ["aB3dE5fG7hI9jK1lM2nO", "cD4eF6gH8iJ0kL2mN3oP"],
}),
}
);
const data = await res.json();
console.log(data.marked_read);
Python
import requests
res = requests.post(
"https://api.youraiconnector.com/v1/contacts/contact123/messages/mark-read",
headers={
"X-API-Key": "YOUR_API_KEY",
"Content-Type": "application/json",
},
json={"message_ids": ["aB3dE5fG7hI9jK1lM2nO", "cD4eF6gH8iJ0kL2mN3oP"]},
)
print(res.json()["marked_read"])
Response (200 OK):
{
"success": true,
"contact_id": "contact123",
"marked_read": 2
}
Mark the whole chat as read
POST /contacts/{contactId}/mark-read
Clears the unread badge for the contact’s entire conversation in the inbox. No request body is required.
cURL
curl -X POST "https://api.youraiconnector.com/v1/contacts/contact123/mark-read" \
-H "X-API-Key: YOUR_API_KEY"
JavaScript
const res = await fetch(
"https://api.youraiconnector.com/v1/contacts/contact123/mark-read",
{ method: "POST", headers: { "X-API-Key": "YOUR_API_KEY" } }
);
const data = await res.json();
console.log(data.success);
Python
import requests
res = requests.post(
"https://api.youraiconnector.com/v1/contacts/contact123/mark-read",
headers={"X-API-Key": "YOUR_API_KEY"},
)
print(res.json()["success"])
Response (200 OK):
{
"success": true,
"contact_id": "contact123"
}
Mark the whole chat as unread
POST /contacts/{contactId}/mark-unread
Puts the unread badge back on the conversation — handy when someone on your team opened a chat but is handing it back. No request body is required.
This is an inbox-only flag: it does not change when the conversation was last read, so no read receipt is sent to the contact on channels that support them.
cURL
curl -X POST "https://api.youraiconnector.com/v1/contacts/contact123/mark-unread" \
-H "X-API-Key: YOUR_API_KEY"
Response (200 OK):
{
"success": true,
"contact_id": "contact123"
}
Export a conversation
Exports give you a whole conversation as a readable transcript, rather than paging through messages. Every export endpoint accepts a filter of all (default), text, media or tool_use, matching the filter on the message list.
Export one contact’s chat
GET /chat-exports/{contactId}
| Query parameter | Required | Description |
|---|---|---|
format |
No | txt (default) returns a download link to a plain-text transcript. json returns the messages as structured data in the response. |
filter |
No | all (default), text, media or tool_use. |
cURL
curl "https://api.youraiconnector.com/v1/chat-exports/contact123?format=json" \
-H "X-API-Key: YOUR_API_KEY"
Python
import requests
res = requests.get(
"https://api.youraiconnector.com/v1/chat-exports/contact123",
headers={"X-API-Key": "YOUR_API_KEY"},
params={"format": "json"},
)
print(res.json()["data"]["messages"])
Response with format=json (200 OK):
{
"success": true,
"data": {
"contact": {
"id": "contact123",
"name": "Jane Doe",
"phone": "+31612345678",
"email": "jane@example.com"
},
"messages": [
{
"body": "Hi! I have a question about my order.",
"direction": "inbound",
"timestamp": "2026-06-01T09:55:00.000Z",
"type": "text",
"media_url": null,
"media_content_type": null,
"name": null,
"args": null
}
]
}
}
With format=txt (the default), data is instead a download link to the generated transcript file:
{
"success": true,
"data": "https://storage.googleapis.com/.../chat-export-contact123-....txt"
}
The download link is short-lived. Fetch the file as soon as you get the link rather than storing it — request a fresh export when you need the transcript again.
Export every recent conversation
GET /chat-exports/recent
Exports the conversations of all contacts that were active in the last X hours, in one call.
| Query parameter | Required | Description |
|---|---|---|
hours |
Yes | How many hours of activity to look back over. Must be a positive whole number. |
format |
No | json (default) returns one entry per contact. txt returns a single downloadable text file with every conversation in it. |
limit |
No | Maximum number of contacts to export. Default 50, maximum 100. |
filter |
No | all (default), text, media or tool_use. |
cURL
curl "https://api.youraiconnector.com/v1/chat-exports/recent?hours=24&limit=25" \
-H "X-API-Key: YOUR_API_KEY"
Response (200 OK):
{
"success": true,
"data": {
"hours_ago": 24,
"total_contacts": 2,
"exports": [
{
"contactId": "contact123",
"contactName": "Jane Doe",
"phoneNumber": "+31612345678",
"email": "jane@example.com",
"messageCount": 12,
"chatExport": "Acme Export - Jane Doe\nPhone: +31612345678\n..."
}
]
}
}
With format=txt the response is the text file itself, sent as a download rather than JSON.
This one call pulls the full history of every matching contact, so keep
hoursandlimitmodest on busy accounts.
Email a transcript to the contact
POST /chat-exports/{contactId}/email
Sends the contact their own conversation transcript by email — the “email me this chat” flow, driven from your own system.
| Field | Required | Description |
|---|---|---|
recipient_email |
No | Where to send it. Defaults to the contact’s stored email address. |
via |
No | auto (default) picks the best route, transactional sends it as a system email, email_channel sends it from your connected email channel. |
note |
No | A short line from you shown above the transcript. Up to 1000 characters. |
cURL
curl -X POST "https://api.youraiconnector.com/v1/chat-exports/contact123/email" \
-H "X-API-Key: YOUR_API_KEY" \
-H "Content-Type: application/json" \
-d '{ "note": "Here is a copy of our chat, as promised." }'
Response (200 OK):
{
"success": true,
"data": {
"via": "transactional",
"recipientEmail": "jane@example.com",
"messageCount": 42,
"omittedCount": 0
}
}
omittedCount tells you how many of the oldest messages were left out to keep the email a sensible length. A 200 means the transcript was built and queued for sending, not that it has landed in the inbox yet.
Pause or resume the AI for one contact
PUT /contacts/{contactId}
Set is_bot_active to false to stop the AI replying to one contact, and back to true to hand the conversation back. This is the takeover switch you want when a human steps into a conversation: outgoing messages you send with the API still deliver while the bot is paused.
cURL
curl -X PUT "https://api.youraiconnector.com/v1/contacts/contact123" \
-H "X-API-Key: YOUR_API_KEY" \
-H "Content-Type: application/json" \
-d '{ "is_bot_active": false }'
JavaScript
await fetch("https://api.youraiconnector.com/v1/contacts/contact123", {
method: "PUT",
headers: {
"X-API-Key": "YOUR_API_KEY",
"Content-Type": "application/json",
},
body: JSON.stringify({ is_bot_active: false }),
});
Python
import requests
requests.put(
"https://api.youraiconnector.com/v1/contacts/contact123",
headers={"X-API-Key": "YOUR_API_KEY"},
json={"is_bot_active": False},
)
Response
{
"success": true,
"contact_id": "contact123"
}
Pausing as part of the reply
If a human is taking over by sending a reply, you can pause the bot in the same request instead of making a second call. POST /contacts/{contactId}/send-message accepts these optional flags:
| Field | Description |
|---|---|
pauseBot |
true pauses the AI for this contact as the message is sent. |
clearIncompleteReply |
true discards a half-finished bot reply so it does not resume afterwards. |
human_reply |
true marks the message as a teammate’s reply and cancels a reply the AI had queued. Pausing still needs pauseBot. |
curl -X POST "https://api.youraiconnector.com/v1/contacts/contact123/send-message" \
-H "X-API-Key: YOUR_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"body": "Hi, Sarah here - taking over from the assistant.",
"pauseBot": true,
"clearIncompleteReply": true,
"human_reply": true
}'
The response includes "botPaused": true when the pause was applied.
Flagging a contact as private with
POST /contacts/bulk-flagalso pauses the bot for them. See Contacts for the full field list.
Building your own inbox
Everything an inbox needs is on this page and in Contacts:
| What you need | Endpoint |
|---|---|
| List conversations | GET /contacts |
| Read a conversation | GET /contacts/{contactId}/messages |
| List a contact’s chat sessions | GET /chat-sessions/{contactId} |
| See what came in recently | GET /chat-sessions/recent |
| Read one chat session | GET /contacts/{contactId}/chat-sessions/{sessionId}/messages |
| Send a manual reply | POST /contacts/{contactId}/send-message |
| Correct a reply you just sent | POST /contacts/{contactId}/messages/{messageId}/edit |
| Remove a message | DELETE /contacts/{contactId}/messages/{messageId} |
| Clear several messages | POST /contacts/{contactId}/messages/bulk-delete |
| React with an emoji | POST /contacts/{contactId}/messages/{messageId}/react |
| Rate or star a message | PATCH /contacts/{contactId}/messages/{messageId} |
| Mark as read | POST /contacts/{contactId}/mark-read |
| Hand a chat back to the team | POST /contacts/{contactId}/mark-unread |
| Export a transcript | GET /chat-exports/{contactId} |
| Pause or resume the AI | PUT /contacts/{contactId} with is_bot_active |
For live updates, subscribe to the New Message, Replies, Human Alerted and Chat Concluded events with Webhooks rather than polling this API on a timer.
Messages API errors
Message endpoints return the standard error envelope:
{
"success": false,
"error": "Contact not found"
}
| Status | When it happens on a message endpoint |
|---|---|
400 |
A required field is missing or a parameter is invalid (bad limit, hours, filter, direction, status, an empty or over-500 message_ids array, an invalid cursor, an empty or over-long edit body, a score outside -1/0/1, or an emoji with spaces or over 16 characters). Also returned when a message cannot be edited at all — it was deleted, its channel has no edit, or it is past that channel’s edit window. |
404 |
The contact, chat session, or one of the supplied message IDs was not found. |
409 |
The channel would not take the change right now. Nothing was written: on an edit, edit_reason says why; on a reaction, the channel was momentarily unreachable and a retry may work. |
422 |
The contact cannot receive outbound messages (do-not-disturb, private, or an unsupported channel), or a reaction can never be delivered on this conversation (reaction_reason says which). |
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
- Webhooks — get delivery-status updates pushed to you instead of polling.
- Contacts — create and look up the contacts you message.
- Appointments — book and manage appointments for your contacts.