#Server Conversations API
Status: Available (reading, creating a conversation, accepting a customer message with an AI reply, sending cards, operations) / Planned (operator reply, takeover, return to the AI, closing). The
operator-messages,takeover,releaseandcloseroutes are described in the design and do not work yet: calls return404.
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
- Connecting the channel
- POST /conversations
- POST /conversations/{id}/messages
- Reasons for no AI reply
- GET /operations/{id}
- POST /conversations/{id}/cards
- 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 Walker Formulation Academy AI seller must answer. A step-by-step guide: The AI seller in your own channel.
- You need to show Walker Formulation Academy 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. Connected in the dashboard: Settings → API → "Connect a chat channel" (one integration is one api channel; the agent is chosen there too). The channel ID is returned by GET /me (channel_id). Until the channel is connected, the write routes answer 409 channel_not_configured (Connecting the channel) |
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, sending cards to a conversation; planned: 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 creating a conversation, accepting a customer message, and sending cards; 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; spend_limit_reached (402) and wallet_suspended (402, exchange suspended) are possible; the cost is in operation.usage.cost (paid operations) |
| Rate limits | reads 600/min, writes 120/min, paid 30/min (starting values; the current ones are in GET /me). No more than 5 concurrent paid operations (AI replies) per integration and no more than 5 per tenant: beyond the limit, 429 concurrency_limit |
| Field selection | the fields= parameter on reads: without it, all fields except personal ones are returned; a personal field only if named explicitly (Minimization and field selection) |
| 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 Walker Formulation Academy |
external_customer_id | string or null | personal field: returned only if named in fields=. 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 |
_enc | array of strings | EN and ID cells only, when the JWE format is in effect: the names of the fields whose values are replaced by a JWE string (JWE format) |
#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 | personal field (only via fields=). Text; null — there is no text (attachment only) or the message is deleted |
attachments | array | personal field (only via fields=). Attachments, up to 5 per message: {type, name, size, url, expires_at}; url is a temporary signed link (about an hour): do not store it, request the message again |
cards | array | the message's product cards (up to three), a copy as of sending; no field means no cards. They contain no personal data. Shape: Cards |
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 |
_enc | array of strings | as for a conversation: EN and ID only, with the JWE format |
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); only the fields you named in fields= are logged. 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… |
fields | string | no | comma-separated, up to 2000 characters; id is always returned. Without the parameter, all fields except personal ones; a personal field (external_customer_id) only if named (Minimization and field selection) | external_customer_id,status |
Encryption-Key-Id | header | no | EN and ID only: which registered key to use for the JWE format (JWE format) | key-2026-10 |
curl -sS "$API_BASE_URL/conversations?channel=api&mode=operator&limit=50&fields=external_customer_id" \
-H "Authorization: Bearer $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": "en",
"created_at": "2026-10-11T08:00:00Z",
"updated_at": "2026-10-11T08:15:30Z"
}
],
"next_cursor": null
}
In the example, the external_customer_id field is named in fields=; without that parameter it will not be in the response. 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; it accepts fields= and Encryption-Key-Id the same way as the list. If the conversation does not exist or belongs to another tenant — 404 not_found.
curl -sS "$API_BASE_URL/conversations/7c1f0e52-3a4b-4d6e-9f10-2b8a5c3d9e01?fields=external_customer_id" \
-H "Authorization: Bearer $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, fields, and the Encryption-Key-Id header (EN and ID). Without fields, the text (content) and attachments (attachments) are not returned: they are personal fields and must be named explicitly, for example fields=content,attachments. Operators' internal notes are not returned. If an EN or ID integration works in required mode and no key is registered — 409 encryption_key_required. 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.
curl -sS "$API_BASE_URL/conversations/7c1f0e52-3a4b-4d6e-9f10-2b8a5c3d9e01/messages?fields=sender,content,attachments,cards,created_at" \
-H "Authorization: Bearer $API_KEY"
{
"data": [
{ "id": "1a2b3c4d-0001-4000-8000-000000000001", "sender": "customer", "content": "How much does the course cost?", "attachments": [], "created_at": "2026-10-12T08:15:30Z" },
{ "id": "1a2b3c4d-0002-4000-8000-000000000002", "sender": "ai", "content": "The course costs 9,900 RUB.", "attachments": [], "created_at": "2026-10-12T08:15:33Z" },
{
"id": "1a2b3c4d-0003-4000-8000-000000000003",
"sender": "customer",
"content": null,
"attachments": [
{ "type": "image", "name": "label.jpg", "size": 182044, "url": "https://example.com/signed/EXAMPLE", "expires_at": "2026-10-12T09:20:00Z" }
],
"created_at": "2026-10-12T08:19:00Z"
}
],
"next_cursor": null
}
#Connecting the channel
All write routes work in your integration's api channel. The channel is connected once in the dashboard: Settings → API → "Connect a chat channel". The agent that will reply is chosen there too.
Until the channel is connected, POST /conversations and POST /conversations/{id}/messages return 409 channel_not_configured: connect the channel in the dashboard and repeat the request. Reading (GET) does not depend on the connection.
#POST /conversations
Status: Available. Scope
conversations:write.Idempotency-Keyis required.
Find or create a conversation for a customer of your channel by external ID.
- If the customer has an active conversation, it is returned:
200. - If not, a new one is created:
201and theconversation.createdevent. external_customer_idis isolated per integration: the same ID in two different integrations means different customers, and their conversations are not mixed.- A retry with the same
Idempotency-Keyreturns the same conversation.
| Field | Type | Required | Constraints | Example |
|---|---|---|---|---|
external_customer_id | string | yes | 1–128 characters; a stable customer ID in your system (not a phone number or an email) | client-4815 |
name | string | no | up to 120 characters; written only to an empty contact field | Anna |
email | string | no | up to 254 characters; only to an empty contact field | [email protected] |
phone | string | no | up to 32 characters, international format; only to an empty contact field | +79990001122 |
language | string | no | ISO 639-1, two letters; the language of the new conversation. The AI reply language is limited to the cell's languages | en |
Unknown fields give 422 validation_failed.
curl -sS -X POST "$API_BASE_URL/conversations" \
-H "Authorization: Bearer $API_KEY" \
-H "Idempotency-Key: 6f3c1f3e-5b52-4a52-9e0b-0d8e3a2c9f11" \
-H "Content-Type: application/json" \
-d '{"external_customer_id":"client-4815","name":"Anna","language":"en"}'
The response is a conversation object: 201 for a new one, 200 for an existing one. The response contains personal data, so the disclosure is recorded in the disclosure log (on RU the restrictions from the Access section apply).
| Status | Code | When |
|---|---|---|
409 | channel_not_configured | the chat channel is not connected (Connecting the channel) |
409 | idempotency_conflict | the same key with a different body |
409 | operation_in_progress | a parallel retry of the same key |
402 | wallet_suspended | exchange suspended: the wallet balance is negative or the wallet is blocked |
422 | validation_failed | no external_customer_id, unknown fields, length |
429 | rate_limited | the rate limit |
503 | service_unavailable | the limits store is unavailable; retry with the same key |
#POST /conversations/{id}/messages
Status: Available. 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 an AI reply is requested and the key has the ai_seller:invoke scope, the platform runs the AI seller asynchronously. Attachments are not accepted on this route.
#Request
| Field | Type | Required | Constraints | Example |
|---|---|---|---|---|
content | string | yes | 1–4000 characters | How much does the course cost? |
ai_reply | auto or none | no, auto by default | auto — request an AI reply (without the ai_seller:invoke scope the message is only saved); none — only save the message | auto |
Unknown fields give 422 validation_failed.
| URL parameter | Type | Description |
|---|---|---|
wait | integer from 0 to 20 | seconds to wait for the AI reply. It waits for the same job; no new one is created. Not part of the idempotency key |
| 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 "$API_BASE_URL/conversations/7c1f0e52-3a4b-4d6e-9f10-2b8a5c3d9e01/messages?wait=15" \
-H "Authorization: Bearer $API_KEY" \
-H "Idempotency-Key: 8d4b2a40-1c63-4d4e-8f0a-6c2d3e1b7a90" \
-H "Content-Type: application/json" \
-d '{"content":"How much does the course cost?","ai_reply":"auto"}'
#Responses
The AI reply runs only asynchronously: the request does not hold the connection longer than wait. The response contains no texts: neither the customer's message nor the AI reply — only identifiers and the outcome. Read the texts with GET /conversations/{id}/messages (there the PII disclosure will go to the disclosure log) or receive them in the message.created event.
| Status | When | ai_reply |
|---|---|---|
202 | the AI reply is started, not ready by the end of wait (or wait is not set) | status: queued, with operation |
200 | the AI reply is ready within wait | status: replied, with reply.id and a completed operation with the cost |
201 | an AI reply was not requested: ai_reply: none or no ai_seller:invoke scope | status: skipped, reason: not_requested; the AI is not invoked, nothing is charged |
200 | the AI will not reply for a reason unrelated to your request | status: skipped and the reason in reason; no operation, no money charged |
200 | the AI reply became a draft awaiting an operator (review mode) | status: queued_for_review |
Accepted, the AI reply is being prepared (202):
{
"message_id": "1a2b3c4d-0001-4000-8000-000000000001",
"conversation_id": "7c1f0e52-3a4b-4d6e-9f10-2b8a5c3d9e01",
"ai_reply": { "status": "queued" },
"operation": {
"id": "5e0c9d52-7b1a-4c3d-8e21-0f4a6b7c8d90",
"type": "conversation.ai_reply",
"status": "queued",
"created_at": "2026-10-12T08:15:30Z",
"resource": { "type": "message", "id": "1a2b3c4d-0001-4000-8000-000000000001" }
}
}
The reply is ready within wait (200). There is no reply text, only reply.id; the operation carries the cost in the wallet currency:
{
"message_id": "1a2b3c4d-0001-4000-8000-000000000001",
"conversation_id": "7c1f0e52-3a4b-4d6e-9f10-2b8a5c3d9e01",
"ai_reply": { "status": "replied" },
"reply": { "id": "1a2b3c4d-0002-4000-8000-000000000002" },
"operation": {
"id": "5e0c9d52-7b1a-4c3d-8e21-0f4a6b7c8d90",
"type": "conversation.ai_reply",
"status": "succeeded",
"created_at": "2026-10-12T08:15:30Z",
"completed_at": "2026-10-12T08:15:33Z",
"resource": { "type": "message", "id": "1a2b3c4d-0001-4000-8000-000000000001" },
"usage": { "cost": { "amount": "0.42", "currency": "RUB" }, "model": "model-EXAMPLE" }
}
}
The AI was not requested or did not reply — this is a successful response, not an error. No operation is created, and no money is charged:
{
"message_id": "1a2b3c4d-0001-4000-8000-000000000001",
"conversation_id": "7c1f0e52-3a4b-4d6e-9f10-2b8a5c3d9e01",
"ai_reply": { "status": "skipped", "reason": "not_requested" }
}
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):
- subscribe to the
message.createdandoperation.completedevents (webhooks): the preferred way; - poll
GET /operations/{id}; - read
GET /conversations/{id}/messages(request the reply text withfields=content).
A repeat of the same request with the same Idempotency-Key while it is running returns the same 202; after completion, the outcome. The message is not recorded or paid for again.
#Checks before recording
The paid part is checked before the message is recorded: on refusal nothing is recorded and the Idempotency-Key is released — repeat the request with the same key after the cause is removed.
- No more than 5 concurrent AI replies per integration and no more than 5 per tenant — otherwise
429 concurrency_limit. - The maximum cost of one operation and the integration's daily limit — otherwise
402 spend_limit_reached. - A conversation of another channel or tenant —
404 not_found; a closed conversation —409 invalid_state.
#Errors
| Status | Code | When | What to do |
|---|---|---|---|
422 | validation_failed | no text, longer than 4000 characters, unknown 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 | spend_limit_reached | the spend limit is reached (per operation or per day) | Wait for the window or raise the limit |
402 | wallet_suspended | exchange suspended: the wallet balance is negative or the wallet is blocked; the response has topup_url and balance | Top up the wallet |
403 | scope_missing, pii_transfer_not_allowed | no conversations:write; on RU, the PII disclosure conditions are not met | Check the scopes and the integration settings |
404 | not_found | the conversation does not exist, belongs to someone else, or is in another channel | Check the ID |
409 | channel_not_configured | the chat channel is not connected | Connect it in the dashboard |
409 | invalid_state | the conversation is closed | Create a new conversation |
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 |
429 | concurrency_limit | 5 AI replies per integration or per tenant are already running | Wait for completion and retry with the same key |
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.
ai_reply is an object {status, reason?}:
status | Meaning |
|---|---|
replied | the AI replied: the reply is a separate message in the conversation (reply.id) |
queued | the reply is being prepared; the result comes through operation or the message.created event |
queued_for_review | the AI reply became a draft and awaits an operator (review mode): it will not go to the customer until the operator approves it |
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 |
insufficient_funds | the wallet did not have enough money for the AI reply | The message is saved, the AI was not invoked. Top up the wallet; tell the customer that a staff member will reply |
upstream_unavailable | the language model did not respond | The message is saved; no need to retry the request — an operator or the next turn will answer |
quota_exceeded | the plan's quota of AI replies is exhausted | Upgrade the plan or wait for the next period |
not_requested | you did not ask for an AI reply: ai_reply: none or the key has no ai_seller:invoke | Nothing; if needed, add the ai_seller:invoke scope |
The list of reasons may grow: branch on the known values and treat an unknown one as "there will be no AI 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).
#GET /operations/{id}
Status: Available. No separate scope: the same scope is needed as for the request that created the operation (for an AI seller reply,
ai_seller:invoke).
The state of an asynchronous operation created by your integration (for example, conversation.ai_reply from a 202 response). Returns the {operation} envelope: the state queued, running, succeeded, failed or canceled, the result resource (resource), and the cost (usage) for paid operations.
curl -sS "$API_BASE_URL/operations/5e0c9d52-7b1a-4c3d-8e21-0f4a6b7c8d90" \
-H "Authorization: Bearer $API_KEY"
An operation of another integration or tenant — 404 not_found. Instead of frequent polling, subscribe to the operation.completed event (Events). Envelope details: Conventions.
#POST /conversations/{id}/cards
Status: Available. Scope
conversations:write.Idempotency-Keyis required.
Send one to three product cards with buttons to a conversation: a message on behalf of an operator. Cards are created in advance, in the dashboard or through the cards API.
| Field | Type | Required | Constraints | Example |
|---|---|---|---|---|
codes | array of strings | yes | card codes, from 1 to 3 | ["hydrolat-basics"] |
comment | string or null | no | text above the cards, up to 1000 characters | I picked a course for you |
curl -sS -X POST "$API_BASE_URL/conversations/7c1f0e52-3a4b-4d6e-9f10-2b8a5c3d9e01/cards" \
-H "Authorization: Bearer $API_KEY" \
-H "Idempotency-Key: 2f9a1c77-5d3e-4b8a-9c01-7e6d5a4b3c21" \
-H "Content-Type: application/json" \
-d '{"codes":["hydrolat-basics"],"comment":"I picked a course for you"}'
Response 201:
{
"message_id": "1a2b3c4d-0003-4000-8000-000000000003",
"conversation_id": "7c1f0e52-3a4b-4d6e-9f10-2b8a5c3d9e01",
"created_at": "2026-10-12T08:20:00Z",
"cards": [
{
"code": "hydrolat-basics",
"name": "Hydrolat Basics",
"description": null,
"image_url": "https://example.com/img/hydrolat.jpg",
"price_text": "9,900 RUB",
"badge": "Popular",
"page_url": "https://example.com/courses/hydrolat-basics",
"buttons": [{ "label": "Learn more", "url": "https://example.com/courses/hydrolat-basics" }]
}
],
"delivery": "metadata",
"status": "sent"
}
delivery shows how the cards reach the customer: metadata — the site widget and the integration channel (you receive the message.created event); native — Telegram, VK and Max in their own format; text — other channels, as lines of "name — price — link". status: sent, delivered or failed.
A card in a message is a copy as of sending, with addresses expanded to full ones. An unknown or inactive code — 422 validation_failed; a conversation of another tenant, of the student portal, or of a personal mailbox — 404 not_found. A repeat of the same Idempotency-Key returns the same message.
#Tenant API: widget and cards
The widget settings and the card catalog can be managed from your system. Full descriptions are in the API reference.
| Route | Scope | What it does |
|---|---|---|
GET /channels/{id}/widget | channels:read | read the channel's widget settings (assistant name and subtitle, queue, signature, files, color) |
PATCH /channels/{id}/widget | channels:write | change the settings; null restores the default value; changes are written to the audit log |
GET /cards, GET /cards/{code} | cards:read | the list and a single card |
POST /cards, PATCH /cards/{code}, DELETE /cards/{code} | cards:write | create, change, delete a card |
POST /conversations/{id}/cards | conversations:write | send cards to a conversation (above) |
What the widget settings mean and what the states look like: Embedding the chat widget.
#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 "$API_BASE_URL/conversations/cnv_EXAMPLE01/operator-messages" \
-H "Authorization: Bearer $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 "$API_BASE_URL/conversations/cnv_EXAMPLE01/takeover" \
-H "Authorization: Bearer $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 (including by a POST /conversations call) | conversation_id; the full payload with PII only with include_pii: true |
message.created | a message appeared in the conversation: from the customer, the AI, or an operator, as well as a message with cards | conversation_id, message_id, sender; for a customer message, ai_reply: {status, reason?}. The event has no texts, attachments, or cards |
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 (identifiers, state, usage.cost; no reply text) |
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 and operation.completed events are always "thin": the message text, attachments, and cards are not included, even with include_pii: true. Get the content with the request GET /conversations/{id}/messages with the fields= you need (Minimization and field selection): that way the disclosure goes to the disclosure log. 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 "$API_BASE_URL/conversations/cnv_EXAMPLE01/messages?wait=15" \
-H "Authorization: Bearer $API_KEY" \
-H "Idempotency-Key: $(uuidgen)" \
-H "Content-Type: application/json" \
-d '{"content":"How much does the course cost?","ai_reply":"auto"}'
#JavaScript (Node.js 18+)
import { randomUUID } from 'node:crypto';
const BASE = process.env.API_BASE_URL; // https://formula-cream.pro/api/public/v1
const KEY = process.env.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, ai_reply: 'auto' })
});
const body = await res.json();
if (!res.ok) {
// application/problem+json: branch on code (concurrency_limit, spend_limit_reached,
// channel_not_configured ...)
const err = new Error(`${body.code}: ${body.detail}`);
err.code = body.code;
err.requestId = body.request_id;
throw err;
}
// 200 — body.ai_reply.status === 'replied', body.reply.id: read the text through
// GET /conversations/{id}/messages?fields=content
// 202 — the reply is still being prepared (body.operation): wait for the message.created
// webhook or poll GET /operations/{id}
// 201 — the AI was not requested (ai_reply.reason === 'not_requested')
return { status: res.status, body, idempotencyKey };
}
#Python (httpx)
import os
import uuid
import httpx
BASE = os.environ["API_BASE_URL"] # https://formula-cream.pro/api/public/v1
KEY = os.environ["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, "ai_reply": "auto"},
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('API_BASE_URL'); // https://formula-cream.pro/api/public/v1
$key = getenv('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, 'ai_reply' => 'auto'], 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. - The turn limit is 4000 characters; attachments are not accepted in
POST /conversations/{id}/messages. - Texts are not returned on write. The response to a customer message carries identifiers and the outcome of the AI reply; read the reply text with a separate request with
fields=content— the disclosure goes to the disclosure log. - 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. - Attachments and cards. Messages have
attachments(a personal field, up to 5 files, temporary links) andcards(no PII, up to three). Cards are sent to a conversation byPOST /conversations/{id}/cards. - 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 bodies and codes of
409 invalid_statefortakeover,release, andclosein a closed conversation; the field names ofoperator-messages. Technical. - Customer attachments in the server API for recording a message (for now attachments are read-only). Technical.