ฐานความรู้ GetMyBot

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.

บนหน้านี้

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:

ProviderHow you connectHow inbound changes arrive
amoCRM / KommoOAuth, self-service in the wizardan amoCRM outgoing webhook to the address you were given, or polling
Bitrix24an incoming webhook you create on your own portal; the OAuth path is still closeda portal outgoing webhook, or polling
RetailCRMAPI key, self-service in the wizarda RetailCRM webhook carrying a MyBot token header, or polling
OzmaCRMOIDC client credentials plus a user login, self-service in the wizarda 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-After and X-RateLimit-Reset are 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.ru or <account>.kommo.com.
  • Bitrix24: <portal>.bitrix24.ru or <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 default on https://account.ozma.io, with the token endpoint at /auth/realms/<realm>/protocol/openid-connect/token.
  • The usr.mybot_sync_marks journal 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_at by 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, plus phone, email, and tags where present, plus every mapped column discovery marked writable.
  • Deal: name, status, amount_minor, currency, customer (the linked customer's id), and tags. A deal write needs a status and a three-letter currency.
  • Conversation: customer or deal (the owner's id), summary, and created_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 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 → 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_normalized trims and lowercases, requires an address shape, and rejects anything longer than 320 characters.
  • phone_e164 accepts spaces, dashes, and brackets as separators and emits 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 longer than 128 characters, and removes duplicates.
  • enum_map maps 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_currency emits 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.

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:

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
POST /hooks/crm/{routeKey}/ozmacrma 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:

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

  1. 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.
  2. Remove the inbound path if you set one up: the webhook subscription in the CRM, or the trigger in OzmaDB.
  3. 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

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. 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/bitrix24crm:writeconnect Bitrix24 with a portal incoming webhook
POST /connections/retailcrmcrm:writeconnect RetailCRM with an API key
POST /connections/ozmacrmcrm:writeconnect OzmaCRM with OIDC credentials, a login, and the entity config
POST /connections/{connectionID}/webhook-routecrm:writereissue the webhook ingress address; needs the Integration secrets right
PATCH /connections/{connectionID}crm:writerename
DELETE /connections/{connectionID}crm:writedisconnect and delete mappings, links, and cursors
POST /connections/{connectionID}/testcrm:writere-run the 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 onto real profiles; deliberately a read, no remote write happens

Sync

EndpointScopeWhat it does
POST /connections/{connectionID}/initial-synccrm:writepush subscriber profiles outbound; needs a confirmation, an idempotency key, and the current mapping revision
POST /connections/{connectionID}/reconcilecrm:writeforce a catch-up reconciliation
GET /operationscrm:readoperations, newest first, keyset paginated
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 on manual resolution
POST /conflicts/{jobID}/resolvecrm:writeresolve 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/start answers 503, because the platform has no Bitrix24 OAuth application. Connect through a portal incoming webhook instead.
  • Bitrix24 webhook ingress needs BITRIX24_APPLICATION_TOKEN on 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.