RU
EN

Endpoint Reference

Full reference for every REST endpoint under /api/v1, plus the separate third-party-migration compatibility routes. This page mirrors the ACTUAL committed route/controller code, not just the original design proposal -- see the notes under each table for anywhere behavior is still evolving.

Conventions

Chats & Messages

MethodPathScopeKey parametersCodes
GET/v1/chatschats:read?status=&channel=&cursor=&limit=200
GET/v1/chats/{id}chats:read200/404
POST/v1/chats/{id}/closechats:write{reason?}200/400/404/409/422
POST/v1/chats/{id}/statechats:write{state: active|ready|expired|spam, reason?} (spam may only go back to active; anything else is a 409)200/400/404/409/422
POST/v1/chats/{id}/transferchats:write{agent_id} (required; must belong to this account)202/400/404/409/422
POST/v1/chats/{id}/rate-requestchats:write— (no body)202/400/404/409
GET/v1/chats/{id}/messagesmessages:read?cursor=&limit=200/404
POST/v1/chats/{id}/messagesmessages:write{type: text|image|file|location|sticker|keyboard, body?, markdown?, file?, coords?, sticker?, keyboard?, origin?, agent_id?}202/400/404/409/422
POST/v1/chats/{id}/typingmessages:write{state: start|stop} (server-throttled to ~1/5s per chat)202/400/404/409/422
POST/v1/chats/{id}/seenmessages:write{message_id}200/400/404/409/422
GET/v1/chats/{id}/transcripttranscripts:read— (no parameters) → streamed text/plain attachment chat-{id}-transcript.txt200/403/404

transfer, rate-request, sending a message, and typing hand off to the internal chat/WS layer rather than writing synchronously, which is why they answer 202 (accepted) instead of the resource-style 200/201 a plain CRUD call might imply. close, state and seen are plain synchronous database writes and answer 200 once the update has actually happened. close is exactly state with {"state": "expired"} — one shared implementation, also used by the operator console, so the API and the web UI always agree. Closing a chat that is already closed is a 200 that does not re-deliver the chat.closed webhook. Chat state is also reconciled in the background, which limits how durable a written state is: a scheduled job re-derives every non-spam chat's state from the age of its last message every 5 minutes — 7 minutes or more idle becomes expired, anything younger becomes active. So ready is transient: it is accepted and written, but the next tick replaces it, and it is not a state you can read back reliably. active and expired hold only while they agree with that 7-minute clock — closing a conversation you have just been talking in reads back as active again within 5 minutes. And that job fires chat.closed on every move into expired, so both re-activating an idle chat and closing a still-fresh one deliver chat.closed a second time — the no-re-delivery guarantee above covers this API and the operator console, not the job, so treat chat.closed as at-least-once and deduplicate on your side. spam chats, and chats with no messages at all, are the only rows the job leaves alone. Note finally that the same value has two names: you POST {"state": ...}, but read it back as data.status and filter the list with ?status= — both refer to one field.

GET /v1/chats/{id}/transcript returns a file, not JSON: a streamed text/plain; charset=UTF-8 attachment rendered by the same code that produces the operator console's own download, so the two can never disagree about what is public. Private operator notes, operator-only email and operator-to-operator messages are excluded by an allowlist; attachments appear as a canonicalized filename only, never a URL or path. It is bounded three ways — at most 50 000 messages, 10 MiB of output, and 64 Ki characters per message body — and the byte budget is applied whole lines at a time, so the body never ends mid-character; when it is reached the export stops after a marker line reading [Экспорт ограничен безопасным размером]. The response is streamed and therefore carries no Content-Length: read to EOF rather than trusting a declared length. It uses its own transcripts:read scope rather than messages:read, because handing over one artifact containing an entire conversation is a different decision from granting paginated message reads — a token may hold either without the other. A chat belonging to another account is a 404 like everywhere else in this API; a 403 means the chat is yours but is an internal department chat (reason: internal_room — those are operator-to-operator rooms, not customer conversations, and are refused to every caller including the web UI's platform admins), or the token lacks the scope (reason: missing_scope). There is no JSON representation on purpose: GET /v1/chats/{id}/messages is the structured, paginated view of the same conversation.

Contacts & Tags

MethodPathScopeKey parametersCodes
GET/v1/contactscontacts:read?query=&cursor=&limit= (query matches name/email/phone)200
GET/v1/contacts/{id}contacts:read200/404
PATCH/v1/contacts/{id}contacts:write{name?, email?, phone?, attributes?} (merge-safe -- omitted fields are left untouched)200/400/404/409/422
GET/v1/tagstags:read— (distinct tag names in use across this account)200
POST/v1/contacts/{id}/tagstags:write{tags: [name, ...]} (re-posting an existing tag is a no-op, not a duplicate)200/400/404/409/422

Agents

