CRM integrations
Connecting amoCRM/Kommo, Bitrix24, RetailCRM, and OzmaCRM: authorization, field discovery, mapping, preview, two-way sync without echo loops, webhook ingress, dialog actions, and the AI lead handoff.
In questa pagina
A CRM connection binds one bot to one CRM account so that customer profiles, deals, and conversation summaries move in both directions. Four providers are supported: amoCRM and Kommo, Bitrix24, RetailCRM, and OzmaCRM (ozma.io).
Every provider sits behind one internal contract, so authorization, field discovery, mapping, conflict resolution, and echo suppression behave the same whichever CRM you use. Only the exchange format differs, and that is what this page describes per 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, its operations and their per-record jobs, with retry for a failed job;
- resolve conflicts that the mapping's policy left for a human;
- see a connection's webhook ingress address and reissue it.
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.
All four providers connect, but the connect path and the inbound path differ:
| Provider | How you connect | How inbound changes arrive |
|---|---|---|
| amoCRM / Kommo | OAuth, self-service in the wizard | an amoCRM outgoing webhook to the address you were given, or polling |
| Bitrix24 | an incoming webhook you create on your own portal; the OAuth path is still closed | a portal outgoing webhook, or polling |
| RetailCRM | API key, self-service in the wizard | a RetailCRM webhook carrying a MyBot token header, or polling |
| OzmaCRM | OIDC client credentials plus a user login, self-service in the wizard | a trigger you install in OzmaDB yourself, or polling |
Polling works for all four and depends on nothing. A webhook is optional everywhere and only shortens the delay.
How a provider is reached
Every call to a CRM, whichever provider it is, goes through one hardened transport:
- 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 every request, and the connection is refused when any resolved address is loopback, private, link-local, multicast, or a cloud metadata address. Redirects must stay on the same host and are checked the same way.
- Response bodies are capped at 1 MB. The request timeout is 30 seconds, the TLS handshake 10 seconds, and the response headers 15 seconds.
Retry-AfterandX-RateLimit-Resetare honoured as retry hints, up to 5 minutes. Rate limits remain the CRM's own: the platform adds no separate call quota.- A provider error collapses to a short lowercase code. HTTP 429, 5xx, and transport failures are classified as transient and retried; everything else is terminal. Response bodies, request URLs, and provider error text never reach logs, job errors, or the audit trail.
Three of the four providers can 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. The confidential token exchange also goes to 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 instead of being derived from an allowed suffix. Only its shape is validated (HTTPS, no user info, no port, no path, query, or fragment); what protects against pointing it at an internal address is the private-egress ban described above.
amoCRM and Kommo
Prerequisites. The platform operator registers one OAuth integration and
sets AMOCRM_CLIENT_ID and AMOCRM_CLIENT_SECRET. Both are 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 amoCRM card in the wizard. Over REST the 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 carries redirect_url. Browser state is single-use, lives for
5 minutes, and is stored as a hash, never in the clear. Starts are limited to
three per minute per account. A denied consent consumes the state too, so the
same callback cannot be replayed with a forged code.
Connect-time verification. After the token exchange the account is read back
through GET /api/v4/account. The subdomain amoCRM returns 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, bot, provider,
normalised account host, key version, and 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 calculated field is never writable, a
multiselect field is marked multi-valued, and a field type MyBot does not know
is offered read-only. Pipelines and their statuses come from
GET /api/v4/leads/pipelines.
Writes. Contacts and leads are written with request_id set to the record's
idempotency key (255 characters at most), and the response must return exactly
one record carrying that key, otherwise the effect counts as ambiguous.
Conversations. Exported as an ordinary note on the contact or lead. The summary and each incoming message are capped at 24 000 characters, only the last 50 messages are kept, and the joined text is truncated to 6 000 characters.
Webhooks. amoCRM and Kommo do not sign webhook bodies, so the authentication
is the opaque ingress address itself, which MyBot hands out when the connection
is created. Beyond that, the adapter accepts JSON or form-encoded bodies 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. Contact add and
update, and lead add, update, and status change are handled. The record is then
re-read through the API and its updated_at must not predate the event, so
authoritative state never comes from the webhook body.
Polling. Contacts are paged by ascending updated_at, 50 per page, with an
encrypted cursor.
Bitrix24
Bitrix24 has two connect paths, and one of them works today.
Incoming webhook, the working path. You create an integration on your own portal under Applications → Webhooks → Incoming webhook and hand over its triple: the portal domain, the member id, and the webhook code shown on that page. This is the Bitrix24 card in the wizard, or a direct call:
curl -X POST "$BASE_URL/api/bots/$BOT_ID/crm/connections/bitrix24" \
-H "Authorization: Bearer $PAT" \
-H "Content-Type: application/json" \
-d '{"name":"Portal","account_key":"acme.bitrix24.ru","member_id":"…","webhook_token":"…"}'
The portal, member id, and token are sealed together, and REST calls are
addressed as /rest/<member id>/<token>/<method>.json. There is no refresh
token in this mode, so the connection reports that OAuth is unavailable. Because
the credential lives in the request path, transport errors always collapse to a
stable code and never quote the URL.
OAuth, still closed. POST /api/bots/{botID}/crm/connections/bitrix24/start
answers 503: the platform reads only AMOCRM_CLIENT_ID and
AMOCRM_CLIENT_SECRET and has no separate Bitrix24 OAuth application. The
wizard marks the "via OAuth" button unavailable while the incoming-webhook form
next to it keeps working. Read the mode below as reference.
OAuth mode, once an application exists. Consent is given at
https://<portal>/oauth/authorize/, and the token exchange goes to the fixed
oauth.bitrix.info server. The token response must carry the
oauth.bitrix.info domain and a client endpoint of exactly
https://<portal>/rest/, otherwise the connection is rejected as a portal
mismatch. The returned member_id is mandatory and stored: it authenticates
webhooks later.
Webhook ingress needs the application token. Bitrix24 deliveries are checked
against application_token, and on a deployment without
BITRIX24_APPLICATION_TOKEN every one of them is rejected before its body is
read. Both connect paths know this: the create response carries an
inbound_warning, and the dashboard shows it as its own block, so you do not
configure an address on the portal that is guaranteed to drop every delivery.
Polling still works.
Least privilege. Grant only the CRM scopes 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 does not report an account currency, so a money mapping must name an 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 here are
deal categories, and each category's statuses are read from crm.status.list
filtered by DEAL_STAGE_<category id>.
Contacts or leads. A customer is written as a contact by default. Setting
{"customer_entity":"lead"} in the connection config switches writes to leads.
Writes. crm.<entity>.add and crm.<entity>.update are called with
REGISTER_SONET_EVENT=N so syncing does not spam the activity stream. Creating
a new record requires a mapped external-key field that is writable: without one
the write is rejected instead of creating a record nobody can link back.
Conversations. Added as a timeline comment on the contact, lead, or deal,
prefixed with [mybot:<idempotency key>] so a redelivery is visible in the
timeline. The joined text is truncated to 32 000 characters.
Webhooks. 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 event except a delete re-reads the record
through crm.<entity>.get, and the returned ID must match the event.
Polling. crm.contact.list sorted by DATE_MODIFY and ID, resuming from
an encrypted checkpoint.
RetailCRM
Prerequisites. An API key. There is no OAuth for RetailCRM here: both authorization and refresh answer an explicit "not supported" code, and the key is the only credential.
Connecting. The RetailCRM card in the wizard, 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 carries a safe
connection summary, the webhook ingress address, and the webhook token, but
never the API key.
Least privilege. On connect the adapter reads /api/credentials and the
sites, stores, and order-methods references; each of the three must return
at least one usable entry. Exporting conversations also needs the
customer_write scope (on older accounts, the
/api/v5/customers/notes/create credential). Without it the connection reports
that conversations are unavailable instead of failing at export time.
Capabilities. Customers, deals, orders, webhooks, polling, and idempotent external keys. Conversations depend on the scope above.
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 reads 250
fields at a time, at most 100 pages and 10 000 fields. Order statuses are grouped
by site, and each site becomes its own pipeline.
Writes. Customers and orders are created and updated through
/api/v5/customers/... and /api/v5/orders/... forms; the API key is added
server-side and never appears in a mapping. MyBot's local identifier is written
to externalId. If the identifier RetailCRM returns does not match the linked
record, the effect is marked ambiguous and the link is not silently repointed.
Conversations. Exported as a customer note, capped at 2 000 characters.
Webhooks. RetailCRM does not sign its callbacks, so MyBot mints its own token
when the connection is created and shows it once. Paste it into the
X-Mybot-Webhook-Token header in RetailCRM's own webhook template: deliveries
must carry that header, and it is compared in constant time. Bodies are capped at
64 KB, the type must be customer or order, the event must be create,
update, or delete, and every event except a delete re-reads the record by ID,
because the templated body carries identity only.
Polling. /api/v5/customers/history and /api/v5/orders/history, 50 records
per page, resuming 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; self-hosted 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 the request is sent with your tenant's client credentials.
- The username and password of the OzmaCRM user MyBot will act as. Give it the smallest role that can read and write your entities.
- The OIDC base URL and realm for a self-hosted instance. For cloud the realm
defaults to
defaultonhttps://account.ozma.io, with the token endpoint at/auth/realms/<realm>/protocol/openid-connect/token. - The
usr.mybot_sync_marksjournal entity described below. Create it before connecting: the connection test fails without it.
A refresh token is mandatory. Access-token expiry is checked locally: once it has
expired the adapter fails with token_expired and the connection is re-sealed
before the next send, rather than refreshing mid-write.
Choosing entities. A connection carries a small config:
customer_entity: the schema and entity holding customers (required).deal_entity: the schema and entity holding deals (required).conversation_entity: optional, the entity for the conversation log.deal_status_field: the deal column whose domain becomes the pipeline.updated_at_field: the change-time 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 must be at most 64 characters. This is not cosmetic: the 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. They go into a separate journal entity you create yourself: MyBot never deploys or replaces a schema, because uploading a layout would overwrite your data. Create it before enabling sync:
create entity usr.mybot_sync_marks with fields:
entity_kind string not null -- customer | lead | deal | order | conversation
remote_id string -- nullable: empty for a new record's first mark
marker string not null -- the effect's idempotency key
direction string not null -- outbound | inbound
created_at datetime not null
The connection test checks /api/check_access, then this journal, then every
configured entity. A missing or incompatible journal fails with
missing_sync_marks_entity and returns the definition above verbatim; sync does
not start until the journal exists.
Fields and pipelines. Writable columns of the configured entities are
discovered from their entity info (id is skipped). int and double become
integer, bool becomes boolean, date and datetime become time, and string,
reference, enum, and uuid become string; an array column takes its subtype
and is marked multi-valued. A column type MyBot does not know is skipped and
stays visible through overflow in the mapping preview. Since OzmaDB has no notion
of a pipeline, the domain of deal_status_field becomes one synthetic pipeline.
Writes. Every write is one POST /api/entities/transaction with two
operations: the record itself, then the journal insert. Both commit together, so
a marker cannot exist without the change it describes. If the transaction fails,
the error says which of the two operations did.
The columns MyBot writes itself are fixed, so your entities must accept them:
- Customer:
name, plusphone,email, andtagswhere present, plus every mapped column discovery marked writable. - Deal:
name,status,amount_minor,currency,customer(the linked customer's id), andtags. A deal write needs a status and a three-letter currency. - Conversation:
customerordeal(the owner's id),summary, andcreated_at.
A mapped value whose column is not marked writable is dropped before the request, so a mapping revision cannot smuggle a write into a column nobody offered.
Conversations. The summary and each message are capped at 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. Changes are read with a FunQL keyset query sorted by your
updated_at column and id, 200 rows per page, resuming strictly after the last
row returned, so overlapping poll windows can neither miss nor duplicate a
record. Each row is left-joined against the journal to tell an inbound change
apart from the echo of MyBot's own last write.
A trigger instead of waiting for the poll. OzmaDB has no webhooks of its own,
so real time here comes from a trigger you install yourself. When the connection
is created MyBot hands out the ingress address and an X-Mybot-Trigger-Secret
value; install an AFTER INSERT/UPDATE trigger on your customer and deal entities
in the OzmaDB or FunApp UI that posts a small event to that address:
export default async function handler(args, ctx) {
await OzmaDB.enqueueHttpRequest({
url: '<ingress address>',
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-Mybot-Trigger-Secret': '<secret>'
},
body: JSON.stringify({
entity_kind: 'customer',
remote_id: args.id,
changed_at: new Date().toISOString()
}),
maxRetries: 10,
retryBaseDelayMs: 2000
})
}
entity_kind is either customer or deal. It uses enqueueHttpRequest, the
at-least-once outbox, rather than a synchronous call: a slow or unreachable MyBot
must never block your own transaction. A redelivery is normal and safe, because
duplicates are dropped by event id. The body is capped at 64 KB, and the whole
connection keeps working on polling if you never install the trigger.
MyBot never installs this trigger for you and does not hand out its source over the API: uploading a layout to OzmaDB replaces schemas wholesale and would destroy your data.
Field discovery and mapping
A mapping is an immutable revision. Editing it creates a new revision and never reinterprets a job already in flight, so a change made today cannot rewrite the meaning of a write queued earlier.
Every 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 → CRM) or inbound (CRM → 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 remains for mappings created before money
carried an explicit currency.)
They are declarative and never tenant code:
email_normalizedtrims and lowercases, requires an address shape, and rejects anything longer than 320 characters.phone_e164accepts spaces, dashes, and brackets as separators and emits 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 longer than 128 characters, and removes duplicates.enum_mapmaps only through the closed list you define. Inbound, the list is inverted, and when two local values lead to the same remote value the mapping is ambiguous and the remote value goes to overflow.money_minor_currencyemits signed minor units plus an ISO 4217 currency.
What a mapping must satisfy
Saving a revision fails when a local path is unknown, a remote key does not exist
on 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 to the same remote destination in the same direction
for the same entity, two inbound mappings write to the same local destination, or
a transform does not connect the local and remote types. Tags need both sides,
and a trigger must name a known event, its pipeline, and one of that pipeline's
statuses.
Tags: inbound only
MyBot labels are never exported as CRM tags. A subscriber profile stores label
ids, not the human-readable names a tag mapping compares against, so exporting
them would write opaque strings like 7f3c1a2e-… into the customer card. The
outbound tag list is deliberately empty.
Inbound tags work normally: a tag mapping transforms and normalises remote tags
into local ones, and an unmapped remote tag goes to overflow. To show a MyBot
label in the CRM, map it as an ordinary profile property through enum_map.
Overflow
Every mapping declares one overflow destination, a field or a note plus a key, and it is mandatory. Nothing that failed to map is thrown away. A value that failed its transform, a missing required value, a local property with no mapping, an unmapped tag, an unmapped remote property, and an unmapped remote tag are all recorded with their original value and reason and sent to that destination.
Conflict policy
mybot_wins: a remote change never overwrites a locally edited profile.crm_wins: the remote change is applied.newest_wins: the later timestamp wins; on an exact tie the change goes to manual review.manual: every conflicting change goes to manual review.
A profile with no local edit recorded yet accepts the remote change under any policy. Whatever the policy, a remote change no newer than the last remote change already recorded for that link is ignored.
Mapping preview
Before any write, a mapping can be projected onto real records. The preview is deterministic and provider-independent, so it cannot cause a remote write: it only shows what would be sent.
It takes at most 20 subscriber profiles, shows every mapped field with its source value, transformed value, warning, and error, and lists the overflow this mapping would produce. Alongside the sample it reports the affected record count, marking it exact when the query over its own records is complete, or estimated when only a bounded estimate is available.
The deal list in the sample is always empty, both in the preview and in the initial sync. MyBot has no deal object of its own before a CRM is connected, so there is nothing to seed outbound beyond subscriber profiles. Deals travel the other way: they arrive from the CRM and become customer events and goals (see "Deals, money, and attribution"). Deal fields in the mapping editor are still configured, and they are used for inbound changes and for deals you send from a dialog.
The initial sync is a separate call and requires an explicit confirmation, an
idempotency key, and the mapping revision currently on screen: a stale revision
answers 409.
Outbound writes, links, and idempotency
Every write carries a stable per-record idempotency key and produces a stored link, a pair of your local identifier and the CRM's record id together with the remote version and the last local, remote, and synced timestamps. It is the link, not a guess, that turns a repeated write into an update.
Each provider anchors the key in its own native mechanism: request_id for
amoCRM and Kommo, an external-key field plus a comment marker for Bitrix24,
externalId for RetailCRM, and the transactional journal row for OzmaCRM.
Webhook ingress and trust models
Inbound changes reach MyBot either from a poll cursor or from a provider webhook. The webhook side has four 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 |
POST /hooks/crm/{routeKey}/ozmacrm | a MyBot-issued X-Mybot-Trigger-Secret header, compared in constant time |
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.
How to get the address. The full ingress address is shown once, when the
connection is created, together with RetailCRM's token or OzmaCRM's trigger
secret. Copy it immediately: it is never stored in the clear and will not be
shown again. If a connection is older and its address is lost, open the
connection card and press Reissue ingress address (this needs the
Integration secrets right), or call
POST /api/bots/{botID}/crm/connections/{connectionID}/webhook-route. The
previous address stops working at once, so update the CRM side with the new one.
If the deployment has no public server address configured, the dashboard says so: the ingress address cannot be shown, and that is a platform configuration gap.
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 normalisation.
The problem both share is the echo: MyBot writes to the CRM, the CRM reports the change back, and a naive integration applies its own write in a loop. Suppression here is per-effect and single-use:
- An outbound write places a marker on the remote record and records it as expected for that link.
- An inbound change is suppressed only when it carries a marker still expected for exactly this connection and remote record, no older than 24 hours, and observed no later than the link's last known remote change.
- The marker is then consumed.
The practical consequence matters: a later change with the same value counts as a genuine human edit. If somebody opens the CRM and adjusts a field MyBot just wrote, even back to the same value, that edit is not swallowed. A marker that never echoes back simply expires, so a lost webhook cannot block a link's inbound sync forever.
The stable link identifier is deliberately not used for this. It is constant for the life of the link, so suppressing on it would swallow every later human edit as well.
Once a change survives suppression and the conflict policy, mapped values become auditable profile property changes, each with its own provenance: connection, link, provider, remote id, remote version, and change time. An empty value in an optional inbound field clears the local property; in a required one it goes to overflow.
Reconciliation
The poll reconciler advances due cursors every 30 seconds by itself, but a connection can be made to catch up immediately:
curl -X POST "$BASE_URL/api/bots/$BOT_ID/crm/connections/$CONNECTION_ID/reconcile" \
-H "Authorization: Bearer $PAT"
A successful pass answers 202 with a report of which resources were polled and
which failed. The answers differ on purpose, so a dead connection cannot look
healthy: 409 when a reconciliation is already running for this connection;
422 when the connection cannot be polled at all and most likely needs
re-authorisation; 502 when every resource's poll failed.
Use it after a provider incident, after a delivery you believe was lost, or when CRM-side edits are clearly not showing up in MyBot. Repeating it is safe: a change already applied is recognised by its link and idempotency key.
There is no button for this endpoint in the dashboard yet. It is API-only,
with the crm:write scope and the Reactions bot right.
Dialog actions
An operator handling a conversation can look at one person's CRM side and push them across by hand. In a dialog this is the CRM menu with "Sync now" and "Export conversation", and on the subscriber card it is a CRM tab showing the link status and the last sync. All of it needs the Dialogs bot right.
Over REST there are three routes:
GET /api/bots/{botID}/subscribers/{subscriberID}/crm/status(subscribers:read): whether the subscriber is linked, to which connection and remote record, when it last synced, and how the last job ended.POST /api/bots/{botID}/subscribers/{subscriberID}/crm/sync(subscribers:write): push the subscriber now. The body namesconnection_idand may carry an optionaldeal(name, pipeline, status,value_minor,currency, extra properties) to be created or updated alongside the customer. The dialog menu sends the bare customer, with no deal, because the operator has no pipeline or status picker there. The subscriber is projected exactly as the background sync projects it, so a manual push can never diverge from a scheduled one.POST /api/bots/{botID}/subscribers/{subscriberID}/crm/export-conversation(subscribers:write): write the conversation into the CRM as a note. The body carries onlyconnection_id; what leaves is described below.
Each action returns the same durable operation object the sync log shows, so a manual action appears in the connection's operation list alongside the rest.
What a conversation export contains
An export is a bounded, redacted excerpt, not a transcript. Before anything leaves MyBot:
- At most the last 50 messages are kept. Older history is truncated rather than paged out.
- Each message is truncated to 4 000 characters, and so is the summary.
- An attachment becomes the literal text
[attachment], with no filename, no link, no MIME type, and no size. The CRM learns that something was attached and nothing more. - Authors collapse to two roles,
customerandoperator. Which operator wrote a line does not leave MyBot. - Empty messages are dropped.
Provider limits apply on top: amoCRM holds the joined text to 6 000 characters, RetailCRM to 2 000, Bitrix24 to 32 000, and OzmaCRM to 32 000 bytes.
Private operator notes never leave. This is not a filter that could be misconfigured: the export path reads the ordinary dialog message feed and has no code branch and no request field leading to the private notes table. There is nothing there to attach by accident. For what a private note is, see Support inbox.
AI qualified-lead handoff
The 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 usual confirmation and idempotency rules.
Two boundaries make it safe for a model:
- The connection must be explicitly allowed for that agent. The tool sees only the connection ids listed in this bot's AI agent tool policy. A model naming any other connection, including a real one belonging to the same bot, is refused before anything executes.
- Facts are limited 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). Every key must be a local path the current mapping revision already writes outbound to a customer, deal, or order field. An unknown key rejects the whole call: dropping it silently and accepting it silently would both hand the model the decision about what reaches your CRM.
The model does not supply the conversation. The export_conversation flag asks
the server to attach it, and the server takes it through the same bounded,
redacted export described above: subscriber, bot, and credentials are all
resolved server-side.
The tool returns an operation id and its status, nothing else.
Deals, money, and attribution
Money is always a signed 64-bit minor amount plus an ISO 4217 currency, never a float. Only the minor amount travels in a provider payload; the currency stays on the canonical deal record. Inbound, a money mapping stores the minor amount and warns that the currency is 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, where present, the minor amount and currency.
A won deal also emits a crm.deal_won goal for
attribution with the same amount and currency.
Privacy and redaction
- Credentials cannot be serialised at all: the credential type refuses to become JSON, so a token cannot leak through a response, a log line, or a job payload.
- Credentials and cursors are encrypted at rest and bound to one connection, bot, provider, account, key version, and credential revision. A ciphertext moved elsewhere simply will not open.
- Reading a connection returns only a safe summary: id, bot, provider, name, account key, status, capabilities, mapping revision, last error code, and timestamps. It carries no credential ciphertext and no webhook route material.
- A route reissue is audited by the fact of the rotation alone: neither the new key nor its hash enters the audit trail.
- Request and response metadata kept for troubleshooting is redacted: method,
scheme, host and path, status code, and retry hint. Any header whose name
contains
authorization,token,secret,api-key,cookie, orpasswordis stored as[redacted]. - Conversation exports are bounded excerpts with anonymised roles, and private operator notes are excluded structurally.
- Inbound profile changes are audited with their provenance, so any value a CRM wrote into a profile is traceable to the connection and remote record it came from.
- CRM links are their own storage category with a 730-day retention; they are included in data export and erasure like every other category.
Connection state, reconnecting, and disconnecting
One bot can hold one connection per provider and account key. A connection is
created as connected; the state set also allows pending, expired,
disabled, and failed, alongside a stable last error code.
The test button records failed only on a terminal provider answer. A timeout, a
502, or a transport failure proves nothing, so during a provider incident one
press does not take the connection out of job execution and polling.
Reconnecting rotates the sealed credentials atomically against an expected revision, so two concurrent reconnects cannot interleave and leave a half-rotated connection. Because the seal is bound to the revision, the previous ciphertext stops opening the moment the new one commits.
To disconnect safely:
- Revoke the credential on the CRM side first: the OAuth application's access for amoCRM and Kommo, the incoming webhook for Bitrix24, the API key for RetailCRM, the OIDC client for OzmaCRM.
- Remove the inbound path if you set one up: the webhook subscription in the CRM, or the trigger in OzmaDB.
- Delete the connection, with the Delete button on the connection card or
DELETE /api/bots/{botID}/crm/connections/{connectionID}(crm:write). Deleting a connection takes its mapping revisions, links, and cursors with it and changes nothing inside your CRM.
The OzmaCRM journal entity is yours: it is safe to keep and safe to drop once no connection uses that instance.
API
Every route below lives under /api/bots/{botID}/crm/… unless stated otherwise,
and all of them also need 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. Works for amoCRM; bitrix24 answers 503 until an application is configured |
GET /api/integrations/crm/{provider}/callback | : | the browser redirect target; the single-use state authenticates it, no token needed |
POST /connections/bitrix24 | crm:write | connect Bitrix24 with a portal incoming webhook |
POST /connections/retailcrm | crm:write | connect RetailCRM with an API key |
POST /connections/ozmacrm | crm:write | connect OzmaCRM with OIDC credentials, a login, and the entity config |
POST /connections/{connectionID}/webhook-route | crm:write | reissue the webhook ingress address; needs the Integration secrets right |
PATCH /connections/{connectionID} | crm:write | rename |
DELETE /connections/{connectionID} | crm:write | disconnect and delete mappings, links, and cursors |
POST /connections/{connectionID}/test | crm:write | re-run the 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 onto real profiles; deliberately a read, no remote write happens |
Sync
| Endpoint | Scope | What it does |
|---|---|---|
POST /connections/{connectionID}/initial-sync | crm:write | push subscriber profiles outbound; needs a confirmation, an idempotency key, and the current mapping revision |
POST /connections/{connectionID}/reconcile | crm:write | force a catch-up reconciliation |
GET /operations | crm:read | operations, newest first, keyset paginated |
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 on manual resolution |
POST /conflicts/{jobID}/resolve | crm:write | resolve one conflict |
Dialog actions (subscribers:read / subscribers:write, the 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
MCP publishes six read-only tools, all needing crm:read:
list_crm_connections: the bot's connections with provider, status, and capabilities.get_crm_connection: one connection's safe summary.get_crm_mapping: a 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 (without the raw job request payload).list_crm_conflicts: jobs waiting on manual conflict resolution.
None of them ever returns a credential. There are deliberately no CRM write tools: connecting, syncing, retrying, and resolving a conflict stay in REST, so an agent can observe a CRM integration without changing how it behaves.
Known limitations
Recorded here so nobody rediscovers them as bugs:
- Bitrix24 OAuth is unavailable:
connections/bitrix24/startanswers503, because the platform has no Bitrix24 OAuth application. Connect through a portal incoming webhook instead. - Bitrix24 webhook ingress needs
BITRIX24_APPLICATION_TOKENon the deployment; without it deliveries are rejected and polling is what remains. - 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.
- OzmaCRM has no webhooks of its own: real time comes only from a trigger you install yourself, and MyBot does not hand out its source over the API.
- The ingress address is shown once: a lost address can only be reissued, not recovered.
- Reconcile has no button in the dashboard; it is API-only.
- The per-subscriber CRM actions are absent from the API Reference, even though the dashboard has them.
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.