Most 1C and Bitrix24 integrations work fine in the demo. The first real problem shows up a month later: the same customer exists three times in the CRM, a payment was applied twice to one deal, and an invoice that was cancelled in accounting is still "awaiting payment" in the pipeline. Nobody wrote a bug on purpose. The exchange simply assumed that every message arrives exactly once, in order, and that both sides agree on what "the same customer" means.
None of those assumptions hold. This post describes how we design the exchange so that repeating any message is harmless. That property, idempotency, is what separates an integration you can leave running from one you babysit.
The failure modes, concretely
Before designing anything, it helps to list the ways duplicates appear. In our experience they come from five places:
- Retry after a lost response. Your connector calls
crm.company.add, Bitrix24 creates the company, the response is lost on the way back. The connector retries and creates a second one. - Overlapping runs. The scheduled job runs every 5 minutes, one run takes 7 minutes on a busy day, and two runs process the same batch.
- Matching by name. "Romashka LLP" in the CRM and "Romashka, LLP" in 1C look like different customers to any string comparison.
- Two creators. A manager creates the company in Bitrix24 while the accountant creates the same counterparty in 1C. Both sync to the other side.
- Additive updates. "Payment received: add 50,000 to the paid amount" is applied twice, and the deal shows 100,000 paid on a 50,000 invoice.
Each needs a different fix, and together they define the design.
Rule 1: one stable key per entity, stored on both sides
Every synced object needs an identifier that never changes and is known to both systems. Names, phone numbers and invoice numbers are not that identifier. Numbers get renumbered at the start of the year, names get edited, phones change.
1C already gives you a good one: every reference and document has a GUID (Ссылка.УникальныйИдентификатор()). Bitrix24 gives you an integer ID per entity type. Store both, in both places:
- In Bitrix24, add a custom field to companies, smart invoices and products, for example
UF_CRM_1C_GUID. - In 1C, add an attribute or a separate information register
ВнешниеИдентификаторыwith dimensions object and external system, and resource external ID. A register is cleaner than adding attributes to every catalog, and it survives configuration updates better on standard configurations.
The connector in the middle keeps its own mapping table. It is the source of truth for "which object over there corresponds to this one over here":
CREATE TABLE entity_map ( entity_type text NOT NULL, -- 'company', 'invoice', 'product', 'payment' onec_guid uuid NOT NULL, b24_id bigint, onec_version text, -- 1C ВерсияДанных at last sync b24_hash text, -- hash of fields we last wrote updated_at timestamptz NOT NULL DEFAULT now(), PRIMARY KEY (entity_type, onec_guid), UNIQUE (entity_type, b24_id) ); The two unique constraints matter more than they look. They make it impossible, at the database level, for one 1C object to point to two Bitrix24 records or the other way round.
Rule 2: create is "find or create", never just "create"
The classic retry duplicate (failure mode 1) happens because add is not idempotent. Wrap every create in a lookup, and do the lookup and the create under a lock keyed by the stable identifier:
def upsert_company(conn, b24, onec): guid = onec["guid"] with conn.transaction(): # Serialises concurrent workers on the same GUID only conn.execute("SELECT pg_advisory_xact_lock(hashtext(%s))", (f"company:{guid}",)) row = conn.fetchone( "SELECT b24_id, b24_hash FROM entity_map " "WHERE entity_type='company' AND onec_guid=%s", (guid,)) fields = map_company_fields(onec) # includes UF_CRM_1C_GUID = guid new_hash = stable_hash(fields) if row and row.b24_hash == new_hash: return row.b24_id # nothing changed, no API call b24_id = row.b24_id if row else find_in_b24(b24, onec) if b24_id: b24.call("crm.company.update", {"id": b24_id, "fields": fields}) else: b24_id = b24.call("crm.company.add", {"fields": fields}) conn.execute(""" INSERT INTO entity_map (entity_type, onec_guid, b24_id, b24_hash) VALUES ('company', %s, %s, %s) ON CONFLICT (entity_type, onec_guid) DO UPDATE SET b24_id=EXCLUDED.b24_id, b24_hash=EXCLUDED.b24_hash, updated_at=now() """, (guid, b24_id, new_hash)) return b24_id find_in_b24 is the safety net for the lost-response case: before creating, search Bitrix24 by UF_CRM_1C_GUID. If the previous attempt did create the company, the search finds it and the retry becomes an update. Because we write the GUID into the company in the same add call, there is no window where the record exists without its key.
The advisory lock takes care of overlapping runs (failure mode 2) without a global "job is running" flag, which tends to get stuck after a crash. Two workers can process different companies in parallel; they only wait for each other on the same GUID.
The hash check is a bonus that pays for itself quickly. Bitrix24 REST has request rate limits, and most scheduled syncs touch thousands of records that did not change. Skipping unchanged records usually removes the bulk of API calls after the initial load.
Rule 3: match on legal identity, then clean up before go-live
Failure mode 3 is not solvable by better string comparison. Normalising "LLP", commas and quotes helps you find candidates, but the decision must rest on a legal identifier. In Kazakhstan that is the BIN for legal entities and the IIN for individual entrepreneurs. In 1C it sits on the counterparty card; in Bitrix24 it lives in the company's requisites (crm.requisite.*, the BIN field in Kazakhstan presets), not in the company itself.
The initial matching pass looks like this:
- Export all counterparties from 1C with GUID, BIN/IIN and name.
- Export all Bitrix24 companies with their requisites.
- Join on BIN/IIN. Exact matches go straight into
entity_map. - Records without a BIN on one side go to a review list with fuzzy name candidates. A person decides.
- Duplicates inside each system (two 1C counterparties with the same BIN) get merged in that system first.
Step 5 takes most of the project time. An exchange running on dirty reference books does not remove duplicates, it replicates them both ways. The same applies to items: if the product catalogue has "Cable 3x2.5" and "Cable 3x2,5 (old)" as separate items, decide which survives before prices start flowing into deals.
Rule 4: one master per field, not per system
Failure mode 4, two creators, is a policy problem disguised as a technical one. The fix is to assign ownership per entity and per field, write it down and enforce it in the connector:
| Data | Master | Other side |
|---|---|---|
| Counterparty legal details (BIN, legal name, bank) | 1C | read-only in CRM |
| Contact persons, phones, responsible manager | Bitrix24 | not synced back or informational only |
| Items and prices | 1C | read-only in deals |
| Invoice | created from the deal, posted in 1C | status from 1C |
| Payment | 1C | read-only |
When a manager needs a new customer, the CRM creates a company with a BIN, the connector checks 1C by BIN, and either links to the existing counterparty or creates a new one there. It never creates a second company in Bitrix24 because the counterparty "came back" from 1C: that GUID is already in entity_map.
Make fields the CRM does not own read-only for managers, otherwise someone will "fix" a legal name and wonder why it reverts every night.
Rule 5: send state, not deltas
Failure mode 5 is the most expensive one, because it touches money. Never send "add 50,000". Send "invoice 9f3c... has payments totalling 50,000, status partially paid", computed from 1C's own records at the time of the message:
{ "message_id": "b3c1e7a2-6f0d-4c8e-9a51-2d7e0f9c4a10", "entity": "invoice", "onec_guid": "9f3c4d2e-1a7b-11ef-8c3a-005056a1b2c3", "onec_version": "AAAAAQAAAAA=", "amount": 120000.00, "paid_total": 50000.00, "status": "partially_paid", "payments": [ {"onec_guid": "c41d...", "date": "2026-11-03", "amount": 50000.00} ] } Applying this message twice gives the same result as applying it once. Applying an older message after a newer one is the remaining risk, and onec_version handles it: 1C changes ВерсияДанных on every write, and the connector stores the version it last applied. If the incoming version matches, skip. For ordering, keep a monotonic sequence on the sending side (the exchange message number, see below) and drop anything older than what you have already applied for that entity.
Partial payments and refunds become trivial: the receiver never does arithmetic.
Using 1C exchange plans as the outbox
On the 1C side you do not need to invent change tracking. Exchange plans (ПланыОбмена) already register changes per node and support an acknowledgement protocol based on message numbers. A minimal HTTP service handler that serves changes to the connector looks like this:
// Returns changes registered for the Bitrix24 node Функция ПолучитьИзменения(Запрос) Узел = ПланыОбмена.ОбменБитрикс24.НайтиПоКоду("B24"); НомерПодтвержденного = Число(Запрос.ПараметрыЗапроса.Получить("ack")); // Connector confirmed everything up to ack: drop those registrations Если НомерПодтвержденного > 0 Тогда ПланыОбмена.УдалитьРегистрациюИзменений(Узел, НомерПодтвержденного); КонецЕсли; НомерСообщения = Узел.НомерОтправленного + 1; Выборка = ПланыОбмена.ВыбратьИзменения(Узел, НомерСообщения); // ... serialise objects to JSON with GUID and ВерсияДанных ... ОбъектУзла = Узел.ПолучитьОбъект(); ОбъектУзла.НомерОтправленного = НомерСообщения; ОбъектУзла.Записать(); // return JSON with message_no = НомерСообщения КонецФункции The key property: changes are removed from the registration only after the connector confirms it has applied them, by passing the message number back on the next request. If the connector crashes mid-batch, the next request has the old ack, 1C returns the same changes again, and the idempotent upserts on the connector side make the replay harmless. At-least-once delivery plus idempotent receivers is the whole trick.
For the opposite direction (deal created in Bitrix24, invoice to be issued in 1C), use the outbound event (ONCRMDEALUPDATE and similar) only as a trigger. Bitrix24 events carry an ID, not the data, and can arrive twice or out of order. The handler should re-read the deal via REST and run the same find-or-create logic against 1C, with the deal ID stored on the 1C document.
Deletions and cancellations
Hard deletes break mapping tables. In 1C, documents are usually marked for deletion or unposted rather than deleted, and the exchange should treat those as status changes: an unposted invoice becomes "cancelled" in the CRM, not removed. If an object is actually deleted, the exchange plan still registers the deletion, and the connector should send a tombstone that sets the CRM side to an archived state and keeps the entity_map row. Otherwise the next full resync will recreate the object from whichever side still has it.
A note on real time: a schedule of a few minutes is enough for invoices and payments, easier to monitor and naturally batches requests under the REST limits. If you do add events, keep the scheduled run as a backstop.
Monitoring that catches silent failures
An idempotent exchange can still fail quietly, by simply not running. The minimum we put in place:
- Queue age alert. If the oldest unacknowledged 1C change is older than, say, 30 minutes, someone gets a message.
- Error log per entity, with the 1C GUID and the Bitrix24 ID, so support can find the record in seconds.
- Nightly reconciliation report. Count invoices and paid totals for the last 30 days on both sides and compare. A mismatch of even one record is investigated.
- Unmapped records list. Companies without a BIN or without a mapping row are listed daily instead of piling up.
Checklist
- Stable GUID stored on both sides and in a mapping table with unique constraints
- Every create is find-or-create under a per-key lock
- Counterparties matched by BIN/IIN, reference books cleaned before launch
- One master per field, enforced in the connector and in CRM field permissions
- Messages carry full state and a version, never deltas
- 1C exchange plans with acknowledged message numbers as the outbox
- Cancellations as status changes, deletions as tombstones
- Alerts on queue age and a nightly reconciliation
A realistic project of this kind, covering counterparties, invoices and payments, takes from about three weeks, and most of that time goes to matching and cleaning reference books rather than writing code. If you want a team that also works inside non-standard 1C configurations to design and run it, here is how we approach connecting Bitrix24 and 1C for invoices, payments and items.