MethodPathScopeKey parametersCodes
GET/v1/agentsagents:read?online=1|0&type=human|ai|webhook&department_id=&status=active|relaxation|banned&cursor=&limit=200/404/422
GET/v1/agents/{id}agents:read200/404
PATCH/v1/agents/{id}agents:write{show_in_header: true|false}200/400/404/409/422
DELETE/v1/agents/{id}agents:write204/404/409
POST/v1/agents/{id}/suspendagents:write200/400/404/409
POST/v1/agents/{id}/restoreagents:write200/400/404/409
POST/v1/agents/{id}/avataragents:writemultipart: file (jpeg|jpg|png|gif|webp, ≤ 2048 KB)201/400/404/409/422/502
DELETE/v1/agents/{id}/avataragents:write204/404
GET/v1/agents/invitationsagents:read?cursor=&limit=200
POST/v1/agents/invitationsagents:write{email}201/400/409/422/429/502
DELETE/v1/agents/invitations/{id}agents:write204/404
GET/v1/agents/statsstats:read?period=today|yesterday|week|month&from=&to=&metric=&cursor=&limit=200/422
GET/v1/agents/{id}/statsstats:read?period=&from=&to=&metric=200/404/422
GET/v1/agents/{id}/eventsagents:events:read?type=&cursor=&limit=200/404/422

Agents cannot be created through this API. A human operator only ever comes into existence by accepting an emailed invitation, and AI operators belong to the AI configuration surface -- DELETE /v1/agents/{id} refuses an AI operator, and refuses the widget owner's own agent row. DELETE is a soft delete: the agent's status becomes banned and the paid seat is released, but nothing is destroyed, so chats, ratings and the event log stay readable and POST /v1/agents/{id}/restore brings the agent back. PATCH is merge-safe and writes show_in_header only; the button-level operators_display_mode lives on PATCH /v1/widget/settings, and ai_assistant_id and the seat role are not writable over the API. Email address, password and account deletion have no API equivalent by design -- those stay in the web cabinet.

Read this before granting agents:write. POST /v1/agents/invitations sends mail on our sending reputation to an address you supply, and when the recipient follows the link it creates a new human principal with operator-console access to every chat on this widget -- every visitor's message body, name, email, phone and geolocation. That access is a database row, not a token grant: it outlives revocation of the API token that created it, and revoking the token does not revoke the person. Only the web cabinet can. Seat accounting is enforced rather than assumed: an invitation is refused with 409 seat_limit_reached unless free seats outnumber the invitations already outstanding, and POST /v1/agents/{id}/restore is refused the same way when no seat is free. Invitations are additionally limited to 10 per hour per widget. Mail is sent before anything is stored, so a mail failure (502) leaves no half-created account behind.

Every write that moves status or show_in_header, and both avatar routes, invalidate the served widget cache before responding -- the widget's chat header renders exactly the active agents flagged show_in_header, with their newest avatar, so the change is live on the next visitor's page load rather than after a cache TTL.

Per-agent metrics use stats:read, not agents:read -- they are statistics, they share the window vocabulary and the metric definitions of GET /v1/stats, and they carry no operator identity beyond an agent id. The event log has its own agents:events:read, because its rows are free-text console strings rather than counts. Every agent on a /v1/agents/stats page is present with zero-filled metrics, so "no activity" and "not in the response" are never confused, and an average that is undefined is null rather than a misleading 0. Note which rows belong to whom differs per metric: chats_total and avg_response_time_seconds are chats assigned to the agent, while messages_total counts messages the agent actually sent -- agents routinely reply in chats that were never assigned to anyone, and counting through the assignment would drop those. On the event log, type is a stable slug and is the contract, type_code is the raw internal action code (so an action with no slug yet still arrives, as type unknown, instead of vanishing), and message is the stored console string in Russian -- human-readable, not a contract, do not parse it.

Each agent object carries id, user_id, name, surname, email, avatar_url, role, type (human|ai|webhook), status (active|relaxation|banned), online, show_in_header, ai_assistant_id, departments and created_at. online is live websocket presence, not the stored status column, and name is the full display name with surname repeated separately so you need not parse it. With no ?status= the listing excludes banned agents, exactly as it did before that filter existed; GET /v1/agents/{id} applies no such exclusion, so a suspended agent is readable there and reports banned. A department_id that does not exist, or belongs to another account, is a 404 -- never an empty list.

Files

MethodPathScopeKey parametersCodes
GET/v1/files/{id}files:read— (redirects to the stored file)302/404
POST/v1/filesfiles:writemultipart file OR JSON {url} (exactly one; 10 MB cap either way)201/400/409/422/500

Fetch-by-url is guarded against SSRF (private/loopback/link-local ranges are rejected, and the resolved IP is re-validated on the one redirect hop this endpoint follows) -- a blocked or unreachable url is reported as 422, not 500.

Stats

MethodPathScopeKey parametersCodes
GET/v1/statsstats:read?from=&to=&period=&metric= (default window: trailing 30 days; metric is an optional comma-separated subset of chats_total, chats_missed, messages_total, avg_response_time_seconds, rating_average -- omitted means all of them)200/422
GET/v1/stats/channelsstats:read?from=&to=&period= (chat volume per channel; channel is the provider code, smartbtn for the on-site widget and ch{id} for a custom channel; channels with no chats in the window are omitted, and internal department chats are not counted as a channel)200/422
GET/v1/stats/timeseriesstats:read?from=&to=&period=&interval=&metric= (interval is day (default), week or month; metric is an optional subset of chats_total, chats_missed, messages_total; buckets are dense and zero-filled)200/422

period accepts today, yesterday, week or month. All four are calendar-anchored in Europe/Moscow -- week starts on Monday and month on the 1st, both running up to now, and yesterday is the whole previous day. It is mutually exclusive with from/to: sending both is a 422, not a silent preference for one of them. chats_missed counts chats opened in the window that never acquired an operator, the same definition as the lost figure in your cabinet's analytics. /v1/stats/timeseries refuses a window that would produce more than 366 buckets with a 422 rather than truncating the series -- ask for a coarser interval instead.

