◆ Sapport for developers API reference Error catalog OpenAPI
Sections

#API Conventions

Status: Available. The general rules that are the same for all endpoints of the server API.

Purpose: request and response format, errors and the full code catalog, pagination, idempotency, asynchronous operations, rate limits, versioning, and paid operations.

The JSON examples below are illustrative: the names of optional fields will be refined in the OpenAPI 3.1 specification.

#Contents

  1. Request and response format
  2. Errors
  3. Error code catalog
  4. Pagination
  5. Idempotency
  6. Asynchronous operations
  7. Rate limits
  8. Versioning
  9. Paid operations
  10. Wallet and exchange suspension

#Request and response format

RuleValue
Base addresshttps://<cell domain>/api/public/v1 (Cells and data)
TransportHTTPS (TLS)
Encoding and formatJSON, UTF-8. For requests with a body: Content-Type: application/json
AuthorizationAuthorization: Bearer <key> (Authentication)
Request body sizeat most 256 KB
Dates and timesISO 8601 in UTC with the Z suffix: 2026-10-11T08:15:30Z. For daily aggregates: YYYY-MM-DD
Moneyan object {"amount": "123.45", "currency": "RUB"}; the amount is a string, never a floating-point number
Currencythe currency of the tenant's wallet: RUB for RU, GBP for EN, IDR for ID
Identifiersopaque strings; do not parse them or compare them by format
Unknown fieldsnew fields may appear in responses; the client ignores them (backward compatibility in v1)
CORSnone: the API is intended only for server-to-server calls

#Response headers

HeaderWhenMeaning
X-Request-IdalwaysThe request identifier. Matches request_id in the error body. Include it when contacting support
RateLimit-Limit, RateLimit-Remaining, RateLimit-ResetalwaysThe rate limit state (below)
Retry-After429, 503How many seconds to wait before retrying
Deprecation, Sunsetif the endpoint is being deprecatedSee Versioning

#Request headers

HeaderRequiredPurpose
AuthorizationyesThe key
Content-Typeif there is a bodyapplication/json
Idempotency-Keyfor POST and PATCHProtection against duplicates, see Idempotency. Required when ingesting a customer message. For PUT and DELETE it is accepted and ignored
If-Matchfor PATCH /leads/{id}The lead version: optimistic locking

#Errors

All errors are application/problem+json per RFC 9457. The error text contains no stack traces and no database details.

#Fields

FieldTypeAlwaysDescription
typeURIyesA link to the code's description in the documentation: https://<cell domain>/developers/errors#<code>
titlestringyesA short name, the same for a given code
statusnumberyesThe HTTP status
codestringyesA stable machine code (catalog). Branch your logic on code, not on the text
detailstringyesAn explanation for the specific case. Human-readable text; do not parse it
request_idstringyesThe request identifier
errorsarraynoPer-field validation errors (only for validation_failed): objects {field, code, message}
correct_base_urlstringnoOnly for wrong_cell: the base address of the cell in which the key was issued

An errors[] element:

FieldTypeDescription
fieldstringThe path to the request field as a JSON Pointer (RFC 6901): /limit, /events/0. For URL parameters: /<parameter name>
codestringA machine code for the reason, for example out_of_range, required, invalid_format
messagestringAn explanation for a human

#Example

HTTP/1.1 422 Unprocessable Entity
Content-Type: application/problem+json
X-Request-Id: req_EXAMPLE0003
{
  "type": "https://formula-cream.pro/developers/errors#validation_failed",
  "title": "Запрос не прошёл проверку",
  "status": 422,
  "code": "validation_failed",
  "detail": "Один или несколько параметров некорректны.",
  "request_id": "req_EXAMPLE0003",
  "errors": [
    { "field": "/limit", "code": "out_of_range", "message": "Должно быть от 1 до 100." }
  ]
}

#How to handle them

ClassWhat to do
400, 413, 422Fix the request. Retrying unchanged is pointless
401Check the key and the cell address. Do not retry automatically
402wallet_suspended: top up the wallet at the topup_url from the response and exchange resumes on its own; insufficient_funds: top up the wallet; spend_limit_reached: wait for the spending limit to reset
403Check the scope, permissions, IP, and plan
404The resource does not exist or belongs to another tenant (these are the same thing for the client)
409, 412, 428State conflict: reread the resource and retry with the current version (for operation_in_progress, wait)
429Wait Retry-After seconds, then retry with the same Idempotency-Key
5xxRetry with exponential backoff and the same Idempotency-Key (including 503 service_unavailable and 503 upstream_unavailable)

#Error code catalog

