#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
- Request and response format
- Errors
- Error code catalog
- Pagination
- Idempotency
- Asynchronous operations
- Rate limits
- Versioning
- Paid operations
- Wallet and exchange suspension
#Request and response format
| Rule | Value |
|---|---|
| Base address | https://<cell domain>/api/public/v1 (Cells and data) |
| Transport | HTTPS (TLS) |
| Encoding and format | JSON, UTF-8. For requests with a body: Content-Type: application/json |
| Authorization | Authorization: Bearer <key> (Authentication) |
| Request body size | at most 256 KB |
| Dates and times | ISO 8601 in UTC with the Z suffix: 2026-10-11T08:15:30Z. For daily aggregates: YYYY-MM-DD |
| Money | an object {"amount": "123.45", "currency": "RUB"}; the amount is a string, never a floating-point number |
| Currency | the currency of the tenant's wallet: RUB for RU, GBP for EN, IDR for ID |
| Identifiers | opaque strings; do not parse them or compare them by format |
| Unknown fields | new fields may appear in responses; the client ignores them (backward compatibility in v1) |
| CORS | none: the API is intended only for server-to-server calls |
#Response headers
| Header | When | Meaning |
|---|---|---|
X-Request-Id | always | The request identifier. Matches request_id in the error body. Include it when contacting support |
RateLimit-Limit, RateLimit-Remaining, RateLimit-Reset | always | The rate limit state (below) |
Retry-After | 429, 503 | How many seconds to wait before retrying |
Deprecation, Sunset | if the endpoint is being deprecated | See Versioning |
#Request headers
| Header | Required | Purpose |
|---|---|---|
Authorization | yes | The key |
Content-Type | if there is a body | application/json |
Idempotency-Key | for POST and PATCH | Protection against duplicates, see Idempotency. Required when ingesting a customer message. For PUT and DELETE it is accepted and ignored |
If-Match | for 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
| Field | Type | Always | Description |
|---|---|---|---|
type | URI | yes | A link to the code's description in the documentation: https://<cell domain>/developers/errors#<code> |
title | string | yes | A short name, the same for a given code |
status | number | yes | The HTTP status |
code | string | yes | A stable machine code (catalog). Branch your logic on code, not on the text |
detail | string | yes | An explanation for the specific case. Human-readable text; do not parse it |
request_id | string | yes | The request identifier |
errors | array | no | Per-field validation errors (only for validation_failed): objects {field, code, message} |
correct_base_url | string | no | Only for wrong_cell: the base address of the cell in which the key was issued |
An errors[] element:
| Field | Type | Description |
|---|---|---|
field | string | The path to the request field as a JSON Pointer (RFC 6901): /limit, /events/0. For URL parameters: /<parameter name> |
code | string | A machine code for the reason, for example out_of_range, required, invalid_format |
message | string | An 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
| Class | What to do |
|---|---|
400, 413, 422 | Fix the request. Retrying unchanged is pointless |
401 | Check the key and the cell address. Do not retry automatically |
402 | wallet_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 |
403 | Check the scope, permissions, IP, and plan |
404 | The resource does not exist or belongs to another tenant (these are the same thing for the client) |
409, 412, 428 | State conflict: reread the resource and retry with the current version (for operation_in_progress, wait) |
429 | Wait Retry-After seconds, then retry with the same Idempotency-Key |
5xx | Retry 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
| Code | HTTP | When | What to do |
|---|---|---|---|
invalid_api_key | 401 | The 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 existed | Check the key; the expiry is visible in GET /me (key.expires_at); issue a new one if necessary |
wrong_cell | 401 | The key was issued in another cell. The response has a correct_base_url field | Use your cell's base address (correct_base_url) |
signature_invalid | 401 | Reserved for the hardened request signing mode. Not returned in v1 | n/a |
scope_missing | 403 | The required scope is missing, or the permissions have been cut down by the integration role, the owner's permissions, or the plan | Check the request permissions |
ip_not_allowed | 403 | The request came from an address outside the key's list | Add the address to the list or call from an allowed one |
feature_not_in_plan | 403 | The license or plan does not include the feature (api_access or a specific capability) | Change the plan or contact support |
pii_transfer_not_allowed | 403 | RU cell: the integration requests a scope with personal data (conversations:*, leads:*) or include_pii, and the hosting country of the receiving system is not Russia | Host the receiving system in Russia or do not request scopes with personal data and include_pii |
#Request and resource state
| Code | HTTP | When | What to do |
|---|---|---|---|
validation_failed | 422 | The parameters failed validation; details are in errors[] | Fix the request |
not_found | 404 | The resource does not exist or belongs to another tenant | Check the identifier |
payload_too_large | 413 | The request body is larger than 256 KB | Reduce the body |
idempotency_conflict | 409 | The Idempotency-Key has already been used with a different request body | Use a new key for a new request, or repeat the same request |
operation_in_progress | 409 | A concurrent retry: the operation with this Idempotency-Key is still running | Wait and retry after Retry-After, or poll the operation |
invalid_state | 409 | The 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_conflict | 412 | If-Match did not match the resource's current version (it was changed) | Reread the resource and apply the edit to the current version |
precondition_required | 428 | If-Match was not sent for a modification | Send the version from the last read |
editorial_profile_required | 409 | The tenant's editorial profile is not filled in for article generation | Fill in the profile in the dashboard |
#Money and limits
| Code | HTTP | When | What to do |
|---|---|---|---|
wallet_suspended | 402 | Exchange 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 suspension | Top up the wallet at topup_url; exchange resumes on its own no later than 30 seconds after the funds arrive |
insufficient_funds | 402 | Not enough funds in the wallet for a paid operation | Top up the wallet |
spend_limit_reached | 402 | A spending limit (per tenant or per integration), the limit on concurrent paid operations, or the maximum cost of a single operation has been reached | Wait for the window or raise the limit in the dashboard |
rate_limited | 429 | The rate limit was exceeded | Wait Retry-After |
#Platform
| Code | HTTP | When | What to do |
|---|---|---|---|
upstream_unavailable | 503 | An external dependency is unavailable, for example a language model | Retry after a pause with the same Idempotency-Key |
service_unavailable | 503 | The limits store is unavailable: the platform cannot safely accept a write or a paid operation. Contains Retry-After | Retry 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.status | Meaning |
|---|---|
replied | The AI replied (the reply is a separate message in the conversation) |
queued | The reply is being prepared: wait for the message.created event or poll the operation |
skipped | The AI will not reply; the reason is in reason |
ai_reply.reason (when skipped) | Meaning |
|---|---|
operator_active | An operator is handling the conversation, the AI does not reply |
spam | The message was classified as spam |
flow_reply | A flow (scripted scenario) replied, not the AI |
ai_unavailable_handoff | The AI is unavailable; the conversation was handed to an operator |
no_agent | No agent is assigned to the channel |
daily_limit | The 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
| Parameter | Type | Required | Constraints | Example |
|---|---|---|---|---|
limit | integer | no | 1 to 100; default 25 | 50 |
cursor | string | no | the next_cursor value from the previous response; valid for 24 hours | eyJ2Ijox… |
include_deleted | boolean | no | true also returns "tombstones" of deleted records; default false | true |
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
| Property | Description |
|---|---|
| Order | by (updated_at, id), strictly "greater than" the previous position |
| Signature | The 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 |
| Opacity | Do not parse the cursor or construct one |
| Filters | When you change filters, start the traversal over, without cursor |
| Lifetime | The 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 |
| Aggregates | Analytics metrics and breakdowns are computed on the platform side and are not traversed with cursors |
#What happens when data changes during a traversal
- Added records will appear in the traversal if they land "after" the current position.
- A modified record gets a new
updated_atand therefore may appear again later. Process records as "upsert byid", not "insert". - Deleted records are not returned by default. With
include_deleted=true, "tombstones" arrive in their place: objects{"id": "…", "deleted_at": "2026-10-11T08:15:30Z"}, so you can remove the record on your side. Tombstones are retained for 30 days: sync at least that often. - Records are not skipped because of changes in neighboring rows.
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.
| Parameter | Value |
|---|---|
| Where required | All POST and PATCH. Required for ingesting a customer message and for other paid requests |
PUT and DELETE | Idempotent by nature: a retry yields the same result. The Idempotency-Key header is accepted and ignored |
| Value | Any unique string of reasonable length, usually a UUID v4, new for every new operation |
| Validity | 24 hours from the first request |
Behavior on a retry with the same key:
| Situation | Result |
|---|---|
| Same key, same body, operation completed | The 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 body | 409 idempotency_conflict |
| Same key, operation still running | 409 operation_in_progress |
| Key older than 24 hours | Treated as a new operation |
Recommendations:
- Generate the key before the first send and store it with the task in your queue, so a retry uses the same key.
- For
429and5xx, retry the request with the same key. - Do not use one key for different operations.
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.
| Field | Type | Description |
|---|---|---|
id | string | The operation identifier |
type | string | The kind of operation, see the table below |
status | string | queued, running, succeeded, failed, canceled |
created_at | string | Creation time, UTC |
completed_at | string | Completion time; only for succeeded, failed, canceled |
resource | object {type, id} | The resource the operation relates to; you read the result through it |
error | object | Only for failed: {code, message}, where code is from the catalog or a reason code named on the operation's page |
usage | object | Only 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:
type | Started by | resource.type | Where to read the result |
|---|---|---|---|
conversation.ai_reply | POST /conversations/{id}/messages | message | Conversations server API |
article.generation | POST /articles/generation-jobs | generation_job | Article generation |
strategist.turn | POST /strategist/chat/sessions/{id}/turns | strategist_session | Conversation with the strategist |
Lead scoring (POST /leads/{id}/score) runs synchronously and is not wrapped in the envelope.
#Ways to get the result
| Method | Description |
|---|---|
202 + operation | The operation was accepted. The body contains an operation object with the status queued or running |
?wait=N | The 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 |
| Polling | GET /operations/{id} returns the operation envelope. The recommended pause grows: 1 s, 2 s, 4 s … |
| Event | The 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.
| Parameter | Type | Required | Constraints | Example |
|---|---|---|---|---|
id (path) | string | yes | The identifier from the operation envelope | op_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:
- The job is recovered after a worker failure; the reserve of paid funds is returned if the operation did not complete.
- Repeating the request with the same
Idempotency-Keyreturns the same operation. - The result of a paid operation contains
usage(see below).
#Rate limits
Limits are counted with a sliding window per key and per tenant at the same time.
| Request class | Starting limit |
|---|---|
Read (GET) | 600 per minute |
Write (POST, PATCH, PUT, DELETE) | 120 per minute |
| Paid operations | 30 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
| Header | Meaning |
|---|---|
RateLimit-Limit | The limit of the current request class |
RateLimit-Remaining | How many requests remain in the window |
RateLimit-Reset | In how many seconds the window resets |
Retry-After | On 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
- For writes and paid operations the mode is "fail closed": when the limits store is unavailable,
503 service_unavailableis returned (withRetry-After). The platform will not allow uncontrolled spending. - For reads, a soft in-process limit applies, and requests continue to be served.
#Recommendations
- Read
RateLimit-Remainingand slow down in advance. - Retry
429afterRetry-Afterwith jitter, not as an avalanche. - For bulk synchronization, use cursor traversal and webhooks instead of frequent polling.
#Versioning
- The version is part of the address:
/api/public/v1. - Within
v1, only additive changes are made: new endpoints, new optional parameters, new response fields, and new enum values where stated. The client must ignore unknown fields. - A breaking change ships only in a new version (
v2) and never replacesv1. - Deprecation is announced in advance:
DeprecationandSunsetheaders in responses and an entry in the changelog, at least 90 days before shutdown.
#Paid operations
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
| Mechanism | Description |
|---|---|
| Reserve | Before execution, the amount is reserved in the same transaction that claims the operation; on failure the reserve is returned |
| Spending limit per tenant | In the currency of the tenant's wallet |
| Spending limit per integration | In the currency of the tenant's wallet |
| Limit on concurrent paid operations | Caps parallelism. By default, 5 concurrent paid operations per tenant |
| Maximum cost of a single operation | An upper bound per call. By default, the equivalent of 100 RUB in the wallet currency |
| Wallet circuit breaker | A global stop for the tenant |
| Rate limit | 30 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
| Code | HTTP | What happened |
|---|---|---|
wallet_suspended | 402 | Exchange is halted entirely (a negative balance or a wallet block), not only paid operations |
insufficient_funds | 402 | Not enough funds |
spend_limit_reached | 402 | One 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:
| Field | Type | Description |
|---|---|---|
usage.cost | object {amount, currency} | The cost in the tenant wallet's currency; amount is a string |
usage.model | string | The 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
| Question | Answer |
|---|---|
| How to know a retry is safe | Idempotency-Key + 429/5xx |
| How to know the list has ended | next_cursor: null |
| How to know a resource was changed before my edit | 412 version_conflict |
| How to know the AI did not reply | ai_reply: {status, reason} in the 200/202 body, not an error |
| How to wait for a long operation | 202 + operation, then ?wait≤20, GET /operations/{id}, or operation.completed |
What to do about 404 for a "foreign" identifier | Assume the resource does not exist |
What to do if an unknown code arrives | Branch 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.