from/to are interpreted in Europe/Moscow, and an explicit UTC offset on them is honoured: 2026-09-01T10:00:00+05:00 selects rows from 2026-09-01T08:00:00+03:00, the same instant. Every timestamp in a response -- from, to and each timeseries bucket boundary -- is returned in Europe/Moscow, so the window you are shown is always the window that was queried.

/v1/stats/channels counts only the channels a customer can reach you through. Internal department chats -- your operators talking to each other or to a department -- are excluded from it. chats_total on /v1/stats keeps its original meaning and still counts every chat, so for a team that uses department chats the channel totals add up to less than chats_total, by exactly that number.

Webhook Subscriptions

MethodPathScopeKey parametersCodes
GET/v1/webhook-subscriptionswebhooks:read?cursor=&limit= (subscription secret is never included)200
POST/v1/webhook-subscriptionswebhooks:write{url (https only, SSRF-checked), events: [...], secret?, is_jivo_compat?} -- secret is generated for you and returned once if omitted201/400/409/422
DELETE/v1/webhook-subscriptions/{id}webhooks:write204/404
POST/v1/webhook-subscriptions/{id}/redeliverwebhooks:write{delivery_id}202/400/404/409/422
GET/v1/webhook-subscriptions/{id}webhooks:read— (subscription secret is never included)200/404
PATCH/v1/webhook-subscriptions/{id}webhooks:write{url?, events?, is_active?, is_jivo_compat?} -- merge-safe: only the keys you send are written. secret is not patchable (422); rotate it instead200/400/404/409/422
GET/v1/webhook-subscriptions/{id}/deliverieswebhooks:read?status=&event_type=&cursor=&limit= -- the delivery log, and the only place a delivery_id for the redelivery call above can be obtained200/404/422
POST/v1/webhook-subscriptions/{id}/rotate-secretwebhooks:write{secret?} -- the new signing secret is returned exactly once and is never readable again; rotation is an immediate cutover with no overlap window200/400/404/409/422
GET/v1/webhook-eventswebhooks:read— the catalogue of subscribable event names, with vocabulary, enabled and triggered_by for each200

events currently accepts one or more of the 7 frozen native event names (see Webhooks for the full catalog and payload shapes), or, when is_jivo_compat is true, one or more of the 5 compat event names instead. A 6th unique-constraint case is also reported as 409: creating a second subscription for a url already registered on this account.

Rotating a secret is an immediate cutover. A subscription holds one secret; the old value is overwritten, not retired alongside the new one, and the delivery worker reads the secret when a queued job runs rather than when it was enqueued. Every delivery signed before the rotation response used the old secret; every delivery signed after it -- including one already queued or already mid-retry-backoff -- uses the new one. Accept both values until the queue can no longer hold work signed with the old one (4 attempts, exponential backoff capped at 60s), then drop it. For the authoritative, always-current list of event names -- and, per event, whether it is currently enabled platform-wide -- call GET /v1/webhook-events rather than relying on any list restated in prose.

Reading the delivery log. payload is what your receiver was actually sent: the stored event ids with the internal numeric button_id removed and personal data (phone numbers, email addresses, card-like digit runs) replaced with the same [скрыто] placeholder the signed body carries, so it never reveals data the webhook itself withholds. A redelivery is re-sent in the envelope the subscription uses right now, not the one in force when the row was recorded, and the 202 body names it as envelope; a native row whose event type has no Jivo-compat equivalent is is_redeliverable: false and 422 while is_jivo_compat is true, and redeliverable again once you switch it back. On an is_jivo_compat subscription this log records final failures only -- no pending row, no success row and no stored payload -- so you will see nothing but failed entries, all is_redeliverable: false; that is expected for compat mode, not a broken integration. Native subscriptions log the full pending/delivered/failed/skipped lifecycle.

AI Replies

MethodPathScopeKey parametersCodes
POST/v1/ai/repliesresponder:inject{room_id, causation_id, content?, escalate?} -- async response to an earlier outbound AI-operator webhook call, matched by causation_id (single-use)202/403/404/409/422

This is the one endpoint on this page whose 422 response is NOT RFC-7807 shaped -- it currently renders as the framework's default validation error body ({"message": ..., "errors": {...}}) rather than application/problem+json. Every other 422 on this page comes from the shared problem-response builder. 403 here covers both "missing scope" (shared middleware) and "causation_id belongs to a different account" (endpoint-specific); 409 covers a malformed or mismatched causation record.

AI Configuration

Three objects, and it is worth getting them straight before you write any code. An assistant is a brain — persona, system prompt, temperature, escalation triggers. An AI operator is an identity — the avatar in the widget header, the name replies are attributed to, and the row the chat runtime resolves before it looks up a brain at all. A credential is the provider key and endpoint the reply is actually generated with. A working bot needs all three, plus the master switch on /v1/ai/settings — with that switch off nothing replies, however well everything else is configured.