The HTTP statuses of the codes are fixed. The description of each code is available at the address in the type field: https://<cell domain>/developers/errors#<code>.

#Authentication and access

CodeHTTPWhenWhat to do
invalid_api_key401The header is missing, or the key is not found, revoked, or expired. The response is intentionally identical in all cases: the platform does not confirm that such a key ever existedCheck the key; the expiry is visible in GET /me (key.expires_at); issue a new one if necessary
wrong_cell401The key was issued in another cell. The response has a correct_base_url fieldUse your cell's base address (correct_base_url)
signature_invalid401Reserved for the hardened request signing mode. Not returned in v1n/a
scope_missing403The required scope is missing, or the permissions have been cut down by the integration role, the owner's permissions, or the planCheck the request permissions
ip_not_allowed403The request came from an address outside the key's listAdd the address to the list or call from an allowed one
feature_not_in_plan403The license or plan does not include the feature (api_access or a specific capability)Change the plan or contact support
pii_transfer_not_allowed403RU cell: the integration requests a scope with personal data (conversations:*, leads:*) or include_pii, and the hosting country of the receiving system is not RussiaHost the receiving system in Russia or do not request scopes with personal data and include_pii

#Request and resource state

CodeHTTPWhenWhat to do
validation_failed422The parameters failed validation; details are in errors[]Fix the request
not_found404The resource does not exist or belongs to another tenantCheck the identifier
payload_too_large413The request body is larger than 256 KBReduce the body
idempotency_conflict409The Idempotency-Key has already been used with a different request bodyUse a new key for a new request, or repeat the same request
operation_in_progress409A concurrent retry: the operation with this Idempotency-Key is still runningWait and retry after Retry-After, or poll the operation
invalid_state409The action is not allowed in the resource's current state (for example, rejecting a strategist topic that is not in the proposed state)Reread the resource and check its state
version_conflict412If-Match did not match the resource's current version (it was changed)Reread the resource and apply the edit to the current version
precondition_required428If-Match was not sent for a modificationSend the version from the last read
editorial_profile_required409The tenant's editorial profile is not filled in for article generationFill in the profile in the dashboard

#Money and limits

CodeHTTPWhenWhat to do
wallet_suspended402Exchange is halted: the tenant wallet balance is negative or the wallet is blocked. Every route except GET /me and GET /wallet responds with this code; the body contains topup_url and balance. For details, see Wallet and exchange suspensionTop up the wallet at topup_url; exchange resumes on its own no later than 30 seconds after the funds arrive
insufficient_funds402Not enough funds in the wallet for a paid operationTop up the wallet
spend_limit_reached402A spending limit (per tenant or per integration), the limit on concurrent paid operations, or the maximum cost of a single operation has been reachedWait for the window or raise the limit in the dashboard
rate_limited429The rate limit was exceededWait Retry-After

#Platform

CodeHTTPWhenWhat to do
upstream_unavailable503An external dependency is unavailable, for example a language modelRetry after a pause with the same Idempotency-Key
service_unavailable503The limits store is unavailable: the platform cannot safely accept a write or a paid operation. Contains Retry-AfterRetry after a pause with the same Idempotency-Key

#Not errors: reasons the AI did not reply

These values arrive in the body of a successful response, not as an error. Ingesting the customer message succeeded; the AI seller did not reply, for the stated reason. The state of the AI reply is carried by the ai_reply field in the response to a customer message and in the message.created event; for details, see the Conversations server API.

ai_reply is an object {status, reason?}:

ai_reply.statusMeaning
repliedThe AI replied (the reply is a separate message in the conversation)
queuedThe reply is being prepared: wait for the message.created event or poll the operation
skippedThe AI will not reply; the reason is in reason
ai_reply.reason (when skipped)Meaning
operator_activeAn operator is handling the conversation, the AI does not reply
spamThe message was classified as spam
flow_replyA flow (scripted scenario) replied, not the AI
ai_unavailable_handoffThe AI is unavailable; the conversation was handed to an operator
no_agentNo agent is assigned to the channel
daily_limitThe channel's daily limit of AI replies is exhausted: the message is saved, the conversation enters the handoff_pending state, and no AI reply will follow

Do not treat these values as a failure: the message was accepted and saved. In the widget's client Chat API, the same daily_limit case is reported with the queued: true field (client Chat API).

#Pagination

Lists are returned with a cursor ("keyset"), not page numbers.

GET /api/public/v1/leads?limit=50&cursor=eyJ2IjoxLC4uLn0 HTTP/1.1
ParameterTypeRequiredConstraintsExample
limitintegerno1 to 100; default 2550
cursorstringnothe next_cursor value from the previous response; valid for 24 hourseyJ2Ijox…
include_deletedbooleannotrue also returns "tombstones" of deleted records; default falsetrue

