#Server Conversations API
Status: Available (reading) / Planned (writing). The three read routes —
GET /conversations,GET /conversations/{id}andGET /conversations/{id}/messages— work. The write routes (POST …) are described in the design and do not work yet: calls return404. For writing, the field names of request and response bodies, other thanai_replyand theoperationenvelope, are marked preliminary and are to be checked against OpenAPI at release.
Purpose: manage conversations from your server: create a conversation from your own messenger, pass the customer's turns to the AI seller, receive its reply, reply as an operator, take a conversation over and return it to the AI, and close it.
#Contents
- When to use it
- Access
- General rules
- Objects
- GET /conversations
- GET /conversations/{id}
- GET /conversations/{id}/messages
- POST /conversations
- POST /conversations/{id}/messages
- Reasons for no AI reply
- POST /conversations/{id}/operator-messages
- Takeover, return to the AI, and closing
- Events
- Examples in four languages
- Notes
- Open questions
#When to use it
- Your customers talk in your own messenger, app, or third-party widget, and the Sapport AI seller must answer. A step-by-step guide: The AI seller in your own channel.
- You need to show Sapport conversations in your system and reply as an operator from there.
- For a chat on a website without your server, use the widget; for your own browser interface, use the client Chat API.
#Access
| Base URL | https://<cell domain>/api/public/v1: RU https://formula-cream.pro, EN https://wfacademy.org, ID https://wfacademy.id |
| Authentication | Authorization: Bearer <key>; a key works only in its own cell (Authentication) |
| Server only | There is no CORS: call the API from your server, and do not put the key in a browser or an app |
| License | the api_access flag; otherwise feature_not_in_plan |
| Channel | api. Created in the dashboard when you create an integration: one integration is one api channel; the agent is chosen there too. The channel ID is returned by GET /me (channel_id) |
Key scopes:
| Scope | What it grants | Paid | Contains PII |
|---|---|---|---|
conversations:read | reading conversations and messages | no | yes |
conversations:write | creating a conversation, accepting a customer message, an operator message, takeover, return to the AI, closing | no | yes |
ai_seller:invoke | the AI seller's reply to a customer message | yes | yes |
The request's permissions are the intersection of the key's scopes, the integration's role, the owner's rights, and the license (how they are computed). To accept a customer message with an AI reply, both scopes are needed: conversations:write and ai_seller:invoke. Accepting without an AI reply requires only conversations:write.
In the RU cell, for scopes with PII (
conversations:*), connecting an integration requires an IP allowlist, the hosting country of the receiving system, and the basis for the transfer. PII is passed only to recipients in the Russian Federation: if the country is different, theconversations:*scope is not granted (403 pii_transfer_not_allowed; see Cells and data).
| Status | Code | When |
|---|---|---|
401 | invalid_api_key, wrong_cell | the key is invalid or from another cell |
403 | scope_missing, feature_not_in_plan, ip_not_allowed | no scope or plan, or the address is not allowed |
413 | payload_too_large | the request body is larger than 256 KB |
#General rules
Details: Conventions.
| Rule | Value |
|---|---|
| Format | JSON, UTF-8, body up to 256 KB |
| Errors | application/problem+json, branch on the code field; catalog: Conventions |
| Request ID | the X-Request-Id header in every response, the request_id field in an error |
| Pagination | limit from 1 to 100 (25 by default), cursor (lives 24 hours); response {data, next_cursor}; ascending order: (updated_at, id) for conversations, (created_at, id) for messages (pagination) |
| Idempotency | the Idempotency-Key header on POST; required for accepting a customer message; 24-hour lifetime (idempotency) |
| Asynchrony | the AI reply is asynchronous only: 202 and the operation envelope; ?wait=N (up to 20 s) waits for the same job; the result comes from GET /operations/{id} or the operation.completed event (async operations) |
| Paid operations | the AI reply is charged to the tenant's wallet; insufficient_funds (402) and spend_limit_reached (402) are possible; the cost is in usage.cost (paid operations) |
| Rate limits | reads 600/min, writes 120/min, paid 30/min; no more than 5 concurrent paid operations per tenant (starting values; the current ones are in GET /me) |
| IDs | opaque strings; the examples use cnv_EXAMPLE01, msg_EXAMPLE01, evt_EXAMPLE01 |
#Objects
The fields of the conversation and message objects are actual, as reading returns them (the
ConversationandMessageschemas in OpenAPI). Identifiers areuuid. The set ofstatus,channelandcontent_typevalues may grow: do not treat the lists as closed.
#Conversation
| Field | Type | Description |
|---|---|---|
id | string (uuid) | conversation id |
channel | string or null | channel type: api, telegram, website … |
contact_id | string or null | id of the contact (customer) in Sapport |
external_customer_id | string or null | customer id in your system; returned only for the api channel, always null for messengers (the messenger's external id is not disclosed) |
status | string | state: open, handoff_pending, resolved, closed … |
mode | ai or operator | who runs the conversation: ai — the AI replies (auto and supervised modes), operator — a human (copilot and manual) |
language | string or null | conversation language (ISO 639-1) |
created_at | string or null | start of the conversation, ISO 8601 UTC |
updated_at | string | last change or message, ISO 8601 UTC; the traversal goes by it. The unread counter does not change it |
#Message
| Field | Type | Description |
|---|---|---|
id | string (uuid) | message id |
conversation_id | string | the conversation |
sender | customer, ai, operator, system | sender: the customer, the AI, an operator, a service message |
content_type | string | kind of content: text, image, file … |
content | string or null | text; null — there is no text (attachment only) or the message is deleted |
attachments | array | attachments (at most one for now) |
deleted | boolean | true — the message is deleted (comes only with include_deleted=true, with no text or attachments) |
created_at | string | message time, ISO 8601 UTC |
Messages contain customers' personal data. Do not write them to logs and do not pass them to third parties without grounds.
Personal data and the disclosure log. Every disclosure is recorded in the disclosure log (see cells and data). On the RU cell the key needs a narrow IP allowlist and the recipient country must be RU, otherwise 403 pii_transfer_not_allowed is returned before anything is read. If the log row cannot be written the answer is 503 and no data is returned. Conversations of personal mailboxes and student-portal chats are not returned; a foreign, non-existent or non-uuid id gives 404 not_found.
Read responses carry Cache-Control: private, no-store.
#GET /conversations
Status: Available. Scope
conversations:read.
A list of conversations with filters and a cursor. The order is ascending by (updated_at, id): to stay in sync, remember next_cursor and come back with it.
| Parameter | Type | Required | Constraints | Example |
|---|---|---|---|---|
channel | string | no | filter by channel | api |
status | string | no | filter by status | open |
mode | string | no | ai or operator | operator |
updated_after | string | no | conversations changed strictly after the moment; ISO 8601 with a zone | 2026-10-11T00:00:00Z |
include_deleted | boolean | no | accepted for uniformity; adds nothing for conversations (there is no soft deletion) | false |
limit | integer | no | 1 to 100, default 25 | 50 |
cursor | string | no | next_cursor of the previous response | eyJ2Ijox… |
curl -sS "$SAPPORT_BASE_URL/conversations?channel=api&mode=operator&limit=50" \
-H "Authorization: Bearer $SAPPORT_API_KEY"
{
"data": [
{
"id": "7c1f0e52-3a4b-4d6e-9f10-2b8a5c3d9e01",
"channel": "api",
"contact_id": "0d94a7c8-5e2f-4b13-8a6c-91f4e7b02d35",
"external_customer_id": "client-4815",
"status": "open",
"mode": "operator",
"language": "ru",
"created_at": "2026-10-11T08:00:00Z",
"updated_at": "2026-10-11T08:15:30Z"
}
],
"next_cursor": null
}
When you change filters, start the traversal over without cursor: the cursor is bound to the filters. An invalid, expired or foreign cursor gives 422 validation_failed.
#GET /conversations/{id}
Status: Available. Scope
conversations:read.
One conversation. If the conversation does not exist or belongs to another tenant — 404 not_found.
curl -sS "$SAPPORT_BASE_URL/conversations/7c1f0e52-3a4b-4d6e-9f10-2b8a5c3d9e01" \
-H "Authorization: Bearer $SAPPORT_API_KEY"
#GET /conversations/{id}/messages
Status: Available. Scope
conversations:read.
The messages of a conversation, page by page: limit (default 25, maximum 100), cursor, include_deleted. The order is ascending by (created_at, id) — oldest first.
With include_deleted=true a deleted message comes as a tombstone: deleted: true, content: null, no attachments.
{
"data": [
{ "id": "1a2b3c4d-0001-4000-8000-000000000001", "conversation_id": "7c1f0e52-3a4b-4d6e-9f10-2b8a5c3d9e01", "sender": "customer", "content_type": "text", "content": "Сколько стоит курс?", "attachments": [], "deleted": false, "created_at": "2026-10-11T08:15:30Z" },
{ "id": "1a2b3c4d-0002-4000-8000-000000000002", "conversation_id": "7c1f0e52-3a4b-4d6e-9f10-2b8a5c3d9e01", "sender": "ai", "content_type": "text", "content": "Курс стоит 9 900 ₽.", "attachments": [], "deleted": false, "created_at": "2026-10-11T08:15:33Z" }
],
"next_cursor": null
}
#POST /conversations
Status: Planned. Scope
conversations:write.Idempotency-Keyis recommended.
Create a conversation from your channel (the api channel). One customer of your system has one external ID; a repeated call with the same external ID should return the same conversation (the platform finds or creates the conversation by external ID).
| Field | Type | Required | Constraints | Example |
|---|---|---|---|---|
external_customer_id | string | yes | a stable customer ID in your channel | client-4815 |
name | string | no | the customer's name | Anna |
email | string | no | [email protected] | |
phone | string | no | +79990001122 | |
page | object | no | context: address, title, type, id | {"type":"course","id":"hydrolat-basics"} |
The field names are preliminary.
curl -sS -X POST "$SAPPORT_BASE_URL/conversations" \
-H "Authorization: Bearer $SAPPORT_API_KEY" \
-H "Idempotency-Key: 6f3c1f3e-5b52-4a52-9e0b-0d8e3a2c9f11" \
-H "Content-Type: application/json" \
-d '{"external_customer_id":"client-4815","name":"Anna"}'
The response is a conversation object (201; a retry with the same key returns the same conversation).
#POST /conversations/{id}/messages
Status: Planned. Scopes:
conversations:write(accepting) andai_seller:invoke(the AI reply).Idempotency-Keyis required. A paid operation when the AI is invoked.
Pass a customer's message. If the AI is handling the conversation, the platform runs the AI seller.
#Request
| Field | Type | Required | Constraints | Example |
|---|---|---|---|---|
content | string | yes | the text of the turn; the length limit is to be determined (for the widget, 4000 characters) | How much does the course cost? |
attachments | array | no | attachments (the shape is not fixed) | none |
| URL parameter | Type | Description |
|---|---|---|
wait | integer from 0 to 20 | seconds to wait for the AI reply. If it does not arrive in time, 202 is returned with the same job |
| Header | Required | Description |
|---|---|---|
Idempotency-Key | yes | a unique string for each new customer turn; a retry with the same key creates no duplicate and does not charge money a second time |
curl -sS -X POST "$SAPPORT_BASE_URL/conversations/cnv_EXAMPLE01/messages?wait=15" \
-H "Authorization: Bearer $SAPPORT_API_KEY" \
-H "Idempotency-Key: 8d4b2a40-1c63-4d4e-8f0a-6c2d3e1b7a90" \
-H "Content-Type: application/json" \
-d '{"content":"How much does the course cost?"}'
#Responses
The AI reply runs only asynchronously: the request does not hold the connection longer than wait. The response shapes are preliminary, except ai_reply and operation.
Accepted, the AI reply is being prepared (202). ai_reply.status is queued, and operation is an async operation envelope of the form conversation.ai_reply:
{
"message": {
"id": "msg_EXAMPLE01",
"conversation_id": "cnv_EXAMPLE01",
"sender": "customer",
"content": "Сколько стоит курс?",
"created_at": "2026-10-11T08:15:30Z"
},
"ai_reply": { "status": "queued" },
"operation": {
"id": "op_EXAMPLE01",
"type": "conversation.ai_reply",
"status": "queued",
"created_at": "2026-10-11T08:15:30Z",
"resource": { "type": "message", "id": "msg_EXAMPLE01" }
}
}
The reply is ready within wait (200). ai_reply.status is replied, the operation is complete, and it carries the cost:
{
"message": {
"id": "msg_EXAMPLE01",
"conversation_id": "cnv_EXAMPLE01",
"sender": "customer",
"content": "Сколько стоит курс?",
"created_at": "2026-10-11T08:15:30Z"
},
"ai_reply": { "status": "replied" },
"reply": {
"id": "msg_EXAMPLE02",
"content": "Курс «Основы гидролатов» стоит 9 900 ₽. Хотите, расскажу, что входит?"
},
"operation": {
"id": "op_EXAMPLE01",
"type": "conversation.ai_reply",
"status": "succeeded",
"created_at": "2026-10-11T08:15:30Z",
"completed_at": "2026-10-11T08:15:33Z",
"resource": { "type": "message", "id": "msg_EXAMPLE01" },
"usage": { "cost": { "amount": "0.42", "currency": "RUB" }, "model": "model-EXAMPLE" }
}
}
If the reply did not arrive in time, 202 is returned with the operation's current state. Then choose a way to get the reply (Async operations):
- poll
GET /operations/{id}and read the conversation's messages byresource; - read
GET /conversations/{id}/messages; - subscribe to the
message.createdandoperation.completedevents (webhooks): the preferred way.
A repeat of the same request with the same Idempotency-Key returns the same operation; a new one is not started.
The AI did not reply, for a reason. This is a successful 200 response, not an error. No operation is created, and no money is charged:
{
"message": {
"id": "msg_EXAMPLE01",
"conversation_id": "cnv_EXAMPLE01",
"sender": "customer",
"content": "Сколько стоит курс?",
"created_at": "2026-10-11T08:15:30Z"
},
"ai_reply": { "status": "skipped", "reason": "operator_active" }
}
The ai_reply object also arrives in the message.created event for the customer's message. See the next section.
#Errors
| Status | Code | When | What to do |
|---|---|---|---|
422 | validation_failed | no text, invalid fields; details in errors[] | Fix the request |
413 | payload_too_large | the body is larger than 256 KB | Reduce the body |
401 | invalid_api_key, wrong_cell | the key | Check the key and the cell address |
402 | insufficient_funds | the wallet has too little money | Top up the wallet |
402 | spend_limit_reached | the spend limit is reached | Wait for the window or raise the limit |
403 | scope_missing | no conversations:write or ai_seller:invoke | Check the scopes |
404 | not_found | the conversation does not exist or belongs to someone else | Check the ID |
409 | idempotency_conflict | the same key with a different body | Use a new key for a new turn |
409 | operation_in_progress | a parallel retry | Wait for Retry-After and retry |
429 | rate_limited | the rate limit | Wait for Retry-After |
503 | upstream_unavailable | the language model is unavailable | Retry with the same key |
503 | service_unavailable | the limits store is unavailable; there is a Retry-After | Retry with the same key |
For a general breakdown of the classes, see Conventions.
#Reasons for no AI reply
The platform accepts a customer's message whenever the request is valid. The AI may not reply, and that is not a failure. The state of the AI reply is returned in the ai_reply field of the body of a successful response to a customer message and in the message.created event.
ai_reply is an object {status, reason?}:
status | Meaning |
|---|---|
replied | the AI replied: the reply is a separate message in the conversation |
queued | the reply is being prepared; the result comes through operation or the message.created event |
skipped | the AI will not reply; the reason is in reason |
reason (when skipped) | Meaning | What your server should do |
|---|---|---|
operator_active | an operator is handling the conversation; the AI does not reply | Nothing: the operator will reply. The reply arrives in a message.created event |
spam | the message was classified as spam | Do not forward an automatic reply to the customer; log it if needed |
flow_reply | a scenario (flow) replied, not the AI | Take the scenario's reply from the conversation |
ai_unavailable_handoff | the AI is unavailable; the conversation was handed to an operator | Tell the customer that a staff member will reply |
no_agent | no agent is assigned to the channel | Assign an agent in the dashboard |
daily_limit | the channel's daily limit of AI replies is exhausted | The message is saved, the conversation moved to the handoff_pending state, and an AI reply was neither requested nor paid for. Tell the customer that a staff member will reply |
For the api channel, the daily limit works the same way as for the widget: the value is taken from the channel's ai_daily_limit setting (200 per day by default, 10,000 at most); the conversation reaches an operator in the conversation.handoff_requested event (Events).
#POST /conversations/{id}/operator-messages
Status: Planned. Scope
conversations:write.Idempotency-Keyis recommended.
An operator reply entered in your system. The message is written to the conversation on behalf of the operator and goes to the customer in a message.created event through your webhook.
| Field | Type | Required | Constraints | Example |
|---|---|---|---|---|
content | string | yes | Hello! My name is Anna. | |
operator_id | string | no | a staff member's ID (preliminary) | usr_EXAMPLE01 |
curl -sS -X POST "$SAPPORT_BASE_URL/conversations/cnv_EXAMPLE01/operator-messages" \
-H "Authorization: Bearer $SAPPORT_API_KEY" \
-H "Idempotency-Key: 0a1f6f7b-8e0d-47c5-9f53-5a1b27c9d3e4" \
-H "Content-Type: application/json" \
-d '{"content":"Hello! My name is Anna."}'
#Takeover, return to the AI, and closing
Status: Planned. Scope
conversations:write.
| Route | Action | After the call |
|---|---|---|
POST /conversations/{id}/takeover | An operator takes the conversation | mode=operator; the AI stops replying |
POST /conversations/{id}/release | An operator returns the conversation to the AI | mode=ai; the customer's next turns get an AI reply |
POST /conversations/{id}/close | Close the conversation | the conversation is closed |
The request bodies are not described in the design. Repeating a call should be safe; pass an Idempotency-Key in any case. An action that is not allowed in the conversation's current state (for example, takeover of a closed conversation) returns 409 invalid_state.
curl -sS -X POST "$SAPPORT_BASE_URL/conversations/cnv_EXAMPLE01/takeover" \
-H "Authorization: Bearer $SAPPORT_API_KEY" \
-H "Idempotency-Key: c2d6f3a9-4b0e-4a8b-8b7f-2d9a6e1f0c55"
#Events
Receive results through webhooks instead of polling (Webhooks). The event envelope is {id, type, api_version, created_at, livemode, data}.
| Event | When | In data |
|---|---|---|
conversation.created | a conversation is created | conversation_id |
message.created | a message appeared in the conversation: from the customer, the AI, or an operator | conversation_id, message_id, sender; for a customer message, ai_reply: {status, reason?} |
conversation.handoff_requested | a handoff to an operator was requested (including by daily_limit and ai_unavailable_handoff) | conversation_id, lead_id (if the conversation has a lead) |
conversation.summary_ready | the conversation summary is ready | conversation_id |
operation.completed | an AI reply finished (succeeded, failed, canceled) | the operation envelope |
There is one handoff-to-operator event: conversation.handoff_requested. The name handoff.requested belongs to version 1 webhooks and is not used in v2.
The message.created event is always "thin": the message text is not included, even with include_pii: true. Get the content with the request GET /conversations/{id}/messages. The subscription's include_pii flag adds PII to conversation.created, conversation.handoff_requested, and conversation.summary_ready; for it the integration needs conversations:read and leads:read (Webhooks).
The message.created event arrives both for operator messages and for AI replies. To avoid sending the customer your own message twice, compare message_id with what you have already processed.
#Examples in four languages
In all the examples, the key and the address come from environment variables; the conversation ID is illustrative.
#curl
curl -sS -X POST "$SAPPORT_BASE_URL/conversations/cnv_EXAMPLE01/messages?wait=15" \
-H "Authorization: Bearer $SAPPORT_API_KEY" \
-H "Idempotency-Key: $(uuidgen)" \
-H "Content-Type: application/json" \
-d '{"content":"How much does the course cost?"}'
#JavaScript (Node.js 18+)
import { randomUUID } from 'node:crypto';
const BASE = process.env.SAPPORT_BASE_URL; // https://formula-cream.pro/api/public/v1
const KEY = process.env.SAPPORT_API_KEY;
export async function sendCustomerMessage(conversationId, text, idempotencyKey = randomUUID()) {
const res = await fetch(`${BASE}/conversations/${conversationId}/messages?wait=15`, {
method: 'POST',
headers: {
Authorization: `Bearer ${KEY}`,
'Idempotency-Key': idempotencyKey,
'Content-Type': 'application/json'
},
body: JSON.stringify({ content: text })
});
const body = await res.json();
if (!res.ok) {
// application/problem+json: branch on code
const err = new Error(`${body.code}: ${body.detail}`);
err.code = body.code;
err.requestId = body.request_id;
throw err;
}
// 202 means the reply is still being prepared (body.operation): wait for the message.created webhook
// or poll GET /operations/{id}
return { status: res.status, body, idempotencyKey };
}
#Python (httpx)
import os
import uuid
import httpx
BASE = os.environ["SAPPORT_BASE_URL"] # https://formula-cream.pro/api/public/v1
KEY = os.environ["SAPPORT_API_KEY"]
def send_customer_message(conversation_id: str, text: str, idempotency_key: str | None = None):
key = idempotency_key or str(uuid.uuid4())
res = httpx.post(
f"{BASE}/conversations/{conversation_id}/messages",
params={"wait": 15},
headers={"Authorization": f"Bearer {KEY}", "Idempotency-Key": key},
json={"content": text},
timeout=30,
)
body = res.json()
if res.status_code >= 400:
# application/problem+json
raise RuntimeError(f'{body.get("code")}: {body.get("detail")} (request {body.get("request_id")})')
return res.status_code, body, key
#PHP
<?php
function sapport_send_customer_message(string $conversationId, string $text, ?string $idempotencyKey = null): array
{
$base = getenv('SAPPORT_BASE_URL'); // https://formula-cream.pro/api/public/v1
$key = getenv('SAPPORT_API_KEY');
$idempotencyKey ??= bin2hex(random_bytes(16));
$ch = curl_init("$base/conversations/$conversationId/messages?wait=15");
curl_setopt_array($ch, [
CURLOPT_POST => true,
CURLOPT_RETURNTRANSFER => true,
CURLOPT_TIMEOUT => 30,
CURLOPT_HTTPHEADER => [
"Authorization: Bearer $key",
"Idempotency-Key: $idempotencyKey",
'Content-Type: application/json',
],
CURLOPT_POSTFIELDS => json_encode(['content' => $text], JSON_UNESCAPED_UNICODE),
]);
$raw = curl_exec($ch);
$status = curl_getinfo($ch, CURLINFO_RESPONSE_CODE);
curl_close($ch);
$body = json_decode($raw, true);
if ($status >= 400) {
throw new RuntimeException(($body['code'] ?? 'error') . ': ' . ($body['detail'] ?? ''));
}
return [$status, $body, $idempotencyKey];
}
#Notes
- Do not retry with a new key. After a network failure, retry the request with the same
Idempotency-Key; otherwise the customer gets two replies and the wallet is charged twice. - One key, one turn. Do not use one
Idempotency-Keyfor different turns. - Customer messages from your channel must not exceed the limit. The server API's limit is not defined in the design; use the widget's 4000 characters as a guide.
- The
apichannel is isolated. The platform's shared site prompt is never used for it; the AI replies according to your agent's settings and the tenant's knowledge base. - PII. Conversation content is personal data. In the RU cell, connecting an integration with
conversations:*scopes requires an IP list, the hosting country of the receiving system (the Russian Federation only; otherwisepii_transfer_not_allowed), and the basis for the transfer; disclosures are recorded in the disclosure log, which the tenant sees in the dashboard and throughGET /disclosures(Cells and data). - Changelog: 90-changelog.
#Open questions
- The full schemas of request and response bodies (field names, the list of
status,mode, andsendervalues) will be available only in OpenAPI at release; the fields here are preliminary. Technical. - The maximum length of a customer turn and the attachment format in the server API. Technical.
- The rules for merging conversations on a repeated
POST /conversationswith the same external customer ID: what is returned if the conversation is closed. Technical. - The bodies and codes of
409 invalid_statefortakeover,release, andclosein a closed conversation. Technical.