MethodPathScopeKey parametersCodes
GET/v1/ai/settingsai:read— → the six widget-level flags, plus effective_assistant (which assistant a new chat resolves to) and legacy_persona200
PATCH/v1/ai/settingsai:writeany subset of {enabled, rag_enabled, shadow_mode, autonomous_tuning_enabled, presence_context_enabled, takeover_timeout_sec} — merge-safe200/400/409/415/422
POST/v1/ai/settings/migrateai:write— (no body) → seeds an assistant from legacy widget-level settings; additive, repeatable200/400/409
GET/v1/ai/assistantsai:read?cursor=&limit=200
POST/v1/ai/assistantsai:write{name*, enabled?, is_default?, persona?, system_prompt?, temperature?, rag_enabled?, escalation_triggers?, greeting?, quick_phrases?, provider?, model?}201/400/409/415/422
GET/v1/ai/assistants/{id}ai:read200/404
PATCH/v1/ai/assistants/{id}ai:writeany subset of the create fields — merge-safe; name cannot be cleared200/400/404/409/415/422
DELETE/v1/ai/assistants/{id}ai:write409 while any AI operator still runs it (the response names them)204/404/409
GET/v1/ai/credentialsai:read— → provider, endpoint, model, scope, has_api_key, resolved backup_chain200
POST/v1/ai/credentialsai:credentials:write{provider*, api_key*, base_url?, model?, signing_secret?, is_active?, assistant_id?, operator_id?, backup_credential_id?}201/400/409/415/422
PATCH/v1/ai/credentials/{id}ai:credentials:writerotate api_key, retire with {"is_active": false}; provider is immutable200/400/404/409/415/422
GET/v1/ai/operatorsai:read— → AI operators only (human agents are GET /v1/agents)200
POST/v1/ai/operatorsai:write{assistant_id*, name?, show_in_header?, status?}201/400/409/415/422
PATCH/v1/ai/operators/{id}ai:writerebind assistant_id, rename, toggle show_in_header, set status200/400/404/409/415/422
DELETE/v1/ai/operators/{id}ai:write— retires the operator (reversible), it is not a row removal204/404
GET/v1/ai/alertsai:read?state=unacknowledged|acknowledged|all, ?cursor=&limit=200/422
POST/v1/ai/alerts/acknowledgeai:write{ids?} — omit ids to acknowledge everything outstanding200/400/409/415/422
GET/v1/ai/marketplace/templatesai:read?category=&locale=&page=&limit= — paged by page, not by cursor200/422
GET/v1/ai/marketplace/templates/{slug}ai:read?locale= → full prompt, triggers and effective_escalation_triggers200/404/422
POST/v1/ai/marketplace/templates/{slug}/applyai:write— adds the solution as a NEW assistant; 5/hour per widget201/400/404/409/422/429

Provider keys are never returned. Not masked, not partially, not once at creation — GET /v1/ai/credentials reports has_api_key and has_signing_secret as booleans and nothing more. ai:credentials:write is a dangerous scope for two reasons: it stores a third-party key the platform then spends on your behalf, and it stores a base_url the platform then calls outbound. That URL is validated far more strictly than the web form validates it — https only, no user:pass@ in the URL, and the host is resolved and refused if it lands in a private, loopback, link-local or cloud-metadata range (including 169.254.169.254). That check runs when you save; DNS can be re-pointed afterwards, so treat it as a guard rather than a guarantee. ai:write deserves similar care for a different reason: it writes persona and system_prompt, the instructions your bot speaks to your customers under.

provider means two different things, so read this once. On a credential it is the real driver key and it decides what actually runs: openai_compatible (with a base_url — this is what you use for OpenAI, OpenRouter, Mistral, YandexGPT and any self-hosted OpenAI-compatible gateway), gemini, anthropic, gigachat, or webhook for an external AI system. The last three address their own vendor endpoint and reject a base_url rather than storing one that would do nothing. On an assistant, provider and model are display labels only — the driver and the model that actually generate a reply come from the credential resolved for that assistant. A credential resolves in three tiers: one scoped to the assistant wins, then one scoped to the operator, then the widget-wide one; scope in the response tells you which tier a row sits in.

Failover chains. A credential can name another active credential on the same widget as its backup via backup_credential_id, up to three links deep, and the runtime walks that cascade under an overall time budget when a provider is unreachable. Cycles and cross-widget links are rejected when you save, not silently at reply time, and backup_chain on a read shows you the cascade as the runtime would resolve it — including a link marked "usable": false when it has since been deactivated. That is also why there is no DELETE on a credential: other rows name it by id. Retire one with {"is_active": false}, which is reversible.

Two places store a persona, and they can disagree. The chat runtime reads an assistant's persona and system_prompt whenever it resolves one — that is the surface this API writes, and effective_assistant on GET /v1/ai/settings tells you which assistant a new chat resolves to. Separately, the platform's self-improvement loop writes an approved persona into the older widget-level store, which the runtime only falls back to when no assistant resolves. On a widget that does resolve one, those two can hold different text, and the assistant's is what your customers hear. legacy_persona.present tells you whether the older store holds anything at all. This API deliberately does not offer a fourth way to write that older persona; surfacing the disagreement is more useful than hiding it behind a field that would quietly lose to the assistant.

Side effects worth knowing. Toggling enabled and every write on /v1/ai/operators changes what the served widget renders, so those calls purge the widget cache for you and the change is live immediately; assistant, credential, alert and marketplace calls do not touch the rendered widget and do not purge. No webhook event is emitted by anything in this section — there is no ai.* domain event on this platform today, so poll if you need to observe configuration changes. The two DELETEs here need no X-Smartbtn-Idempotency-Key, the same exemption the Conventions list above names for deletion generally; every other write in this section requires one. POST /v1/ai/marketplace/templates/{slug}/apply is additive — it adds a new assistant and never overwrites an existing persona — and it spends your own provider tokens embedding the template's knowledge base, one call per rule, which is why it is capped at five per hour per widget and reports chunks_created/chunks_skipped so you can see a partially seeded knowledge base rather than assume a clean one.