Response:

{
  "data": [
    { "id": "lead_EXAMPLE01", "status": "new", "updated_at": "2026-10-11T08:15:30Z" }
  ],
  "next_cursor": "eyJ2IjoxLC4uLn0"
}

When next_cursor is null, the list has ended.

#Cursor properties

PropertyDescription
Orderby (updated_at, id), strictly "greater than" the previous position
SignatureThe cursor is signed by the platform and bound to the tenant, the key mode, the position, and the set of filters. It cannot be forged or moved to a different request
OpacityDo not parse the cursor or construct one
FiltersWhen you change filters, start the traversal over, without cursor
LifetimeThe cursor is valid for 24 hours from issuance. After that (or if the cursor is invalid) the request returns 422 validation_failed with errors[].field = /cursor: start the traversal over
AggregatesAnalytics metrics and breakdowns are computed on the platform side and are not traversed with cursors

#What happens when data changes during a traversal

For real-time synchronization, use webhooks, and leave periodic traversal for reconciliation.

#Idempotency

The Idempotency-Key header protects against duplicates when retrying after network failures and timeouts.

ParameterValue
Where requiredAll POST and PATCH. Required for ingesting a customer message and for other paid requests
PUT and DELETEIdempotent by nature: a retry yields the same result. The Idempotency-Key header is accepted and ignored
ValueAny unique string of reasonable length, usually a UUID v4, new for every new operation
Validity24 hours from the first request

Behavior on a retry with the same key:

SituationResult
Same key, same body, operation completedThe same result: the operation is not executed again and money is not charged a second time. The response is built from the identifiers of the created resources, so it reflects their current state
Same key, different body409 idempotency_conflict
Same key, operation still running409 operation_in_progress
Key older than 24 hoursTreated as a new operation

Recommendations:

The platform does not store personal data in idempotency records: a retry redraws the response from the resource's current data.

#Asynchronous operations

Long operations (an AI seller reply, article generation, a strategist turn) do not hold the connection open. They follow a single scheme: the request returns 202 Accepted with an operation object; you can wait for the result, poll for it, or receive it as an event.

