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:

ProviderHow you connectInbound in practice
amoCRM / KommoOAuth, self-service in the wizardpolling — see "Webhook ingress and trust models" below
RetailCRMAPI key, self-service in the wizardpolling
OzmaCRMOIDC client credentials plus a user login, self-service in the wizardpolling only
Bitrix24not 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-After and X-RateLimit-Reset are 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.ru or <account>.kommo.com.
  • Bitrix24 — <portal>.bitrix24.ru or <portal>.bitrix24.com. Confidential token exchange additionally uses the one fixed authorization server oauth.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 default realm on https://account.ozma.io; the token endpoint is /auth/realms/<realm>/protocol/openid-connect/token.
  • The usr.mybot_sync_marks journal 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_at by 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, plus phone, email, and tags when present, plus every mapped column that discovery marked writable.
  • Deal — name, status, amount_minor, currency, customer (the linked customer's id), and tags. A deal write requires a status and a three-letter currency.
  • Conversation — customer or deal (the owner's id), summary, and created_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 pathprofile.id, profile.name, profile.first_name, profile.last_name, profile.phone, profile.email, profile.tags, or any other profile property as profile.<property>; and for deals deal.id, deal.customer_id, deal.name, deal.pipeline, deal.status, deal.amount_minor (or deal.value_minor), deal.currency, or any other deal property as deal.<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_normalized trims and lowercases, requires an address shape, and rejects anything over 320 characters.
  • phone_e164 accepts spaces, dashes, and parentheses as separators and produces a +-prefixed number of 9 to 16 characters that does not start with +0.
  • string caps a value at 10 000 characters.
  • tags requires strings, trims them, rejects empty tags and tags over 128 characters, and de-duplicates.
  • enum_map maps 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_currency produces 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.

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:

RouteWhat authenticates a delivery
POST /hooks/crm/{routeKey}/amocrmnothing in the body is signed: the opaque route is the secret, plus account[subdomain] must match the connected account
POST /hooks/crm/{routeKey}/bitrix24application_token and member_id compared in constant time, and auth.domain must equal the connected portal
POST /hooks/crm/{routeKey}/retailcrma 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:

  1. An outbound write stamps a marker on the remote record and records it as pending for that link.
  2. 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.
  3. 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 the connection_id, and may carry an optional deal (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 the connection_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, customer and operator. 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, or password is 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:

  1. 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.
  2. Remove the inbound path if you configured one: the webhook subscription in the CRM.
  3. 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

EndpointScopeWhat it does
GET /connectionscrm:readlist connections (safe summaries)
GET /connections/{connectionID}crm:readone connection summary
POST /connections/{provider}/startcrm:writebegin the browser OAuth flow; returns redirect_url. amoCRM only today — every other provider answers 503
GET /api/integrations/crm/{provider}/callbackthe browser redirect target; the single-use state is the authentication, so no token is needed
POST /connections/retailcrmcrm:writeconnect RetailCRM with an API key
POST /connections/ozmacrmcrm:writeconnect OzmaCRM with OIDC client credentials, a login, and the entity configuration
PATCH /connections/{connectionID}crm:writerename
DELETE /connections/{connectionID}crm:writedisconnect and remove mappings, links, and cursors
POST /connections/{connectionID}/testcrm:writere-run the provider's connectivity and prerequisite checks

Mapping and preview

EndpointScopeWhat it does
GET /connections/{connectionID}/fieldscrm:readdiscovered remote fields
GET /connections/{connectionID}/pipelinescrm:readpipelines and their statuses
GET /connections/{connectionID}/mappingcrm:readthe current mapping revision
PUT /connections/{connectionID}/mappingcrm:writesave a new immutable revision
POST /connections/{connectionID}/previewcrm:readproject the mapping over real profiles — deliberately a read: it causes no remote write

Sync

EndpointScopeWhat it does
POST /connections/{connectionID}/initial-synccrm:writeseed subscriber profiles outbound
POST /connections/{connectionID}/reconcilecrm:writeforce a catch-up poll
GET /operationscrm:readoperations, newest first, keyset-paged
GET /operations/{operationID}crm:readone operation and its jobs
POST /operations/{operationID}/cancelcrm:writecancel a running operation
POST /jobs/{jobID}/retrycrm:writeretry one failed job
GET /conflictscrm:readjobs waiting for manual resolution
POST /conflicts/{jobID}/resolvecrm:writeresolve 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.