Walker Formulation Academy for developers API reference Error catalog OpenAPI
Sections

#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, release and close routes are described in the design and do not work yet: calls return 404.

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. Connecting the channel
  9. POST /conversations
  10. POST /conversations/{id}/messages
  11. Reasons for no AI reply
  12. GET /operations/{id}
  13. POST /conversations/{id}/cards
  14. POST /conversations/{id}/operator-messages
  15. Takeover, return to the AI, and closing
  16. Events
  17. Examples in four languages
  18. Notes
  19. 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. 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:

ScopeWhat it grantsPaidContains PII
conversations:readreading conversations and messagesnoyes
conversations:writecreating a conversation, accepting a customer message, sending cards to a conversation; planned: 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 creating a conversation, accepting a customer message, and sending cards; 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; spend_limit_reached (402) and wallet_suspended (402, exchange suspended) are possible; the cost is in operation.usage.cost (paid operations)
Rate limitsreads 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 selectionthe fields= parameter on reads: without it, all fields except personal ones are returned; a personal field only if named explicitly (Minimization and field selection)
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 Walker Formulation Academy
external_customer_idstring or nullpersonal 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)
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
_encarray of stringsEN 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

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 nullpersonal field (only via fields=). Text; null — there is no text (attachment only) or the message is deleted
attachmentsarraypersonal 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
cardsarraythe message's product cards (up to three), a copy as of sending; no field means no cards. They contain no personal data. Shape: Cards
deletedbooleantrue — the message is deleted (comes only with include_deleted=true, with no text or attachments)
created_atstringmessage time, ISO 8601 UTC
_encarray of stringsas 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.

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…
fieldsstringnocomma-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-IdheadernoEN 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-Key is required.

Find or create a conversation for a customer of your channel by external ID.

FieldTypeRequiredConstraintsExample
external_customer_idstringyes1–128 characters; a stable customer ID in your system (not a phone number or an email)client-4815
namestringnoup to 120 characters; written only to an empty contact fieldAnna
emailstringnoup to 254 characters; only to an empty contact field[email protected]
phonestringnoup to 32 characters, international format; only to an empty contact field+79990001122
languagestringnoISO 639-1, two letters; the language of the new conversation. The AI reply language is limited to the cell's languagesen

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).

StatusCodeWhen
409channel_not_configuredthe chat channel is not connected (Connecting the channel)
409idempotency_conflictthe same key with a different body
409operation_in_progressa parallel retry of the same key
402wallet_suspendedexchange suspended: the wallet balance is negative or the wallet is blocked
422validation_failedno external_customer_id, unknown fields, length
429rate_limitedthe rate limit
503service_unavailablethe limits store is unavailable; retry with the same key

#POST /conversations/{id}/messages

Status: Available. 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 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

FieldTypeRequiredConstraintsExample
contentstringyes1–4000 charactersHow much does the course cost?
ai_replyauto or noneno, auto by defaultauto — request an AI reply (without the ai_seller:invoke scope the message is only saved); none — only save the messageauto

Unknown fields give 422 validation_failed.

URL parameterTypeDescription
waitinteger from 0 to 20seconds to wait for the AI reply. It waits for the same job; no new one is created. Not part of the idempotency key
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 "$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.

StatusWhenai_reply
202the AI reply is started, not ready by the end of wait (or wait is not set)status: queued, with operation
200the AI reply is ready within waitstatus: replied, with reply.id and a completed operation with the cost
201an AI reply was not requested: ai_reply: none or no ai_seller:invoke scopestatus: skipped, reason: not_requested; the AI is not invoked, nothing is charged
200the AI will not reply for a reason unrelated to your requeststatus: skipped and the reason in reason; no operation, no money charged
200the 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):

  1. subscribe to the message.created and operation.completed events (webhooks): the preferred way;
  2. poll GET /operations/{id};
  3. read GET /conversations/{id}/messages (request the reply text with fields=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.

#Errors

StatusCodeWhenWhat to do
422validation_failedno text, longer than 4000 characters, unknown 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
402spend_limit_reachedthe spend limit is reached (per operation or per day)Wait for the window or raise the limit
402wallet_suspendedexchange suspended: the wallet balance is negative or the wallet is blocked; the response has topup_url and balanceTop up the wallet
403scope_missing, pii_transfer_not_allowedno conversations:write; on RU, the PII disclosure conditions are not metCheck the scopes and the integration settings
404not_foundthe conversation does not exist, belongs to someone else, or is in another channelCheck the ID
409channel_not_configuredthe chat channel is not connectedConnect it in the dashboard
409invalid_statethe conversation is closedCreate a new conversation
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
429concurrency_limit5 AI replies per integration or per tenant are already runningWait for completion and retry with the same key
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.

ai_reply is an object {status, reason?}:

statusMeaning
repliedthe AI replied: the reply is a separate message in the conversation (reply.id)
queuedthe reply is being prepared; the result comes through operation or the message.created event
queued_for_reviewthe AI reply became a draft and awaits an operator (review mode): it will not go to the customer until the operator approves it
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
insufficient_fundsthe wallet did not have enough money for the AI replyThe message is saved, the AI was not invoked. Top up the wallet; tell the customer that a staff member will reply
upstream_unavailablethe language model did not respondThe message is saved; no need to retry the request — an operator or the next turn will answer
quota_exceededthe plan's quota of AI replies is exhaustedUpgrade the plan or wait for the next period
not_requestedyou did not ask for an AI reply: ai_reply: none or the key has no ai_seller:invokeNothing; 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-Key is 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.

FieldTypeRequiredConstraintsExample
codesarray of stringsyescard codes, from 1 to 3["hydrolat-basics"]
commentstring or nullnotext above the cards, up to 1000 charactersI 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.

RouteScopeWhat it does
GET /channels/{id}/widgetchannels:readread the channel's widget settings (assistant name and subtitle, queue, signature, files, color)
PATCH /channels/{id}/widgetchannels:writechange the settings; null restores the default value; changes are written to the audit log
GET /cards, GET /cards/{code}cards:readthe list and a single card
POST /cards, PATCH /cards/{code}, DELETE /cards/{code}cards:writecreate, change, delete a card
POST /conversations/{id}/cardsconversations:writesend 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-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 "$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.

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 "$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}.

EventWhenIn data
conversation.createda conversation is created (including by a POST /conversations call)conversation_id; the full payload with PII only with include_pii: true
message.createda message appeared in the conversation: from the customer, the AI, or an operator, as well as a message with cardsconversation_id, message_id, sender; for a customer message, ai_reply: {status, reason?}. The event has no texts, attachments, or cards
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 (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

#Open questions