POST …  (Idempotency-Key)            →  202 Accepted  +  {"operation": {…}}
                                            │
         ┌──────────────────────────────────┼──────────────────────────────┐
         ▼                                  ▼                              ▼
   ?wait=N (up to 20 s):             polling                        webhook event
   wait for the same job             GET /operations/{id}           operation.completed
                                                                    (and the module's event)

#The operation envelope

The same object is returned from the creating request, from GET /operations/{id}, and in the operation.completed event.

FieldTypeDescription
idstringThe operation identifier
typestringThe kind of operation, see the table below
statusstringqueued, running, succeeded, failed, canceled
created_atstringCreation time, UTC
completed_atstringCompletion time; only for succeeded, failed, canceled
resourceobject {type, id}The resource the operation relates to; you read the result through it
errorobjectOnly for failed: {code, message}, where code is from the catalog or a reason code named on the operation's page
usageobjectOnly for paid operations: {cost: {amount, currency}, model}

202 example:

{
  "operation": {
    "id": "op_EXAMPLE01",
    "type": "conversation.ai_reply",
    "status": "queued",
    "created_at": "2026-10-11T08:15:30Z",
    "resource": { "type": "message", "id": "msg_EXAMPLE01" }
  }
}

Kinds of operations:

typeStarted byresource.typeWhere to read the result
conversation.ai_replyPOST /conversations/{id}/messagesmessageConversations server API
article.generationPOST /articles/generation-jobsgeneration_jobArticle generation
strategist.turnPOST /strategist/chat/sessions/{id}/turnsstrategist_sessionConversation with the strategist

Lead scoring (POST /leads/{id}/score) runs synchronously and is not wrapped in the envelope.

#Ways to get the result

MethodDescription
202 + operationThe operation was accepted. The body contains an operation object with the status queued or running
?wait=NThe wait parameter (seconds, at most 20) on the creating request asks to wait for the result. This waits on the same job; a new one is not started. If it did not finish, you get 202 with the operation's current state
PollingGET /operations/{id} returns the operation envelope. The recommended pause grows: 1 s, 2 s, 4 s …
EventThe operation.completed event arrives on any completion (succeeded, failed, canceled); besides it, the module sends its own event (message.created, article.ready, strategist.turn.completed …). Subscribe to the one you need (Webhooks) and do not poll

#GET /operations/{id}

Returns an operation of your integration. It requires no separate scope: to read the result, you need the scopes of the resource itself (for example, conversations:read). An operation of another integration or another tenant is returned as 404 not_found.

ParameterTypeRequiredConstraintsExample
id (path)stringyesThe identifier from the operation envelopeop_EXAMPLE01

200 response:

{
  "operation": {
    "id": "op_EXAMPLE01",
    "type": "conversation.ai_reply",
    "status": "succeeded",
    "created_at": "2026-10-11T08:15:30Z",
    "completed_at": "2026-10-11T08:15:41Z",
    "resource": { "type": "message", "id": "msg_EXAMPLE01" },
    "usage": { "cost": { "amount": "0.42", "currency": "RUB" }, "model": "model-EXAMPLE" }
  }
}

Properties:

#Rate limits

Limits are counted with a sliding window per key and per tenant at the same time.

Request classStarting limit
Read (GET)600 per minute
Write (POST, PATCH, PUT, DELETE)120 per minute
Paid operations30 per minute

The values are starting values and may change; limits by plan are not fixed. The actual values for your key are visible in GET /me (limits). Ingesting events and offline conversions (analytics:write) counts as a write: 120 requests per minute, at most 100 events per batch.

#Headers

HeaderMeaning
RateLimit-LimitThe limit of the current request class
RateLimit-RemainingHow many requests remain in the window
RateLimit-ResetIn how many seconds the window resets
Retry-AfterOn 429 and 503: in how many seconds to retry
HTTP/1.1 429 Too Many Requests
Content-Type: application/problem+json
RateLimit-Limit: 120
RateLimit-Remaining: 0
RateLimit-Reset: 23
Retry-After: 23

#Limits store failure

#Recommendations

#Versioning

Paid operations charge money to the tenant's wallet: an AI seller reply, lead scoring, article generation, a strategist turn. They are assigned to separate scopes (Authentication).

#Protection against uncontrolled spending

MechanismDescription
ReserveBefore execution, the amount is reserved in the same transaction that claims the operation; on failure the reserve is returned
Spending limit per tenantIn the currency of the tenant's wallet
Spending limit per integrationIn the currency of the tenant's wallet
Limit on concurrent paid operationsCaps parallelism. By default, 5 concurrent paid operations per tenant
Maximum cost of a single operationAn upper bound per call. By default, the equivalent of 100 RUB in the wallet currency
Wallet circuit breakerA global stop for the tenant
Rate limit30 paid requests per minute

When the wallet state is unknown (the balance source did not respond), paid operations are closed: 503 upstream_unavailable with Retry-After. Free reads keep working and carry the header Sapport-Wallet-State: unknown.

The limit values are configured in the tenant dashboard; the effective values for concurrency and daily spend are visible in GET /me (limits.paid_concurrency, wallet.daily_spend).

#Responses

CodeHTTPWhat happened
wallet_suspended402Exchange is halted entirely (a negative balance or a wallet block), not only paid operations
insufficient_funds402Not enough funds
spend_limit_reached402One of the spending or concurrency limits was triggered

#Cost in the response

Every successful response of a paid operation, as well as the operation envelope of a paid operation, contains a usage field:

FieldTypeDescription
usage.costobject {amount, currency}The cost in the tenant wallet's currency; amount is a string
usage.modelstringThe model that performed the operation

Example (illustrative): lead scoring, a synchronous paid operation:

{
  "lead_id": "lead_EXAMPLE01",
  "score": 72,
  "usage": {
    "cost": { "amount": "0.12", "currency": "RUB" },
    "model": "model-EXAMPLE"
  }
}

Test keys do not charge money (Authentication).

#Cheat sheet

QuestionAnswer
How to know a retry is safeIdempotency-Key + 429/5xx
How to know the list has endednext_cursor: null
How to know a resource was changed before my edit412 version_conflict
How to know the AI did not replyai_reply: {status, reason} in the 200/202 body, not an error
How to wait for a long operation202 + operation, then ?wait≤20, GET /operations/{id}, or operation.completed
What to do about 404 for a "foreign" identifierAssume the resource does not exist
What to do if an unknown code arrivesBranch on status and show detail

#Wallet and exchange suspension

If the tenant wallet balance is below zero or the wallet is blocked, data exchange halts entirely: every route except GET /me and GET /wallet responds with 402 wallet_suspended before scopes are checked and before the request body is read, and outgoing webhooks are not sent but deferred. The response carries topup_url (where to top up) and balance (the balance at the moment of refusal). The state is always visible in GET /wallet and in the wallet block of the GET /me response. The full description, states, and examples are in Wallet and exchange suspension.