CRM integrations
A CRM connection links one bot to one CRM account so that customer profiles, deals, and conversation summaries can travel in both directions. Four providers are supported: amoCRM and Kommo, Bitrix24, RetailCRM, and OzmaCRM (ozma.io).
Every provider sits behind the same internal contract, so authorization, field discovery, mapping, conflict handling, and echo suppression behave identically no matter which CRM you use. Only the wire format differs, and that is what this page describes provider by provider.
What you can do today
The CRM screen is Integrations → CRM in the dashboard. It needs the Reactions bot right. From there you can:
- connect a CRM through the wizard, rename a connection, test it, reconnect it, and delete it;
- edit the field mapping and save it as a new revision;
- run a preview of the mapping against real subscriber profiles, then start the initial sync;
- watch the sync log — operations and their per-record jobs, with retry for a failed job;
- resolve conflicts that the mapping's policy left for a human.
The durable sync engine is running: the job worker wakes every 5 seconds (batches of 20 jobs, a 2-minute lease per job) and the poll reconciler every 30 seconds (batches of 20 due cursors). Nothing here waits for an operator to switch it on.
Provider availability differs, and this is the part worth reading twice:
| Provider | How you connect | Inbound in practice |
|---|---|---|
| amoCRM / Kommo | OAuth, self-service in the wizard | polling — see "Webhook ingress and trust models" below |
| RetailCRM | API key, self-service in the wizard | polling |
| OzmaCRM | OIDC client credentials plus a user login, self-service in the wizard | polling only |
| Bitrix24 | not connectable — the connect endpoint answers 503 | — |
Bitrix24's adapter is complete and passes the same contract suite as the others, but no OAuth application is configured for it on the platform, so there is nothing to connect against. The wizard shows its card with an "Unavailable" badge rather than letting you fill a form that would fail.
How a provider is reached
Every CRM request goes through one hardened transport, whichever provider it belongs to:
- HTTPS only. No user info in the URL, no custom port, and no proxy inherited from the process environment.
- The host is re-resolved before each request, and the connection is refused if any resolved address is loopback, private, link-local, multicast, or a cloud metadata address. Redirects must stay on the same host and are re-validated the same way.
- A response body is capped at 1 MB. The request times out after 30 seconds, the TLS handshake after 10 seconds, and response headers after 15 seconds.
Retry-AfterandX-RateLimit-Resetare honoured as retry hints, capped at 5 minutes. Provider rate limits themselves are the provider's, not MyBot's: there is no additional per-provider call quota in the platform.- Provider failures are reduced to a short lowercase code. HTTP 429, 5xx, and transport failures are classified transient and retried; everything else is terminal. Response bodies, request URLs, and provider error prose never reach a log, a job error, or an audit record.
Three of the four providers may only be pointed at a closed set of hosts, derived from the account identifier you supply:
- amoCRM and Kommo —
<account>.amocrm.ruor<account>.kommo.com. - Bitrix24 —
<portal>.bitrix24.ruor<portal>.bitrix24.com. Confidential token exchange additionally uses the one fixed authorization serveroauth.bitrix.info. - RetailCRM —
<account>.retailcrm.ru.
OzmaCRM is the exception. Because self-hosted instances are supported, its base URL is supplied by the operator rather than derived from an approved suffix. Only the shape is validated (HTTPS, no user info, no port, no path, query, or fragment); the private-egress guard above is what actually keeps a connection from reaching an internal address.
amoCRM and Kommo
Prerequisites. The platform operator registers one OAuth integration and
sets AMOCRM_CLIENT_ID and AMOCRM_CLIENT_SECRET. Both must be set together,
and APP_BASE_URL must be an absolute HTTPS origin, because the redirect URI is
derived from it as <APP_BASE_URL>/api/integrations/crm/amocrm/callback. Until
that is configured, the connect endpoints answer 503.
Connecting. In the dashboard this is the wizard's amoCRM card. Over REST, a bot owner posts the account host and follows the returned URL:
curl -X POST "$BASE_URL/api/bots/$BOT_ID/crm/connections/amocrm/start" \
-H "Authorization: Bearer $PAT" \
-H "Content-Type: application/json" \
-d '{"account_key":"acme.amocrm.ru"}'
The response contains redirect_url. The browser state is single-use, lives
for 5 minutes, and is stored hashed — never in plain text. Starts are limited
to three per minute per account. A denied consent still consumes the state, so
the same callback cannot be retried with a forged code.
Verification at connect time. After the token exchange the account is
re-read from GET /api/v4/account. The subdomain reported by amoCRM must match
the account key you supplied, otherwise the connection fails with an account
mismatch, and the account currency must be a valid ISO 4217 code. Tokens are
sealed with envelope encryption bound to the connection, the bot, the provider,
the normalized account host, the key version, and the credential revision, so a
ciphertext moved to another connection or another allowed subdomain will not
open.
Capabilities. Customers, leads, deals, conversations, webhooks, polling, and OAuth, plus the account currency.
Fields and pipelines. Custom fields are discovered for contacts and leads
and exposed as custom:<field id>. A computed field is never writable, a
multiselect field is marked as multi-valued, and a field type MyBot does not
recognize is offered read-only rather than guessed. Pipelines and their statuses
come from GET /api/v4/leads/pipelines.
Writes. Contacts and leads are written with request_id set to the
per-record idempotency key (255 characters at most), and the response must echo
that same key back for exactly one record — otherwise the effect is treated as
ambiguous rather than assumed successful.
Conversations. Exported as a common note on the contact or lead. The summary and each message are limited to 24 000 characters on input, only the last 50 messages are kept, and the joined text is truncated to 6 000 characters.
Webhooks. amoCRM and Kommo do not sign their webhook bodies, so
authentication is the opaque ingress route itself. On top of that the adapter
accepts JSON or form-encoded deliveries up to 256 KB, rejects duplicate JSON
keys, trailing data, and nesting deeper than 64 levels, and requires exactly one
entity event per delivery. If the body declares account[subdomain], it must
match the connected account. Accepted events are contact add and update, and
lead add, update, and status change. The record is then re-read from the API and
must carry an updated_at no older than the event, so the authoritative state
is never taken from the webhook body.
Polling. Contacts are paged by updated_at ascending, 50 per page, with an
encrypted cursor.
Bitrix24
Bitrix24 cannot be connected today. POST /api/bots/{botID}/crm/connections/bitrix24/start
answers 503: the platform has no Bitrix24 OAuth application configured, and
there is no API-key route for it the way RetailCRM has one. The rest of this
section describes an adapter that is finished and tested but currently
unreachable — keep it as reference, not as a setup guide.
Prerequisites. Either a local OAuth application on the portal, or an inbound webhook. One connection uses one of the two — never both at once.
OAuth mode. Consent happens at https://<portal>/oauth/authorize/; the
token exchange goes to the fixed oauth.bitrix.info server. The token response
must declare the domain oauth.bitrix.info and a client endpoint of exactly
https://<portal>/rest/, otherwise the connection is rejected as a portal
mismatch. The returned member_id is required and stored: it is later used to
authenticate webhooks.
Inbound webhook mode. The portal, the member id, and the webhook token are
sealed together, and REST calls are addressed as
/rest/<member id>/<token>/<method>.json. In this mode there is no refresh
token, so the connection reports OAuth as unavailable. Because the credential
lives inside the request path, transport errors are always reduced to a stable
code rather than quoting the URL.
Least permissions. Grant only the CRM scope your mapping needs. The adapter
calls profile, crm.contact.fields, crm.lead.fields, crm.deal.fields,
crm.dealcategory.list, crm.status.list, crm.contact.list, the
crm.{contact,lead,deal}.{add,update,get} methods, and
crm.timeline.comment.add. Nothing else is used.
Capabilities. Customers, leads, deals, conversations, webhooks, polling, and idempotent external keys. Bitrix24 reports no account currency, so a money mapping must name its ISO currency explicitly.
Fields and pipelines. Contact, lead, and deal fields are read from the
portal with their read-only and multi-value flags preserved. Pipelines are deal
categories, and each category's statuses are read from
crm.status.list filtered by DEAL_STAGE_<category id>.
Choosing contacts or leads. By default a customer is written as a contact.
Setting {"customer_entity":"lead"} in the connection configuration writes
leads instead.
Writes. crm.<entity>.add and crm.<entity>.update are called with
REGISTER_SONET_EVENT=N so a sync does not spam the activity stream. Creating a
brand-new record requires a mapped external-key field, and that field must be
writable — otherwise the write is refused instead of silently creating an
unlinkable record.
Conversations. Appended as a timeline comment on the contact, lead, or deal,
prefixed with [mybot:<idempotency key>] so a repeated delivery is
recognizable in the timeline.
Webhooks. The application_token and member_id are compared in constant
time and auth.domain must equal the connected portal. Bodies are JSON only, up
to 256 KB, with no trailing data. The handled events are
ONCRMCONTACTADD/UPDATE/DELETE, ONCRMLEADADD/UPDATE/DELETE, and
ONCRMDEALADD/UPDATE/DELETE. Every non-delete event re-reads the record via
crm.<entity>.get, and the returned ID must match the event.
Polling. crm.contact.list ordered by DATE_MODIFY and ID, resumed from
an encrypted checkpoint.
RetailCRM
Prerequisites. An API key. RetailCRM has no OAuth here: authorization and refresh both answer with an explicit "unsupported" code, and the key is the only credential.
Connecting. The wizard's RetailCRM card, or directly:
curl -X POST "$BASE_URL/api/bots/$BOT_ID/crm/connections/retailcrm" \
-H "Authorization: Bearer $PAT" \
-H "Content-Type: application/json" \
-d '{"name":"Shop","account_key":"acme.retailcrm.ru","api_key":"…"}'
name is optional and defaults to the account host. The response is the safe
connection summary — never the key.
Least permissions. At connect time the adapter reads /api/credentials and
the sites, stores, and order-methods references; each of the three must
return at least one usable entry. Conversation export additionally requires the
customer_write scope (on older accounts, the
/api/v5/customers/notes/create credential). Without it, the connection simply
reports conversations as unavailable instead of failing at export time.
Capabilities. Customers, deals, orders, polling, and idempotent external keys. Conversations depend on the scope above; webhooks depend on a stored token, described below.
Fields and pipelines. Custom fields are read from /api/v5/custom-fields
separately for the customer and order entities and exposed as
custom:<code>. A field is writable only when RetailCRM marks it editable.
Discovery pages 250 fields at a time, over at most 100 pages and 10 000 fields.
Order statuses are grouped by site, and each site becomes one pipeline.
Writes. Customers and orders are created and edited through
/api/v5/customers/... and /api/v5/orders/... as form posts; the API key is
attached server-side and never travels in a mapping. The MyBot local identifier
is written as externalId. If the identifier returned by RetailCRM does not
match the linked record, the effect is reported as ambiguous rather than
silently repointing the link.
Conversations. Exported as a customer note; the text is limited to 2 000 characters.
Webhooks. RetailCRM does not sign its callbacks. A connection can be sealed
with a MyBot-issued token (16 to 256 printable characters) that you paste into
RetailCRM's own webhook template; deliveries must then carry it in the
X-Mybot-Webhook-Token header, and it is compared in constant time. A
connection without such a token answers webhook_unsupported, and polling
becomes the only inbound transport — which is exactly what the connection's
capabilities report. The self-service connect body above has no field for that
token yet, so a connection you create yourself is poll-only. Bodies are limited
to 64 KB, the type must be customer or order, the event must be create,
update, or delete, and every non-delete event re-reads the record by ID,
because the templated body carries only an identity.
Polling. /api/v5/customers/history and /api/v5/orders/history, 50
entries per page, resumed by sinceId or startDate.
OzmaCRM
OzmaCRM is ozma.io, a low-code CRM/ERP built on OzmaDB. It has no fixed CRM schema, so unlike the other three providers you tell MyBot which entities to use rather than the other way round.
Prerequisites.
- The instance base URL. Cloud instances look like
https://<account>.api.ozma.org; a self-hosted instance is any HTTPS origin you control. - An OIDC client id and client secret, not just a login. Authentication uses the OIDC resource-owner password grant, and it is sent with per-tenant client credentials.
- A username and password for the OzmaCRM user MyBot will act as. Give it the narrowest role that can read and write your mapped entities.
- The OIDC base URL and realm for a self-hosted instance. A cloud instance
defaults to the
defaultrealm onhttps://account.ozma.io; the token endpoint is/auth/realms/<realm>/protocol/openid-connect/token. - The
usr.mybot_sync_marksjournal entity, described below. Create it before connecting: the connection test fails closed without it.
The refresh token is mandatory. The access token's expiry is enforced locally:
once it has lapsed the adapter fails with token_expired rather than refreshing
in the middle of a write, so the connection is re-sealed before the next
dispatch instead of mid-effect.
Choosing entities. A connection carries a small configuration:
customer_entity— the schema and entity that holds customers (required).deal_entity— the schema and entity that holds deals (required).conversation_entity— optional, the entity that logs conversations.deal_status_field— the deal column whose domain becomes the pipeline.updated_at_field— the change timestamp column,updated_atby default.
Every schema, entity, and field name must be lowercase ASCII letters, digits, or underscores, must not start with a digit, and is limited to 64 characters. This is not cosmetic: those names are interpolated into FunQL, so the allowlist is the injection boundary.
The sync journal. OzmaCRM is the one provider where MyBot's causal markers cannot live on your own records. Instead they go into a separate journal entity that you create yourself — MyBot never provisions or replaces a schema, because uploading a layout would overwrite your data. Create it before turning sync on:
create entity usr.mybot_sync_marks with fields:
entity_kind string not null -- customer | lead | deal | order | conversation
remote_id string -- nullable: blank for a brand-new record's first marker
marker string not null -- the per-effect idempotency key
direction string not null -- outbound | inbound
created_at datetime not null
Testing the connection checks /api/check_access, then this journal, then every
configured entity. A missing or incompatible journal fails closed with
missing_sync_marks_entity and returns the definition above verbatim; sync stays
off until it exists.
Fields and pipelines. Writable columns of the configured entities are
discovered from their entity info (id is skipped). int and double map to
integer, bool to boolean, date and datetime to time, and string,
reference, enum, and uuid to string; an array column takes its subtype and
is marked multi-valued. A column type MyBot does not recognize is skipped rather
than guessed, and stays visible through the mapping preview's overflow. Since
OzmaDB has no pipeline concept, the domain of deal_status_field becomes one
synthetic pipeline.
Writes. Each write is one POST /api/entities/transaction containing two
operations: the record write first, then the journal insert. Both commit
together, so a marker can never exist without the change it describes. If the
transaction fails, the reported error says which of the two failed.
The columns MyBot writes itself are fixed, so your entities must accept them:
- Customer —
name, plusphone,email, andtagswhen present, plus every mapped column that discovery marked writable. - Deal —
name,status,amount_minor,currency,customer(the linked customer's id), andtags. A deal write requires a status and a three-letter currency. - Conversation —
customerordeal(the owner's id),summary, andcreated_at.
A mapped value whose column discovery did not mark writable is dropped before the request, so a mapping revision cannot smuggle a write into a column that was never offered.
Conversations. The summary and each message are limited to 16 384 bytes, the last 50 messages are kept, and the joined text is truncated to 32 000 bytes on a valid UTF-8 boundary.
Polling — and only polling. Changes are read with a keyset FunQL query
ordered by your updated_at column and id, 200 rows per page, resuming
strictly after the last row returned — so overlapping poll windows cannot skip
or duplicate a record. Each row is left-joined to the journal so an inbound
change can be told apart from an echo of MyBot's own last write.
OzmaDB has no native webhook, and MyBot does not expose a real-time ingress route for OzmaCRM today. An OzmaCRM connection therefore syncs inbound on the poll cycle only; there is no trigger for you to install yet.
Field discovery and mapping
A mapping is an immutable revision. Editing a mapping creates a new revision and does not reinterpret a job that is already in flight, so a change you make now cannot rewrite the meaning of a write that was already queued.
Each field mapping names:
- a local path —
profile.id,profile.name,profile.first_name,profile.last_name,profile.phone,profile.email,profile.tags, or any other profile property asprofile.<property>; and for dealsdeal.id,deal.customer_id,deal.name,deal.pipeline,deal.status,deal.amount_minor(ordeal.value_minor),deal.currency, or any other deal property asdeal.<property>; - a remote key from the discovered field list;
- an entity — customer, lead, deal, or order;
- a direction — outbound (MyBot to CRM) or inbound (CRM to MyBot);
- a transform;
- and whether the value is required.
Transforms
identity, string, integer, boolean, timestamp, datetime_rfc3339,
phone_e164, email_normalized, enum_map, tags, and
money_minor_currency. (minor_units still exists for mappings created before
money gained an explicit currency.)
They are declarative, never customer-supplied code:
email_normalizedtrims and lowercases, requires an address shape, and rejects anything over 320 characters.phone_e164accepts spaces, dashes, and parentheses as separators and produces a+-prefixed number of 9 to 16 characters that does not start with+0.stringcaps a value at 10 000 characters.tagsrequires strings, trims them, rejects empty tags and tags over 128 characters, and de-duplicates.enum_mapmaps only through a closed list you define. Inbound, the list is inverted — and if two local values map to the same remote value, the mapping is ambiguous and the remote value goes to overflow instead of being guessed.money_minor_currencyproduces signed minor units plus an ISO 4217 currency.
What a mapping must satisfy
Saving a revision fails if a local path is unknown, a remote key does not exist
for that entity, an outbound mapping targets a field the provider marked
read-only, an enum_map has no values, a money mapping has no uppercase ISO
currency, two mappings write the same remote destination in the same direction
for the same entity, two inbound mappings write the same local destination, or
the transform cannot bridge the local and remote types. A tag mapping needs both
sides, and a trigger must name a known event, the mapping's own pipeline, and
one of that pipeline's statuses.
Tags: inbound only
MyBot labels are never sent to a CRM as tags. A subscriber profile stores
label identifiers, not the human-readable names a tag mapping compares
against, so pushing them outbound would write opaque strings like
7f3c1a2e-… into your customer card. The outbound tag list is therefore left
empty on purpose.
Inbound tags work normally: a tag mapping converts and normalizes remote tags
into local ones, and an unmapped remote tag goes to overflow rather than
disappearing. If you need a MyBot label visible in the CRM, map it as an
ordinary profile property through enum_map instead.
Overflow
Every mapping declares one overflow destination — a field or a note plus its key — and it is mandatory. Nothing that fails to map is discarded. A value that fails conversion, a required value that is missing, a local property with no mapping, an unmapped tag, an unmapped remote property, and an unmapped remote tag are all recorded as overflow with the original value and the reason, and routed to that destination.
Conflict policy
- mybot_wins — a remote change never overwrites a locally changed profile.
- crm_wins — the remote change is applied.
- newest_wins — the later timestamp wins; on an exact tie the change is marked for manual resolution.
- manual — every conflicting change is marked for manual resolution.
A profile with no recorded local change yet accepts the remote change under any policy. Independently of the policy, a remote change that is not newer than the last remote change already recorded for the link is ignored.
Mapping preview
Before anything is written you can project a mapping over real records. The preview is deterministic, has no provider dependency, and therefore cannot cause a remote write — it only shows what would be sent.
It samples at most 20 subscriber profiles, shows each mapped field with its source value, converted value, warning, and error, and lists the overflow the mapping would produce. Alongside the sample it reports the number of affected records, marked as exact when the owned-record query is complete or as an estimate when only a bounded count is available.
The deal sample is always empty, in the preview and in the initial sync alike. MyBot has no deal object of its own before a CRM is connected, so the only thing there is to seed outbound is subscriber profiles. Deals travel the other way: they arrive from the CRM and become customer events and goals (see "Deals, money, and attribution" below). The mapping editor still lets you configure deal fields, and they are used for inbound changes and for deals you push from a dialog.
Outbound writes, links, and idempotency
Every write carries a stable per-record idempotency key and results in a stored link — the pair of your local identifier and the CRM record id, together with the remote version and the last local, remote, and synced timestamps. The link, not a guess, is what makes a repeated write an update instead of a duplicate.
Each provider anchors the key in its own native mechanism: request_id on
amoCRM and Kommo, the external-key field and a comment marker on Bitrix24,
externalId on RetailCRM, and the transactional journal row on OzmaCRM.
Webhook ingress and trust models
Inbound changes reach MyBot either from a poll cursor or from a provider webhook. The webhook side has three separate ingress routes, one per provider, each with its own trust model:
| Route | What authenticates a delivery |
|---|---|
POST /hooks/crm/{routeKey}/amocrm | nothing in the body is signed: the opaque route is the secret, plus account[subdomain] must match the connected account |
POST /hooks/crm/{routeKey}/bitrix24 | application_token and member_id compared in constant time, and auth.domain must equal the connected portal |
POST /hooks/crm/{routeKey}/retailcrm | a MyBot-issued X-Mybot-Webhook-Token header, compared in constant time; a connection with no stored token answers webhook_unsupported |
The route key is random per connection and stored only as a hash, so the provider is bound at registration time: posting an amoCRM body to a RetailCRM route, or to a connection that is not connected, is not a different error — it is the same 404 as an unknown route. A delivery that reaches the right route but fails its adapter check gets a single generic 400. Neither response tells a prober whether the route or the connection exists. Every body is capped at 256 KiB with a 10-second read deadline.
Today those routes are not yet self-service. The per-connection route URL is generated when the connection is created but is not shown in the dashboard or returned by any API, and RetailCRM's webhook token has no field in the connect body. In practice, that means inbound changes arrive on the poll cycle for every provider. The trust models above are what a delivery will have to satisfy once the route URL is handed out; treat this section as reference, not as a setup checklist.
Inbound changes and echo suppression
An inbound change arrives either from a verified webhook or from a poll cursor, and both paths converge on the same normalization.
The problem both paths share is the echo: MyBot writes to the CRM, the CRM reports the change back, and a naive integration re-applies its own write in a loop. Suppression here is per-effect and consume-once:
- An outbound write stamps a marker on the remote record and records it as pending for that link.
- An inbound change is suppressed only if it carries a marker that is still pending for that exact connection and remote record, is no older than 24 hours, and was observed no later than the link's last known remote change.
- The marker is then consumed.
The practical consequence matters: a later change carrying the same value is treated as a genuine human edit, not as another echo. If someone opens the CRM and edits the field MyBot just wrote — even back to the same value — that edit is not swallowed. A marker that is never echoed back simply lapses after its time-to-live, so a lost webhook can never block a link's inbound sync forever.
The stable per-link identifier is deliberately not used for this. It is constant for the life of the link, so gating suppression on it would also swallow every later human edit.
Once a change survives suppression and the conflict policy, mapped values become audited profile property changes, each carrying its provenance: the connection, the link, the provider, the remote id, the remote version, and the change time. An empty value on a non-required inbound field clears the local property; on a required field it becomes overflow instead.
Reconciliation
The poll reconciler advances due cursors on its own every 30 seconds, but you can also force a connection to catch up immediately:
curl -X POST "$BASE_URL/api/bots/$BOT_ID/crm/connections/$CONNECTION_ID/reconcile" \
-H "Authorization: Bearer $PAT"
It answers {"status":"reconciling"} and re-reads the provider's change feed
from the stored cursor. Use it after a provider outage, after a delivery you
believe was dropped, or when a CRM-side edit is visibly missing on the MyBot
side. It is safe to repeat: a change that was already applied is recognized by
its link and idempotency key rather than applied twice.
This endpoint has no button in the dashboard yet. It is API-only, scoped
crm:write, and needs the Reactions bot right.
Dialog actions
An operator working a conversation can look at the CRM side of that one person and push them across on demand. These three actions exist over REST only — there is no CRM tab in the operator inbox yet. They all need the Dialogs bot right.
GET /api/bots/{botID}/subscribers/{subscriberID}/crm/status(subscribers:read) — is this subscriber linked, to which connection and remote record, when was it last synced, and how did the last job end.POST /api/bots/{botID}/subscribers/{subscriberID}/crm/sync(subscribers:write) — push this subscriber now. The body names theconnection_id, and may carry an optionaldeal(name, pipeline, status,value_minor,currency, extra properties) to create or update alongside the customer. The subscriber is projected exactly the same way the background sync projects them, so a manual push never disagrees with the scheduled one.POST /api/bots/{botID}/subscribers/{subscriberID}/crm/export-conversation(subscribers:write) — write the conversation into the CRM as a note. The body names only theconnection_id; what gets exported is described below.
Each returns the same durable operation object the sync log shows, so a manual action is visible on the connection's operations list like any other.
What a conversation export contains
An export is a trimmed, edited excerpt — not a transcript. Before anything leaves MyBot:
- At most the newest 50 messages are kept. An older conversation is cut, not paged.
- Each message is truncated to 4 000 characters, and so is the summary.
- An attachment becomes the literal text
[attachment]— no file name, no link, no MIME type, no size. The CRM learns that something was attached and nothing more. - Authors are reduced to two roles,
customerandoperator. Which operator wrote a line never travels. - Empty messages are dropped rather than exported as blanks.
Provider limits apply on top of that: amoCRM keeps the joined text to 6 000 characters, RetailCRM to 2 000, OzmaCRM to 32 000 bytes.
Private operator notes can never be exported. This is not a filter that could be misconfigured: the export path reads the ordinary dialog message timeline and has no code path, and no request field, that reaches the private notes table at all. There is nothing to accidentally include. See Support inbox for what a private note is.
AI qualified-lead handoff
An AI agent can hand a qualified lead to the CRM itself, through one
closed tool: crm.sync_qualified_lead. It is a write-effect tool, so it obeys
the agent's normal approval and idempotency rules.
Two boundaries make it safe to give a model:
- The connection must be explicitly approved for that agent. The tool can only reach connection ids listed in the bot's own AI-agent tool policy. A model naming any other connection — including a real one belonging to the same bot — is denied before anything runs.
- Facts are restricted to fields the mapping already configures. The model
may attach at most 20
{key, value}facts (key up to 128 characters, value up to 1 000). Each key must be a local path the connection's current mapping already writes outbound to a customer, deal, or order field. An unrecognized key rejects the call — it is not silently dropped and not silently accepted, because either would let a model decide what lands in your CRM.
The model never supplies the conversation. Setting export_conversation asks
the server to attach it, and the server sources it from the same bounded,
redacted export described above — subscriber, bot, and credentials all resolved
server-side.
The tool returns the operation id and status, nothing else.
Deals, money, and attribution
Money is always a signed 64-bit minor amount plus an ISO 4217 currency, never a floating point number. On the provider payload only the minor amount travels; the currency stays in the canonical deal record. Inbound, a money mapping keeps the minor amount and warns that the currency was fixed by the mapping — if the currency can vary, map it as its own field.
A deal change produces a customer event named crm.deal_created,
crm.deal_updated, crm.deal_won, or crm.deal_lost, with the local id,
customer id, pipeline, status, and (when present) the minor value and currency.
A won deal additionally produces the crm.deal_won goal for
attribution, carrying the same value and currency.
Privacy and redaction
- Credentials cannot be serialized at all: the credential type refuses to marshal to JSON, so a token cannot leak through a response, a log line, or a job payload by accident.
- Credentials and cursors are encrypted at rest and bound to one connection, bot, provider, account, key version, and credential revision. A ciphertext moved elsewhere fails to open rather than authorizing the wrong account.
- Connection reads expose only a safe summary: id, bot, provider, name, account key, status, capabilities, mapping revision, last error code, and timestamps. The credential ciphertext and the webhook route material are not part of it.
- Request and response metadata kept for troubleshooting is redacted: the
method, the scheme, host and path only, the status code, and the retry hint.
Any header whose name contains
authorization,token,secret,api-key,cookie, orpasswordis stored as[redacted]. - Conversation exports are bounded, role-anonymized excerpts, and private operator notes are structurally excluded from them.
- Inbound profile changes are audited with their provenance, so every value a CRM wrote into a profile can be traced back to the connection and remote record it came from.
- CRM links are a retention category of their own and are kept for 730 days; they are covered by data export and erasure requests like any other category.
Connection state, reconnecting, and disconnecting
One bot may hold one connection per provider and account key. A connection is
created in the connected state; the state set also allows pending,
expired, disabled, and failed, together with a stable last error code.
Reconnecting rotates the sealed credentials atomically against the expected revision, so two concurrent reconnects cannot interleave and leave a half-rotated connection. Because the seal is bound to that revision, the previous ciphertext stops opening the moment the new one is committed.
To disconnect safely:
- Revoke the credential on the CRM side first — the OAuth application's access for amoCRM and Kommo, the API key for RetailCRM, the OIDC client for OzmaCRM.
- Remove the inbound path if you configured one: the webhook subscription in the CRM.
- Delete the connection — the Delete button on the connection card, or
DELETE /api/bots/{botID}/crm/connections/{connectionID}(crm:write). Removing a connection removes its mapping revisions, links, and cursors along with it, and changes nothing inside your CRM.
The OzmaCRM journal entity is yours: it is safe to keep it, and safe to drop it once no connection uses that instance.
API
All routes below are under /api/bots/{botID}/crm/… unless shown otherwise, and
all of them additionally require the Reactions bot right. Request and
response schemas are in the API Reference.
Connections
| Endpoint | Scope | What it does |
|---|---|---|
GET /connections | crm:read | list connections (safe summaries) |
GET /connections/{connectionID} | crm:read | one connection summary |
POST /connections/{provider}/start | crm:write | begin the browser OAuth flow; returns redirect_url. amoCRM only today — every other provider answers 503 |
GET /api/integrations/crm/{provider}/callback | — | the browser redirect target; the single-use state is the authentication, so no token is needed |
POST /connections/retailcrm | crm:write | connect RetailCRM with an API key |
POST /connections/ozmacrm | crm:write | connect OzmaCRM with OIDC client credentials, a login, and the entity configuration |
PATCH /connections/{connectionID} | crm:write | rename |
DELETE /connections/{connectionID} | crm:write | disconnect and remove mappings, links, and cursors |
POST /connections/{connectionID}/test | crm:write | re-run the provider's connectivity and prerequisite checks |
Mapping and preview
| Endpoint | Scope | What it does |
|---|---|---|
GET /connections/{connectionID}/fields | crm:read | discovered remote fields |
GET /connections/{connectionID}/pipelines | crm:read | pipelines and their statuses |
GET /connections/{connectionID}/mapping | crm:read | the current mapping revision |
PUT /connections/{connectionID}/mapping | crm:write | save a new immutable revision |
POST /connections/{connectionID}/preview | crm:read | project the mapping over real profiles — deliberately a read: it causes no remote write |
Sync
| Endpoint | Scope | What it does |
|---|---|---|
POST /connections/{connectionID}/initial-sync | crm:write | seed subscriber profiles outbound |
POST /connections/{connectionID}/reconcile | crm:write | force a catch-up poll |
GET /operations | crm:read | operations, newest first, keyset-paged |
GET /operations/{operationID} | crm:read | one operation and its jobs |
POST /operations/{operationID}/cancel | crm:write | cancel a running operation |
POST /jobs/{jobID}/retry | crm:write | retry one failed job |
GET /conflicts | crm:read | jobs waiting for manual resolution |
POST /conflicts/{jobID}/resolve | crm:write | resolve one conflict |
Dialog actions (subscribers:read / subscribers:write, Dialogs bot right,
see "Dialog actions" above) are
GET|POST /api/bots/{botID}/subscribers/{subscriberID}/crm/status|sync|export-conversation.
They are not in the API Reference yet.
MCP tools
Six read-only tools are published over MCP, all requiring
crm:read:
list_crm_connections— a bot's connections with provider, status, and capabilities.get_crm_connection— one connection's safe summary.get_crm_mapping— the connection's current mapping revision.list_crm_operations— recent sync operations, newest first, optionally scoped to one connection.get_crm_operation— one operation with its jobs (never a job's raw request payload).list_crm_conflicts— jobs waiting for manual conflict resolution.
None of them ever returns credentials. There is no CRM write tool on purpose: connecting, syncing, retrying, and resolving a conflict stay REST-only, so an agent can watch a CRM integration but cannot change what it does.
Known limitations
Recorded here so nobody rediscovers them as bugs:
- Bitrix24 cannot be connected — the connect endpoint answers
503. - MyBot labels are not exported as CRM tags; inbound tag mapping works.
- The preview and the initial sync always show an empty deal list — MyBot has no deal object of its own to seed.
- Webhook ingress is not self-service yet: the per-connection route URL is never handed out, so every provider is effectively poll-only, and RetailCRM's webhook token cannot be set from the connect body.
- OzmaCRM has no real-time trigger — polling only.
- Reconcile has no button in the dashboard; it is API-only.
- The per-subscriber CRM actions have no UI; they are API-only and absent from the API Reference.
Related pages
- Integrations — the other external services a bot can connect.
- Sources and Keys — REST and OzmaDB connectors for reading and writing data directly.
- Web requests and webhooks — inbound and outbound HTTP.
- People and profiles — the profiles a CRM mapping reads and writes.
- Support inbox — the conversation an export is built from, and private notes.
- AI agent — the agent that can hand a qualified lead over.
- MCP — the six read-only CRM tools.
- Analytics — where a won deal lands as a goal.