◆ Sapport for developers API reference Error catalog OpenAPI
Sections

#Server Conversations API

Status: Available (reading) / Planned (writing). The three read routes — GET /conversations, GET /conversations/{id} and GET /conversations/{id}/messages — work. The write routes (POST …) are described in the design and do not work yet: calls return 404. For writing, the field names of request and response bodies, other than ai_reply and the operation envelope, 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

  1. When to use it
  2. Access
  3. General rules
  4. Objects
  5. GET /conversations
  6. GET /conversations/{id}
  7. GET /conversations/{id}/messages
  8. POST /conversations
  9. POST /conversations/{id}/messages
  10. Reasons for no AI reply
  11. POST /conversations/{id}/operator-messages
  12. Takeover, return to the AI, and closing
  13. Events
  14. Examples in four languages
  15. Notes
  16. Open questions

#When to use it

#Access

Base URLhttps://<cell domain>/api/public/v1: RU https://formula-cream.pro, EN https://wfacademy.org, ID https://wfacademy.id
AuthenticationAuthorization: Bearer <key>; a key works only in its own cell (Authentication)
Server onlyThere is no CORS: call the API from your server, and do not put the key in a browser or an app
Licensethe api_access flag; otherwise feature_not_in_plan
Channelapi. 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:

ScopeWhat it grantsPaidContains PII
conversations:readreading conversations and messagesnoyes
conversations:writecreating a conversation, accepting a customer message, an operator message, takeover, return to the AI, closingnoyes
ai_seller:invokethe AI seller's reply to a customer messageyesyes

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, the conversations:* scope is not granted (403 pii_transfer_not_allowed; see Cells and data).

StatusCodeWhen
401invalid_api_key, wrong_cellthe key is invalid or from another cell
403scope_missing, feature_not_in_plan, ip_not_allowedno scope or plan, or the address is not allowed
413payload_too_largethe request body is larger than 256 KB

#General rules

Details: Conventions.

RuleValue
FormatJSON, UTF-8, body up to 256 KB
Errorsapplication/problem+json, branch on the code field; catalog: Conventions
Request IDthe X-Request-Id header in every response, the request_id field in an error
Paginationlimit 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)
Idempotencythe Idempotency-Key header on POST; required for accepting a customer message; 24-hour lifetime (idempotency)
Asynchronythe 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 operationsthe 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 limitsreads 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)
IDsopaque 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 Conversation and Message schemas in OpenAPI). Identifiers are uuid. The set of status, channel and content_type values may grow: do not treat the lists as closed.

#Conversation

FieldTypeDescription
idstring (uuid)conversation id
channelstring or nullchannel type: api, telegram, website …
contact_idstring or nullid of the contact (customer) in Sapport
external_customer_idstring or nullcustomer id in your system; returned only for the api channel, always null for messengers (the messenger's external id is not disclosed)
statusstringstate: open, handoff_pending, resolved, closed …
modeai or operatorwho runs the conversation: ai — the AI replies (auto and supervised modes), operator — a human (copilot and manual)
languagestring or nullconversation language (ISO 639-1)
created_atstring or nullstart of the conversation, ISO 8601 UTC
updated_atstringlast change or message, ISO 8601 UTC; the traversal goes by it. The unread counter does not change it

#Message

FieldTypeDescription
idstring (uuid)message id
conversation_idstringthe conversation
sendercustomer, ai, operator, systemsender: the customer, the AI, an operator, a service message
content_typestringkind of content: text, image, file …
contentstring or nulltext; null — there is no text (attachment only) or the message is deleted
attachmentsarrayattachments (at most one for now)
deletedbooleantrue — the message is deleted (comes only with include_deleted=true, with no text or attachments)
created_atstringmessage 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.

ParameterTypeRequiredConstraintsExample
channelstringnofilter by channelapi
statusstringnofilter by statusopen
modestringnoai or operatoroperator
updated_afterstringnoconversations changed strictly after the moment; ISO 8601 with a zone2026-10-11T00:00:00Z
include_deletedbooleannoaccepted for uniformity; adds nothing for conversations (there is no soft deletion)false
limitintegerno1 to 100, default 2550
cursorstringnonext_cursor of the previous responseeyJ2Ijox…
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-Key is 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).

FieldTypeRequiredConstraintsExample
external_customer_idstringyesa stable customer ID in your channelclient-4815
namestringnothe customer's nameAnna
emailstringno[email protected]
phonestringno+79990001122
pageobjectnocontext: 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) and ai_seller:invoke (the AI reply). Idempotency-Key is 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

FieldTypeRequiredConstraintsExample
contentstringyesthe text of the turn; the length limit is to be determined (for the widget, 4000 characters)How much does the course cost?
attachmentsarraynoattachments (the shape is not fixed)none
URL parameterTypeDescription
waitinteger from 0 to 20seconds to wait for the AI reply. If it does not arrive in time, 202 is returned with the same job
HeaderRequiredDescription
Idempotency-Keyyesa 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):

  1. poll GET /operations/{id} and read the conversation's messages by resource;
  2. read GET /conversations/{id}/messages;
  3. subscribe to the message.created and operation.completed events (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

StatusCodeWhenWhat to do
422validation_failedno text, invalid fields; details in errors[]Fix the request
413payload_too_largethe body is larger than 256 KBReduce the body
401invalid_api_key, wrong_cellthe keyCheck the key and the cell address
402insufficient_fundsthe wallet has too little moneyTop up the wallet
402spend_limit_reachedthe spend limit is reachedWait for the window or raise the limit
403scope_missingno conversations:write or ai_seller:invokeCheck the scopes
404not_foundthe conversation does not exist or belongs to someone elseCheck the ID
409idempotency_conflictthe same key with a different bodyUse a new key for a new turn
409operation_in_progressa parallel retryWait for Retry-After and retry
429rate_limitedthe rate limitWait for Retry-After
503upstream_unavailablethe language model is unavailableRetry with the same key
503service_unavailablethe limits store is unavailable; there is a Retry-AfterRetry 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?}:

statusMeaning
repliedthe AI replied: the reply is a separate message in the conversation
queuedthe reply is being prepared; the result comes through operation or the message.created event
skippedthe AI will not reply; the reason is in reason
reason (when skipped)MeaningWhat your server should do
operator_activean operator is handling the conversation; the AI does not replyNothing: the operator will reply. The reply arrives in a message.created event
spamthe message was classified as spamDo not forward an automatic reply to the customer; log it if needed
flow_replya scenario (flow) replied, not the AITake the scenario's reply from the conversation
ai_unavailable_handoffthe AI is unavailable; the conversation was handed to an operatorTell the customer that a staff member will reply
no_agentno agent is assigned to the channelAssign an agent in the dashboard
daily_limitthe channel's daily limit of AI replies is exhaustedThe 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-Key is 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.

FieldTypeRequiredConstraintsExample
contentstringyesHello! My name is Anna.
operator_idstringnoa 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.

RouteActionAfter the call
POST /conversations/{id}/takeoverAn operator takes the conversationmode=operator; the AI stops replying
POST /conversations/{id}/releaseAn operator returns the conversation to the AImode=ai; the customer's next turns get an AI reply
POST /conversations/{id}/closeClose the conversationthe 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}.

EventWhenIn data
conversation.createda conversation is createdconversation_id
message.createda message appeared in the conversation: from the customer, the AI, or an operatorconversation_id, message_id, sender; for a customer message, ai_reply: {status, reason?}
conversation.handoff_requesteda 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_readythe conversation summary is readyconversation_id
operation.completedan 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

#Open questions