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
- Base URL:
https://<host>/api/v1. See Quickstart for a first request, Authentication & Scopes for tokens and scopes. - Universal codes: every endpoint below can additionally return
401(missing/invalid/expired token),403(token lacks the required scope), or429(rate limited) from the shared authentication/rate-limit layer -- these are omitted from the per-endpoint "Codes" columns below to avoid repeating them 26 times; only the codes the endpoint's own logic produces are listed there. - Idempotency: every write endpoint below requires an
X-Smartbtn-Idempotency-Keyheader, exceptDELETE /v1/webhook-subscriptions/{id}(deletion is naturally idempotent) andPOST /v1/ai/replies(dedup instead uses the single-usecausation_id). A missing header returns400; reusing a key already seen in the last 24 hours returns409. - Pagination: list endpoints accept
?cursor=&limit=(limitcapped at 100, default 20) and return{"data": [...], "next_cursor": "..." | null}. - Errors: almost every error response is RFC-7807
application/problem+json({type, title, status, detail, instance, errors?}) -- the one exception is noted under AI Replies below. - Ids: chats, files, webhook subscriptions, and compat Bot-API chat ids are internal numeric ids; contacts use a 32-character opaque guid instead (never the internal numeric id) -- both are tenant-scoped, so an id belonging to a different account always 404s rather than leaking existence.
Chats & Messages
| Method | Path | Scope | Key parameters | Codes |
|---|---|---|---|---|
| GET | /v1/chats | chats:read | ?status=&channel=&cursor=&limit= | 200 |
| GET | /v1/chats/{id} | chats:read | — | 200/404 |
| POST | /v1/chats/{id}/close | chats:write | {reason?} | 200/400/404/409/422 |
| POST | /v1/chats/{id}/state | chats: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}/transfer | chats:write | {agent_id} (required; must belong to this account) | 202/400/404/409/422 |
| POST | /v1/chats/{id}/rate-request | chats:write | — (no body) | 202/400/404/409 |
| GET | /v1/chats/{id}/messages | messages:read | ?cursor=&limit= | 200/404 |
| POST | /v1/chats/{id}/messages | messages: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}/typing | messages:write | {state: start|stop} (server-throttled to ~1/5s per chat) | 202/400/404/409/422 |
| POST | /v1/chats/{id}/seen | messages:write | {message_id} | 200/400/404/409/422 |
| GET | /v1/chats/{id}/transcript | transcripts:read | — (no parameters) → streamed text/plain attachment chat-{id}-transcript.txt | 200/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
| Method | Path | Scope | Key parameters | Codes |
|---|---|---|---|---|
| GET | /v1/contacts | contacts:read | ?query=&cursor=&limit= (query matches name/email/phone) | 200 |
| GET | /v1/contacts/{id} | contacts:read | — | 200/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/tags | tags:read | — (distinct tag names in use across this account) | 200 |
| POST | /v1/contacts/{id}/tags | tags:write | {tags: [name, ...]} (re-posting an existing tag is a no-op, not a duplicate) | 200/400/404/409/422 |
Agents
| Method | Path | Scope | Key parameters | Codes |
|---|---|---|---|---|
| GET | /v1/agents | agents:read | ?online=1|0&type=human|ai|webhook&department_id=&status=active|relaxation|banned&cursor=&limit= | 200/404/422 |
| GET | /v1/agents/{id} | agents:read | — | 200/404 |
| PATCH | /v1/agents/{id} | agents:write | {show_in_header: true|false} | 200/400/404/409/422 |
| DELETE | /v1/agents/{id} | agents:write | — | 204/404/409 |
| POST | /v1/agents/{id}/suspend | agents:write | — | 200/400/404/409 |
| POST | /v1/agents/{id}/restore | agents:write | — | 200/400/404/409 |
| POST | /v1/agents/{id}/avatar | agents:write | multipart: file (jpeg|jpg|png|gif|webp, ≤ 2048 KB) | 201/400/404/409/422/502 |
| DELETE | /v1/agents/{id}/avatar | agents:write | — | 204/404 |
| GET | /v1/agents/invitations | agents:read | ?cursor=&limit= | 200 |
| POST | /v1/agents/invitations | agents:write | {email} | 201/400/409/422/429/502 |
| DELETE | /v1/agents/invitations/{id} | agents:write | — | 204/404 |
| GET | /v1/agents/stats | stats:read | ?period=today|yesterday|week|month&from=&to=&metric=&cursor=&limit= | 200/422 |
| GET | /v1/agents/{id}/stats | stats:read | ?period=&from=&to=&metric= | 200/404/422 |
| GET | /v1/agents/{id}/events | agents: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
| Method | Path | Scope | Key parameters | Codes |
|---|---|---|---|---|
| GET | /v1/files/{id} | files:read | — (redirects to the stored file) | 302/404 |
| POST | /v1/files | files:write | multipart 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
| Method | Path | Scope | Key parameters | Codes |
|---|---|---|---|---|
| GET | /v1/stats | stats: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/channels | stats: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/timeseries | stats: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
| Method | Path | Scope | Key parameters | Codes |
|---|---|---|---|---|
| GET | /v1/webhook-subscriptions | webhooks:read | ?cursor=&limit= (subscription secret is never included) | 200 |
| POST | /v1/webhook-subscriptions | webhooks:write | {url (https only, SSRF-checked), events: [...], secret?, is_jivo_compat?} -- secret is generated for you and returned once if omitted | 201/400/409/422 |
| DELETE | /v1/webhook-subscriptions/{id} | webhooks:write | — | 204/404 |
| POST | /v1/webhook-subscriptions/{id}/redeliver | webhooks: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 instead | 200/400/404/409/422 |
| GET | /v1/webhook-subscriptions/{id}/deliveries | webhooks:read | ?status=&event_type=&cursor=&limit= -- the delivery log, and the only place a delivery_id for the redelivery call above can be obtained | 200/404/422 |
| POST | /v1/webhook-subscriptions/{id}/rotate-secret | webhooks:write | {secret?} -- the new signing secret is returned exactly once and is never readable again; rotation is an immediate cutover with no overlap window | 200/400/404/409/422 |
| GET | /v1/webhook-events | webhooks:read | — the catalogue of subscribable event names, with vocabulary, enabled and triggered_by for each | 200 |
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
| Method | Path | Scope | Key parameters | Codes |
|---|---|---|---|---|
| POST | /v1/ai/replies | responder: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.
| Method | Path | Scope | Key parameters | Codes |
|---|---|---|---|---|
| GET | /v1/ai/settings | ai:read | — → the six widget-level flags, plus effective_assistant (which assistant a new chat resolves to) and legacy_persona | 200 |
| PATCH | /v1/ai/settings | ai:write | any subset of {enabled, rag_enabled, shadow_mode, autonomous_tuning_enabled, presence_context_enabled, takeover_timeout_sec} — merge-safe | 200/400/409/415/422 |
| POST | /v1/ai/settings/migrate | ai:write | — (no body) → seeds an assistant from legacy widget-level settings; additive, repeatable | 200/400/409 |
| GET | /v1/ai/assistants | ai:read | ?cursor=&limit= | 200 |
| POST | /v1/ai/assistants | ai: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:read | — | 200/404 |
| PATCH | /v1/ai/assistants/{id} | ai:write | any subset of the create fields — merge-safe; name cannot be cleared | 200/400/404/409/415/422 |
| DELETE | /v1/ai/assistants/{id} | ai:write | — 409 while any AI operator still runs it (the response names them) | 204/404/409 |
| GET | /v1/ai/credentials | ai:read | — → provider, endpoint, model, scope, has_api_key, resolved backup_chain | 200 |
| POST | /v1/ai/credentials | ai: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:write | rotate api_key, retire with {"is_active": false}; provider is immutable | 200/400/404/409/415/422 |
| GET | /v1/ai/operators | ai:read | — → AI operators only (human agents are GET /v1/agents) | 200 |
| POST | /v1/ai/operators | ai:write | {assistant_id*, name?, show_in_header?, status?} | 201/400/409/415/422 |
| PATCH | /v1/ai/operators/{id} | ai:write | rebind assistant_id, rename, toggle show_in_header, set status | 200/400/404/409/415/422 |
| DELETE | /v1/ai/operators/{id} | ai:write | — retires the operator (reversible), it is not a row removal | 204/404 |
| GET | /v1/ai/alerts | ai:read | ?state=unacknowledged|acknowledged|all, ?cursor=&limit= | 200/422 |
| POST | /v1/ai/alerts/acknowledge | ai:write | {ids?} — omit ids to acknowledge everything outstanding | 200/400/409/415/422 |
| GET | /v1/ai/marketplace/templates | ai:read | ?category=&locale=&page=&limit= — paged by page, not by cursor | 200/422 |
| GET | /v1/ai/marketplace/templates/{slug} | ai:read | ?locale= → full prompt, triggers and effective_escalation_triggers | 200/404/422 |
| POST | /v1/ai/marketplace/templates/{slug}/apply | ai:write | — adds the solution as a NEW assistant; 5/hour per widget | 201/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
| Method | Path | Scope | Key parameters | Codes |
|---|---|---|---|---|
| GET | /v1/channels | channels: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/channels | channels: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 only | 201/400/409/422 |
| GET | /v1/channels/{id} | channels:register | — → one channel; reports has_secret and jivo_compat_enabled rather than either credential itself | 200/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 below | 200/400/404/409/422 |
| POST | /v1/channels/{id}/rotate-secret | channels: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 key | 200/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 needed | 204/404 |
| GET | /v1/channels/{id}/status | channels:read | — → {"online": 0|1} | 200/404 |
| POST | /v1/channels/{id}/messages | channels:write | {external_client_id, external_channel_id, message} -- inbound delivery from your custom channel into this account's chat | 202/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.
| Header | Meaning |
|---|---|
Content-Type | application/json |
X-Smartbtn-Event | Always channel.outbound today — the only event this transport sends. Switch on it rather than assuming it. |
X-Smartbtn-Signature | sha256=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
| Method | Path | Scope | Key parameters | Codes |
|---|---|---|---|---|
| GET | /v1/widget/settings | widget:read | — (all appearance and behaviour settings for the widget this token belongs to) | 200 |
| PATCH | /v1/widget/settings | widget:write | any 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/skins | widget:read | — → skin ids with premium and available (your plan already applied) | 200 |
| GET | /v1/widget/icons | widget:read | — → launcher-icon preset ids (raw SVG frames are not returned) | 200 |
| GET | /v1/widget/embed | widget:read | — → the <script> tag to paste onto your site | 200 |
| POST | /v1/widget/cache/purge | widget: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
| Method | Path | Scope | Key parameters | Codes |
|---|---|---|---|---|
| GET | /v1/account | account:read | — → the profile of the account that owns this widget, plus legal_entities[] | 200/404 |
| PATCH | /v1/account | account:write | any 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 fields —
name, 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
| Method | Path | Scope | Key parameters | Codes |
|---|---|---|---|---|
| GET | /v1/departments | departments:read | ?cursor=&limit= | 200 |
| POST | /v1/departments | departments: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 merged | 201/400/409/422 |
| GET | /v1/departments/{id} | departments:read | — → one department, with agent_ids and all seven days of schedule | 200/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 below | 200/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}/agents | departments:write | {agent_ids: [id, ...]} — replaces the whole set; an empty array empties the department. Every id must belong to this widget | 200/400/404/409/422 |
| PUT | /v1/departments/{id}/schedule | departments:write | {schedule: {Monday: "full" | {from, to} | null, ...}} — replaces the whole week; an omitted day is closed | 200/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)
| Method | Path | Scope | Key parameters | Codes |
|---|---|---|---|---|
| GET | /v1/billing/balance | billing:read | — → balance_minor, reserved_minor, available_minor, currency, minor_unit_scale | 200 |
| GET | /v1/billing/transactions | billing:read | limit, cursor, type, state, direction=credit|debit | 200/422 |
| GET | /v1/billing/transactions/{id} | billing:read | — | 200/404 |
| GET | /v1/billing/subscriptions | billing:read | limit, cursor, status, kind=product|package | 200/422 |
| GET | /v1/billing/subscriptions/{id} | billing:read | — | 200/404 |
| GET | /v1/billing/invoices | billing:read | limit, cursor, status | 200/422 |
| GET | /v1/billing/invoices/{id} | billing:read | — → the invoice plus its items | 200/404 |
| GET | /v1/billing/entities | billing:read | limit, cursor — account-scoped, see below | 200/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.
| Method | Path | Scope | Key parameters | Codes |
|---|---|---|---|---|
| GET | /v1/messengers | messengers:read | — → all eleven providers, each with connected, writable and has_token | 200 |
| GET | /v1/messengers/{provider} | messengers:read | — | 200/404 |
| PUT | /v1/messengers/{provider} | messengers:write | every field the provider declares (url, token, and sid for wa/apple) — replaced whole | 200/400/404/409/415/422/429 |
| DELETE | /v1/messengers/{provider} | messengers:write | — | 204/404/429 |
| POST | /v1/messengers/{provider}/verify | messengers:write | — (no body; re-registers the stored credential) | 200/400/404/409/422/429 |
| GET | /v1/messengers/vk/communities | messengers:read | cursor, limit | 200 |
| POST | /v1/messengers/vk/communities | messengers:write | token (community access token), community (id, club123, or a vk.com link) | 201/400/409/415/422/429/502 |
| POST | /v1/messengers/vk/communities/{id}/primary | messengers:write | — (no body) | 200/400/404/409/422 |
| DELETE | /v1/messengers/vk/communities/{id} | messengers:write | — | 204/404/429/502 |
| GET | /v1/messengers/avito/cabinets | messengers:read | cursor, limit | 200 |
| GET | /v1/messengers/avito/cabinets/{id} | messengers:read | — | 200/404 |
| PATCH | /v1/messengers/avito/cabinets/{id} | messengers:write | status: enabled | disabled | 200/400/404/409/415/422 |
| DELETE | /v1/messengers/avito/cabinets/{id} | messengers:write | — | 204/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).
| Method | Path | Auth | Key parameters | Codes |
|---|---|---|---|---|
| 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 |