Channels

MethodPathScopeKey parametersCodes
GET/v1/channelschannels:register?cursor=&limit= — the registered channels on this account; this is where the {id} the routes below need comes from (the signing secret is never returned, only has_secret)200
POST/v1/channelschannels:register{name, outbound_url?, secret?, is_active?}outbound_url must be https, must not carry user:pass@ credentials, and is refused if it resolves into a private, loopback, link-local, cloud-metadata or unspecified range; secret is generated for you when omitted and is returned once, in this response only201/400/409/422
GET/v1/channels/{id}channels:register— → one channel; reports has_secret and jivo_compat_enabled rather than either credential itself200/404
PATCH/v1/channels/{id}channels:register{name?, outbound_url?, is_active?} — merge-safe: omitted fields are left untouched, and outbound_url: null clears it. secret is not patchable here (it is rejected, not ignored) — rotate it below200/400/404/409/422
POST/v1/channels/{id}/rotate-secretchannels:register— → a new signing secret, returned once. The previous secret stops working immediately: there is no grace period, because rotation exists to revoke a leaked key200/400/404/409
DELETE/v1/channels/{id}channels:register— inbound stops resolving and outbound delivery stops; existing chats and messages are kept. To disable without losing the registration, PATCH {"is_active": false} instead. No idempotency key needed204/404
GET/v1/channels/{id}/statuschannels:read— → {"online": 0|1}200/404
POST/v1/channels/{id}/messageschannels:write{external_client_id, external_channel_id, message} -- inbound delivery from your custom channel into this account's chat202/400/404/409/422

Outbound deliveries — what Smartbtn sends to your outbound_url

Everything above is a request you make. This is the request you receive: when an operator (or the AI operator) replies in a chat that arrived through your custom channel, Smartbtn POSTs that reply to the outbound_url you registered. Setting that field is what this section's endpoints exist for, so here is what you have to be able to consume.

HeaderMeaning
Content-Typeapplication/json
X-Smartbtn-EventAlways channel.outbound today — the only event this transport sends. Switch on it rather than assuming it.
X-Smartbtn-Signaturesha256=v1:<hex hmac> — HMAC-SHA256 of the raw request body under your channel's signing secret.

The request line and body are exactly:

POST <your outbound_url>
Content-Type: application/json
X-Smartbtn-Event: channel.outbound
X-Smartbtn-Signature: sha256=v1:<hex hmac>

{
  "external_channel_id": "your-channel-thread-42",
  "external_client_id": "your-end-user-7",
  "message": {
    "id": 918273,
    "body": "Yes, that is covered by the warranty.",
    "created_at": "2026-09-01T12:34:56+03:00"
  }
}

external_channel_id and external_client_id are the values your channel sent on the way in via POST /v1/channels/{id}/messages, echoed back verbatim — route the reply by them. Smartbtn's own internal room, button and client ids are deliberately never included. message.body is the operator's text after the platform's PII scrubber has run, so it can differ from what the operator typed.

Verification is identical to the webhook one — compute sha256=v1: + HMAC-SHA256 over the raw request body (the exact bytes, before any JSON parsing) using your channel's signing secret, and compare it in constant time; the worked example under Webhooks → Signature verification applies unchanged, substituting the channel secret returned once by POST /v1/channels or POST /v1/channels/{id}/rotate-secret. A rotation takes effect on the very next delivery, with no overlap window.

This is not the webhook transport, and it does not carry the webhook envelope: there is no X-Smartbtn-Idempotency-Key, no X-Smartbtn-Delivery, no X-Smartbtn-Timestamp and no {"id", "type", "data"} wrapper — the three fields above are the whole body. Dedupe on message.id, which is stable across every retry of the same reply. A delivery is retried on a transport error or on any non-2xx status, up to 4 attempts, backing off 10s, then 20s, then 40s; answer 2xx as soon as you have durably accepted the body. Nothing is sent at all while the channel is inactive, has no outbound_url, or has no signing secret — an unsigned delivery is never made.

The https rule on outbound_url is a rule about the URL you register, not an end-to-end TLS guarantee. Only the blocked address ranges are re-checked at delivery time; the delivery job still permits the http scheme on the wire, so if your endpoint answers a delivery with a 3xx to an http:// target, that single re-validated hop is followed in cleartext, carrying the message body and its HMAC. Tightening this belongs to the delivery job and the shared SSRF guard, which serve webhook delivery too; until that lands, do not redirect deliveries — answer them 2xx at the URL you registered.

An inactive channel is not a deleted one, but its inbound endpoint cannot tell you apart from one: POST /v1/channels/{id}/messages answers 404 while is_active is false — the same code an id that does not exist returns, because this API does not distinguish the two. If inbound suddenly 404s, read the channel back with GET /v1/channels/{id}: a 200 with "is_active": false means disabled, and a 404 there too means gone.

Widget Settings

MethodPathScopeKey parametersCodes
GET/v1/widget/settingswidget:read— (all appearance and behaviour settings for the widget this token belongs to)200
PATCH/v1/widget/settingswidget:writeany subset of the settings fields (merge-safe — an omitted field is left untouched, an explicit null clears it)200/400/404/409/415/422
GET/v1/widget/skinswidget:read— → skin ids with premium and available (your plan already applied)200
GET/v1/widget/iconswidget:read— → launcher-icon preset ids (raw SVG frames are not returned)200
GET/v1/widget/embedwidget:read— → the <script> tag to paste onto your site200
POST/v1/widget/cache/purgewidget:write— (no body; 6/minute per widget on top of the shared limit)200/400/409/429

PATCH here is merge-safe and deliberately diverges from the web constructor: the constructor's save writes every field on every submit, so anything its form did not send is nulled -- this endpoint writes only the keys present in your body. Sending a field as null clears it: the stored key is removed and the widget falls back to its render default. For every widget_show_* toggle that default is on, so a field you read back as null from GET can be PATCHed straight back with no effect -- send false when you actually want a feature switched off. Four members GET returns are read-only (widget_id, api_enabled, widget_language_policy_revision, updated_at); drop them before you send the resource back, or you get a 422 naming them. It is also stricter: invalid input is a 422, never a silent fall-back to the previously stored value, and widget_button_size is restricted to 50|75|100 even though the web stores it unvalidated. Social/messenger credentials (buttons[]) and the AI, map, price-list, logo and quick-phrase subtrees are not part of this resource and are never returned by it; the api kill switch is read-only here (api_enabled) because disabling it over the API would lock every token out with no way back. Any successful write purges the served widget's cache for you, so the change is live immediately -- POST /v1/widget/cache/purge is only needed when something changed the widget out-of-band.

Account

MethodPathScopeKey parametersCodes
GET/v1/accountaccount:read— → the profile of the account that owns this widget, plus legal_entities[]200/404
PATCH/v1/accountaccount:writeany subset of name, surname, phone (merge-safe — an omitted field is left untouched, an explicit null clears it)200/400/404/409/415/422

This is the one resource where a widget-scoped token reads account-scoped data, so read this before you grant account:read. Your token belongs to a single widget, but an account does not: this endpoint returns the profile of the user who owns that widget, together with every legal entity registered on the account — full bank requisites (bank_name, bik, checking_account, correspondent_account) and registration identifiers (inn, kpp, ogrn) included. If the same person owns several widgets, a token minted for any one of them reads the same account object. There is no id to pass and no way to reach a different account — the owner is resolved from your own token — but there is also no narrower variant of this scope: if that width is more than your integration needs, leave account:read off the token.

PATCH writes exactly three fieldsname, surname, phone — and is merge-safe: only keys present in your body are written, and an explicit null clears one. Everything else GET returns is read-only and is refused by name with a 422 that says why, so the ordinary read-modify-write round trip tells you which keys to drop rather than failing on a generic "unknown field". It is stricter than the web, which validates none of these: phone accepts an optional +, a leading digit, then digits, spaces, parentheses or hyphens, up to 32 characters, and name/surname reject control characters. Three refusals are permanent, not "not yet": changing the account email, changing the password, and deleting the account. The web email change requires your current password as a step-up and a bearer token cannot supply one, so allowing any of the three would turn a leaked token into full account takeover — use the web cabinet. Minting API tokens is web-only for the same reason. legal_entities is refused too: those records carry the bank details a partner payout is resolved against at approval time, so they are readable here but editable only in the web cabinet — the scopes table on the Authentication page describes account:write as covering legal entities, and for that reason it does not in this release. Finally, changing name changes what the served widget renders — it is your operator label in the widget header — so a name change purges the widget cache of every button this account owns. surname and phone are not rendered by the widget and purge nothing. One residual: if this user is also an operator on someone else's widget, that widget keeps the old label until its own 60-second cache entry expires; this endpoint will not purge another account's cache.


Departments

MethodPathScopeKey parametersCodes
GET/v1/departmentsdepartments:read?cursor=&limit=200
POST/v1/departmentsdepartments:write{name, channels?, agent_ids?, schedule?}name is required; the other three may be supplied here so a department can be created in one call, but afterwards each is replaced whole rather than merged201/400/409/422
GET/v1/departments/{id}departments:read— → one department, with agent_ids and all seven days of schedule200/404
PATCH/v1/departments/{id}departments:write{name?, channels?} — merge-safe: omitted keys are left untouched. agent_ids and schedule are not patchable here (they are rejected, not ignored) — use the two PUTs below200/400/404/409/422
DELETE/v1/departments/{id}departments:write— (no body, no idempotency key; the department and its agent links go together)204/404
PUT/v1/departments/{id}/agentsdepartments:write{agent_ids: [id, ...]} — replaces the whole set; an empty array empties the department. Every id must belong to this widget200/400/404/409/422
PUT/v1/departments/{id}/scheduledepartments:write{schedule: {Monday: "full" | {from, to} | null, ...}} — replaces the whole week; an omitted day is closed200/400/404/409/422

A department is a routing rule, not a container: it pairs a set of agents with a set of channels and a working week, and the platform consults it when it decides whether an agent is on duty for an incoming chat. That is why the agent set and the week are replaced whole with PUT rather than merged — neither has per-item identity, so "add this one" and "this is the set" would be indistinguishable in a PATCH. Sending either of them to PATCH /v1/departments/{id} is a 422, never a silent no-op. Four members GET returns are read-only (id, avatar_url, created_at, updated_at); drop them before you send a department back, or you get a 422 naming them.

This resource is stricter than the web constructor in three places, on purpose. (1) Every agent_id you send is checked against this widget before anything is written; the constructor does not check at all, so an id it would have accepted can be a 422 here. The same check applies to an av__<n> Avito channel. Unknown and cross-account ids report identically, so neither can be used to probe what exists elsewhere. (2) channels is a closed vocabulary (tg, vk, wa, vb, fb, ok, smb for the on-site widget, or av__<avito_cabinet_id>); anything else is a 422 rather than a stored value that would simply never match a chat. A provider does not have to be connected yet. (3) In a {from, to} day, from must be strictly earlier than to — the constructor quietly rewrites an inverted range into "open all day", which this API refuses instead. Overnight ranges are not supported; use "full".

schedule always comes back with all seven days, in Monday-to-Sunday order, whatever is stored: a department created in the web cabinet can have a partial week, and the days it never wrote read back as null (closed), which is exactly how the routing check already treats them. agent_ids is read from the agent links the routing runtime actually consults, intersected with this widget's own agents, so a stale link the web cabinet left pointing at another account's agent is dropped rather than returned. It carries ids only — for the full agent objects call GET /v1/agents?department_id={id}, which is what that filter is for. A suspended agent stays in the set: suspension is reversible, and this API never silently rewrites the set you send, so the set you send is the set you read back. Note that the department chat fan-out does not currently filter suspended agents, so one left in the set is still attached to new department chats. Deleting a department leaves the chats it already produced in place; they keep their internal-chat marker with no department behind them, exactly as the web cabinet's own delete behaves.


Billing (read-only)

MethodPathScopeKey parametersCodes
GET/v1/billing/balancebilling:read— → balance_minor, reserved_minor, available_minor, currency, minor_unit_scale200
GET/v1/billing/transactionsbilling:readlimit, cursor, type, state, direction=credit|debit200/422
GET/v1/billing/transactions/{id}billing:read200/404
GET/v1/billing/subscriptionsbilling:readlimit, cursor, status, kind=product|package200/422
GET/v1/billing/subscriptions/{id}billing:read200/404
GET/v1/billing/invoicesbilling:readlimit, cursor, status200/422
GET/v1/billing/invoices/{id}billing:read— → the invoice plus its items200/404
GET/v1/billing/entitiesbilling:readlimit, cursoraccount-scoped, see below200/422

This domain is read-only, and one endpoint on it is wider than your token. Billing records are stored per account, while your token is issued for a single widget. Seven of the eight endpoints above are narrowed back down for you — balance, transactions, subscriptions and invoices all require the record to match both this widget and its owning account, so a record belonging to a sibling widget is invisible to you. /v1/billing/entities cannot be narrowed that way: legal entities are stored against the account and the table carries no widget column at all. So a token issued for one widget can read the owning account's legal entities, including entities used to pay for that account's other widgets. It can never read another account's. If that is wider than you want to grant an integration, leave billing:read out of the token.

Every monetary value is an integer in minor units (kopecks) and is named with a _minor suffix, next to a currency and the minor_unit_scale returned by /v1/billing/balance. There are no decimals and no float rounding anywhere in this domain. This is a deliberate divergence from storage: the underlying tables are not consistent — balances and ledger movements are stored in kopecks while catalogue prices, subscription charges and invoice totals are stored in whole roubles — and the API resolves that to one unit rather than passing the ambiguity on to you.

Statuses are stable slugs, never the stored integers. The internal status vocabularies here are legacy magic integers drawn from two unrelated constant tables whose values overlap and whose meanings do not; they are an implementation detail and they will change. Every one of them is mapped to a snake_case slug, and every filter accepts slugs only — an unrecognised value is a 422, never a filter that silently returns the whole collection. unknown is a member of every enum on purpose: a status the platform starts writing after your integration ships arrives as unknown rather than as a new raw integer, so switch on the slugs you know and treat the rest as unknown.

What this domain deliberately does not expose. There is no card data anywhere in it, and no endpoint takes a card id — the stored card records hold the number, expiry and CVC in the clear, so they are neither serialised nor joined. Invoices for physical persons (transaction_cards) are not exposed for that reason; only bank-transfer invoices raised against a legal entity are. Invoice PDFs are not linked: has_document tells you one exists, and files are served by the files domain behind its own scope. Invoice items are returned only where the line-item record unambiguously belongs to the invoice you asked for; invoices raised by the legacy admin path report an empty items array rather than risk attributing another payment's lines to yours. And there is no write endpoint in this domain at all — billing:write exists, is flagged dangerous on the consent screen, and has no endpoint behind it yet.


Messengers, VK communities & Avito

Three related surfaces: the messenger credentials stored on the widget itself, the VK communities wired to it, and its Avito cabinets. Every write here needs messengers:write, which is a dangerous scope for a concrete reason: these calls store third-party bot credentials and change webhook registration on the provider's side. A wrong call can silently repoint or remove a live bot's webhook and take that channel's inbound down. Read the webhook object in every write response — a 200 on its own does not mean inbound is working.

MethodPathScopeKey parametersCodes
GET/v1/messengersmessengers:read— → all eleven providers, each with connected, writable and has_token200
GET/v1/messengers/{provider}messengers:read200/404
PUT/v1/messengers/{provider}messengers:writeevery field the provider declares (url, token, and sid for wa/apple) — replaced whole200/400/404/409/415/422/429
DELETE/v1/messengers/{provider}messengers:write204/404/429
POST/v1/messengers/{provider}/verifymessengers:write— (no body; re-registers the stored credential)200/400/404/409/422/429
GET/v1/messengers/vk/communitiesmessengers:readcursor, limit200
POST/v1/messengers/vk/communitiesmessengers:writetoken (community access token), community (id, club123, or a vk.com link)201/400/409/415/422/429/502
POST/v1/messengers/vk/communities/{id}/primarymessengers:write— (no body)200/400/404/409/422
DELETE/v1/messengers/vk/communities/{id}messengers:write204/404/429/502
GET/v1/messengers/avito/cabinetsmessengers:readcursor, limit200
GET/v1/messengers/avito/cabinets/{id}messengers:read200/404
PATCH/v1/messengers/avito/cabinets/{id}messengers:writestatus: enabled | disabled200/400/404/409/415/422
DELETE/v1/messengers/avito/cabinets/{id}messengers:write204/404/429

What a write does on the provider's side

PUT /v1/messengers/{provider} runs three steps in this order: a read-only probe of your credential (Telegram only — no other provider offers a call that changes nothing), then the local write, then webhook registration with the provider. If the probe gets a real rejection you get a 422 and nothing is stored and nothing is touched at the provider. If the registration afterwards fails, your credential is saved and only inbound is not live yet: retry with POST /v1/messengers/{provider}/verify. We never do it the other way round, because repointing your live bot at us for a credential we might then fail to store is the one outcome with no cheap way back. What each provider actually does: tg setWebhook — or, if this deployment runs Telegram in long-poll mode, deleteWebhook plus getMe, because a live webhook would break the poller; vb set_webhook; ok graph/me/subscribe; mx POST /subscriptions; cian its webhook subscribe; ali a subscribe call whose contract is unverified, so it is reported as unconfirmed_provider_api and never as success. fb, wa and apple have no webhook mechanism on this platform at all and report attempted: false.

Deletes. DELETE /v1/messengers/{provider} tries the provider-side removal first but never lets it block the delete — a provider outage must not leave you unable to remove a channel. If that call fails we remove our record anyway, so a webhook may remain registered on the provider side. It is inert (inbound for a removed channel is dropped), and you can clear it from the provider's own console or by re-connecting the same token and deleting again. Only tg and cian have a real unregister call. VK is the deliberate exception: DELETE /v1/messengers/vk/communities/{id} keeps the community and answers 502 if VK does not confirm, because that row holds the only copy of the community token and server id — dropping it would strand a callback server still injecting into your chat with nothing left to address it with.

Secrets are write-only. No read ever returns a token, sid or community key — you get has_token / has_sid booleans. Webhook URLs are not returned either, because Telegram's and AliExpress's embed the bot token in the path by protocol design.

Stricter than the web cabinet, on purpose. The constructor checks only that a field is non-empty; this API validates types, lengths and the provider vocabulary and answers 422, never falling back to your previously stored value. apple and custom carry a real URL, which must be https and must not resolve to a private or link-local address. Connecting, verifying and enabling require a paid plan — on the free plan the served widget renders no messenger channels at all, so the write would have no visible effect — but removing never does: if you downgrade you can still disconnect your bots rather than having credentials and live webhooks stranded behind a paywall. vk and custom are readable and deletable but not writable — connect VK through POST /v1/messengers/vk/communities. Avito status accepts enabled and disabled only: suspended_by_billing belongs to the billing system, so you can neither set it nor leave it here, and enabling is capped by the Avito slots your plan actually includes.

There is no way to create an Avito cabinet over this API. A cabinet only exists after the interactive Avito OAuth redirect, which is what produces its access token, refresh token and expiry — all of them required columns. A bearer token cannot complete that redirect, so cabinets are created in the web cabinet and read, enabled, disabled and deleted here. Deleting one is permanent as far as this API is concerned. VK is the same story in reverse: the old user-OAuth connection flow was retired by VK in 2024, so a community access token is the only supported way in.

Every endpoint that talks to a provider carries its own limit of 10 calls per minute per widget, on top of the shared token limit. It is counted per widget, not per token, so minting a second token does not raise it — what is being protected is your account with the provider. Any successful write also purges the served widget's cache, so the social row updates immediately.


Migration-compat routes

The three routes below exist specifically to make migrating an existing bot/channel integration from a third-party chat platform to Smartbtn a base-URL-and-token swap rather than a rewrite. They accept that platform's webhook wire shapes verbatim and translate internally onto the native endpoints above -- they are not part of the native /v1 resource surface, live under their own /compat/jivo prefix, and are authenticated differently (a token or secret embedded in the URL path, matching the legacy convention, rather than an Authorization header).

MethodPathAuthKey parametersCodes
POST /compat/jivo/bot/{token} bot token in the path (same token store as a native bearer token) {message_type: BOT_MESSAGE|INVITE_AGENT|INIT_RATE, chat_id, message?}BOT_MESSAGE maps to sending a message as an agent, INVITE_AGENT maps to a transfer (picks a random online agent; responds 200 with an informational status if none are online, which is expected, not an error), INIT_RATE maps to a rate-request. 200/202/401/403/404/422
POST /compat/jivo/chat/{secret} per-channel secret in the path {sender: {id}, recipient: {id}, message} -- text-only inbound message on the matching custom channel 202/403/404/422
GET /compat/jivo/chat/{secret}/status per-channel secret in the path — → bare 0 or 1 text body (NOT JSON -- this one route intentionally preserves the legacy plain-text wire format instead of this API's usual application/json) 200/403/404
Умная кнопка