Зачем переносить аккаунт Битрикс между инстансами
В предыдущей статье (как отдавать лиды из Next.js в 1С Битрикс) я показывал outbound-интеграцию: сайт пишет лид к себе в PostgreSQL, через after() отдаёт его в Битрикс, в строку лида подкладывает bitrix_id. Архитектура работает, пока Битрикс один.
Но в реальной жизни Битрикс редко остаётся один. Сценарии, в которых нужна полноценная миграция между инстансами, я ловил на проектах четыре раза за последний год:
-
Переезд серверов. Клиент держал self-hosted Битрикс на старом VPS, переезжает на новый. SaaS-инстанс на новый домен — то же самое.
-
Разделение test/prod. Команда работала в одном продакшн-аккаунте. Хотят отдельный staging, в который скопирован срез реальных данных, чтобы тестировать без риска для живой воронки.
-
Разделение юрлиц. Компания делится на два юридических лица, каждому нужен свой Битрикс с частью общей клиентской базы.
-
Тестовый контур интеграции. Я как разработчик не могу гонять интеграцию по живой CRM клиента. Мне нужен инстанс с зеркалом данных — чтобы отлаживать синхронизатор без риска налажать на проде.
Во всех четырёх случаях задача одна: перенести лиды, сделки, контакты, компании из source-инстанса в target-инстанс, не плодя дубли при повторных прогонах. То есть не просто скрипт «один раз залить и забыть», а инструмент, который можно запускать 5 раз — и пятый раз он не создаст ещё пять копий каждого лида.
В этой статье — паттерн migration toolkit, который мы используем на проекте маркетплейса недвижимости. Один Node-скрипт, два webhook URL в env-переменных, никаких очередей и отдельной БД. Идемпотентность держится через ORIGINATOR_ID + ORIGIN_ID — это и есть главное, что отличает migration toolkit от наивного «слил-залил».
Архитектура: один скрипт, два webhook
Migration toolkit не нужен как сервис. Это разовый CLI-инструмент, который запускается оператором руками, сверяется с логом и при необходимости прогоняется повторно.
┌────────────────────┐ ┌────────────────────┐│ BITRIX_SOURCE │ ── crm.lead.list ─────▶ │ migrate.ts ││ (откуда тянем) │ crm.deal.list │ - pagination ││ │ crm.contact.list │ - normalization ││ │ crm.company.list │ - mapping │└────────────────────┘ │ │ │ │┌────────────────────┐ │ ││ BITRIX_TARGET │ ◀── crm.lead.add ──── │ ││ (куда пишем) │ crm.deal.add │ ││ │ crm.contact.add │ ││ │ crm.company.add │ │└────────────────────┘ └────────────────────┘ │ ▼ ┌─────────────────┐ │ migration.log │ │ (плоский JSON) │ └─────────────────┘
Что в этой схеме намеренно отсутствует:
-
Нет промежуточной БД. Данные source-инстанса читаются батчами в память (по 50 записей), мапятся, отправляются в target и забываются. Если процесс упадёт — перезапускаем, идемпотентность защитит от дублей.
-
Нет воркеров и очередей. Это разовая операция, не daemon. Redis/BullMQ тут — over-engineering. Если миграция занимает 6 часов — пусть скрипт работает 6 часов в
screen/tmux. -
Нет двусторонней синхронизации. Это miграция, а не sync. Source → target, в одну сторону. После миграции source выключается или используется только как архив.
Конфиг — две переменные окружения:
bash
# .envBITRIX_SOURCE_WEBHOOK_URL=https://old-account.bitrix24.ru/rest/1/abc123def456/BITRIX_TARGET_WEBHOOK_URL=https://new-account.bitrix24.ru/rest/1/xyz789ghi012/
Webhook вместо OAuth-приложения по тем же причинам, что и в outbound-сценарии: не нужен Marketplace-апрув, скоупы задаются в админке Битрикса при создании вебхука, токен хранится как обычный env. Минус — токен в URL, поэтому правило: ни в логи, ни в Sentry полный URL не пишется. Только название метода и payload.
Чтение source: crm.*.list + пагинация
Битрикс отдаёт данные через семейство методов crm.{entity}.list. Базовый вызов:
typescript
// migrate-toolkit/src/source.tsimport { bitrixRequest } from "./client";const PAGE_SIZE = 50; // Жёсткий лимит Bitrix24, больше нельзяexport async function* iterateLeads(): AsyncGenerator<BitrixLead> { let start = 0; while (true) { const response = await bitrixRequest("source", "crm.lead.list", { start, order: { ID: "ASC" }, filter: {}, // без фильтра — тянем всё select: ["*", "UF_*"], // включая пользовательские поля }); const leads: BitrixLead[] = response.result ?? []; for (const lead of leads) { yield lead; } // Битрикс возвращает next в виде смещения для следующей страницы if (response.next === undefined || leads.length < PAGE_SIZE) { return; } start = response.next; }}
Три момента, на которые натыкаются почти все:
1. start — это смещение, не номер страницы. Если на странице 50 записей и вы прочитали 10 страниц — следующий start = 500. Битрикс отдаёт это значение в response.next, и проще доверять ему, чем считать самому.
2. Лимит 50 записей на страницу — жёсткий. Можно попросить меньше через ?limit=20, но больше — нет, отрежет молча. Я держу PAGE_SIZE = 50 константой и не трогаю.
3. select: ["*", "UF_*"] — нужно явно просить пользовательские поля. По умолчанию crm.lead.list отдаёт только системные поля, и все ваши UF_CRM_* (адреса домов, кастомные статусы, ссылки на объекты) останутся за бортом. Это самая частая ошибка миграции — мигрировали лиды без половины важных полей и заметили через неделю.
Аналогично работают crm.deal.list, crm.contact.list, crm.company.list — единый паттерн пагинации.
Retry на rate limit
У Битрикса есть лимит ~2 запросов в секунду на webhook (на самом деле сложнее, там скользящее окно, но ориентируйтесь на 2 RPS). При миграции базы на 50 000 записей это означает минимум 50 000 / 50 / 2 = 500 секунд чтения. На практике дольше из-за сетевых задержек.
Если упереться в лимит — Битрикс возвращает error: QUERY_LIMIT_EXCEEDED. Минимальный клиент с retry на этот случай:
typescript
// migrate-toolkit/src/client.tsconst MAX_ATTEMPTS = 4;const REQUEST_TIMEOUT_MS = 15_000; // больше, чем для outbound — list тяжелее addconst BASE_DELAY_MS = 600;type Side = "source" | "target";const URLS: Record<Side, string> = { source: process.env.BITRIX_SOURCE_WEBHOOK_URL!, target: process.env.BITRIX_TARGET_WEBHOOK_URL!,};export async function bitrixRequest( side: Side, method: string, payload: Record<string, unknown>) { const url = `${URLS[side]}${method}.json`; for (let attempt = 1; attempt <= MAX_ATTEMPTS; attempt++) { const controller = new AbortController(); const timer = setTimeout(() => controller.abort(), REQUEST_TIMEOUT_MS); try { const response = await fetch(url, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(payload), signal: controller.signal, }); const text = await response.text(); const json = text ? JSON.parse(text) : {}; // Rate limit — ждём и повторяем if (json.error === "QUERY_LIMIT_EXCEEDED") { await sleep(BASE_DELAY_MS * attempt * 2); continue; } if (!response.ok || json.error) { throw new Error( json.error_description || json.error || `HTTP ${response.status}` ); } return json; } catch (error) { if (attempt >= MAX_ATTEMPTS) throw error; await sleep(BASE_DELAY_MS * attempt); } finally { clearTimeout(timer); } } throw new Error(`${method}: retries exhausted`);}const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms));
Линейный backoff, четыре попытки, отдельная ветка под QUERY_LIMIT_EXCEEDED с увеличенной паузой. Большего на разовой миграции не нужно — экспоненциальный backoff с jitter и circuit breaker оставьте для прод-сервисов.
Идемпотентность: ORIGINATOR_ID + ORIGIN_ID как нативный ключ
Это центральная часть статьи. Если в migration toolkit нет идемпотентности — он не migration toolkit, а скрипт «слей-залей».
Битрикс из коробки даёт два поля для пометки записей, пришедших из внешнего источника:
-
ORIGINATOR_ID— идентификатор системы-источника. Я кладу сюда хеш URL source-вебхука или просто понятный лейбл вродеbitrix-old-prod. -
ORIGIN_ID— идентификатор записи в системе-источнике. Сюда кладу строковыйIDлида/сделки/контакта из source-инстанса.
При создании записи через crm.lead.add и аналоги Битрикс сам проверяет, нет ли уже в target-инстансе записи с такой парой (ORIGINATOR_ID, ORIGIN_ID). Если есть — ничего не создаётся, возвращается ID существующей записи. Это нативный механизм Битрикса, не наш велосипед.
Вот как это выглядит в коде:
typescript
// migrate-toolkit/src/leads.tsimport { bitrixRequest } from "./client";import { iterateLeads } from "./source";import { mapLead } from "./mapping";const ORIGINATOR_ID = "bitrix-old-prod"; // фиксируем на весь прогонexport async function migrateLeads() { let migrated = 0; let skipped = 0; for await (const sourceLead of iterateLeads()) { const targetPayload = await mapLead(sourceLead); const response = await bitrixRequest("target", "crm.lead.add", { fields: { ...targetPayload, ORIGINATOR_ID, ORIGIN_ID: String(sourceLead.ID), }, params: { REGISTER_SONET_EVENT: "N" }, // не плодим события в ленте }); if (response.result) { migrated++; logSuccess(sourceLead.ID, response.result); } else { skipped++; logSkip(sourceLead.ID, response.error); } } console.log(`Leads: migrated=${migrated}, skipped=${skipped}`);}
При повторном запуске того же скрипта — Битрикс увидит, что лид с ORIGINATOR_ID = "bitrix-old-prod" и ORIGIN_ID = "12345" уже создан, и не плодит дубль. Это работает на четырёх основных сущностях: crm.lead.add, crm.deal.add, crm.contact.add, crm.company.add.
Два важных момента, на которых ловятся:
1. ORIGINATOR_ID — строка, и она должна быть стабильной между прогонами. Если первый раз вы записали ORIGINATOR_ID = "old-bitrix", а второй раз — ORIGINATOR_ID = "bitrix-old-prod", то для Битрикса это разные источники, и он создаст дубли. Я фиксирую константу в коде и не трогаю до конца миграции.
2. params: { REGISTER_SONET_EVENT: "N" } — без этого каждый созданный лид породит запись в живой ленте Битрикса. После миграции 50 000 лидов лента превратится в нечитаемое полотно. У менеджеров будет сердечный приступ. Этот флаг я ставлю на все миграционные *.add-вызовы.
Маппинг сложных полей
Простые поля (имя, телефон, email) переносятся как есть. Сложности начинаются на трёх типах полей.
Enum-значения (статусы, типы, источники)
В Битриксе статус лида хранится как ID элемента справочника, например STATUS_ID: "NEW" или SOURCE_ID: "5". Проблема в том, что ID статусов в source и target могут не совпадать, особенно если справочники в target правились руками.
Поэтому первый шаг миграции — вытянуть оба справочника через crm.status.list и построить маппинг по символьным значениям:
typescript
// migrate-toolkit/src/mapping/status.tsimport { bitrixRequest } from "../client";type StatusEntity = "STATUS" | "SOURCE" | "DEAL_STAGE";let cache: Map<string, Map<string, string>> | null = null;async function buildMap(entity: StatusEntity) { const sourceList = await bitrixRequest("source", "crm.status.list", { filter: { ENTITY_ID: entity }, }); const targetList = await bitrixRequest("target", "crm.status.list", { filter: { ENTITY_ID: entity }, }); // Ключ — пара (STATUS_ID, NAME). Сначала пробуем по STATUS_ID, потом по NAME. const targetByStatusId = new Map<string, string>(); const targetByName = new Map<string, string>(); for (const item of targetList.result) { targetByStatusId.set(item.STATUS_ID, item.STATUS_ID); targetByName.set(item.NAME.trim().toLowerCase(), item.STATUS_ID); } const map = new Map<string, string>(); for (const item of sourceList.result) { const sourceId = item.STATUS_ID; const matchById = targetByStatusId.get(sourceId); const matchByName = targetByName.get(item.NAME.trim().toLowerCase()); const targetId = matchById ?? matchByName; if (targetId) { map.set(sourceId, targetId); } } return map;}export async function getStatusMap(entity: StatusEntity) { if (!cache) cache = new Map(); let map = cache.get(entity); if (!map) { map = await buildMap(entity); cache.set(entity, map); } return map;}
Логика двухступенчатая: сначала пробуем найти статус по STATUS_ID (если справочники совпадают — попадаем сразу), потом по нормализованному NAME (если ID отличаются — выручают человекочитаемые названия). Если ни то, ни другое не сработало — статус остаётся пустым в target, и это пишется в migration.log для ручного разбора.
То же самое работает для SOURCE_ID, стадий сделок (DEAL_STAGE), типов компании, типов контакта.
Пользователи (ответственные)
Поле ASSIGNED_BY_ID хранит ID пользователя Битрикса, на которого назначен лид. ID в source и target почти гарантированно разные, потому что пользователи добавляются в каждый аккаунт независимо.
Маппинг строится по email — это единственное поле, которое стабильно совпадает у одного и того же человека в двух инстансах:
typescript
// migrate-toolkit/src/mapping/user.tsconst FALLBACK_USER_ID = "1"; // обычно админexport async function buildUserMap() { const sourceUsers = await fetchAllUsers("source"); const targetUsers = await fetchAllUsers("target"); const targetByEmail = new Map<string, string>(); for (const u of targetUsers) { if (u.EMAIL) targetByEmail.set(u.EMAIL.trim().toLowerCase(), u.ID); } const map = new Map<string, string>(); for (const u of sourceUsers) { if (!u.EMAIL) continue; const targetId = targetByEmail.get(u.EMAIL.trim().toLowerCase()); map.set(u.ID, targetId ?? FALLBACK_USER_ID); } return map;}async function fetchAllUsers(side: "source" | "target") { const all: Array<{ ID: string; EMAIL: string }> = []; let start = 0; while (true) { const r = await bitrixRequest(side, "user.get", { start }); const batch = r.result ?? []; all.push(...batch); if (r.next === undefined) break; start = r.next; } return all;}
Если соответствие не найдено — назначаем на FALLBACK_USER_ID, обычно это администратор аккаунта. Без fallback’а лиды с неизвестным ASSIGNED_BY_ID упадут с ошибкой при создании, и миграция остановится посреди прогона.
File-поля
Файлы (документы, фото, аватары) в Битриксе хранятся через FILES API. Перенести по ID нельзя — у файла в target будет другой ID. Перенос идёт через base64:
typescript
// migrate-toolkit/src/mapping/file.tsimport { bitrixRequest } from "../client";export async function transferFile( sourceFileUrl: string, filename: string): Promise<{ fileData: [string, string] }> { // 1. Скачиваем файл с source-инстанса const fileResponse = await fetch(sourceFileUrl); const buffer = Buffer.from(await fileResponse.arrayBuffer()); const base64 = buffer.toString("base64"); // 2. Возвращаем структуру, которую Битрикс понимает в crm.*.add return { fileData: [filename, base64], };}
Эта структура передаётся в fields целевого crm.lead.add напрямую — Битрикс расшифрует base64 и положит файл в свой FILES API. Подводный камень: base64 раздувает размер тела запроса в 4/3 раза. Файл на 5 МБ превратится в payload на ~6.7 МБ. У Битрикса есть лимит на размер тела запроса (обычно 30-50 МБ), большие файлы (видео, тяжёлые PDF) лучше переносить отдельным шагом или вовсе вручную.
Кастомные поля UF_*
Если справочники UF_* совпадают по ID между инстансами — переносятся как есть. Если не совпадают — нужно ещё одно расширение getStatusMap под crm.userfield.list. На моём проекте справочники UF_* совпадали (target создавался копированием конфига source), поэтому я этот случай не реализовывал — но если у вас два независимо выросших аккаунта, готовьтесь к ещё одному маппингу.
Подводные камни
Кратко то, что прилетает посреди миграции и стоит знать заранее.
1. Rate limit ~2 RPS. На 50 000 лидов это минимум ~8-10 минут только чистого чтения, плюс столько же на запись, плюс задержки сети. Реалистично закладывать 30-40 минут на 50 000 записей для одной сущности. Полная миграция (лиды + сделки + контакты + компании + связи) на средней базе — 2-4 часа.
2. Разные ID кастомных полей в source и target. Поле UF_CRM_1234567890 в одном инстансе ≠ UF_CRM_0987654321 в другом, даже если они называются одинаково. Перед миграцией снимаю снапшот через crm.userfield.list для обоих инстансов и держу маппинг.
3. FILES API через base64 раздувает payload. См. выше. Плюс — каждый файл это отдельный round-trip к source за скачиванием, что ещё умножает время миграции.
4. Битрикс не возвращает next после последней страницы. Я в цикле проверяю и response.next === undefined, и leads.length < PAGE_SIZE. Если проверять только что-то одно — на одних версиях Битрикса будет бесконечный цикл, на других — обрубите последнюю неполную страницу.
5. Связи между сущностями переносятся в правильном порядке. Сначала компании, потом контакты (которые ссылаются на компании), потом сделки/лиды (которые ссылаются на контакты). Если перенести лид раньше контакта — CONTACT_ID в target будет указывать в пустоту. Я кодирую порядок жёстко в migrate.ts:
typescript
async function main() { await buildMaps(); // юзеры, статусы, источники, стадии await migrateCompanies(); await migrateContacts(); await migrateLeads(); await migrateDeals();}
6. crm.lead.list отдаёт только активные лиды по умолчанию. Архивные/конвертированные нужно явно запрашивать через filter: { CONVERTED: "Y" } отдельным проходом. Иначе в target не уедет половина истории.
Чек-лист dry-run перед прогоном на проде
Перед тем как пускать миграцию на живой target-инстанс, прохожу пять пунктов:
-
Маппинг проверен на 10 случайных записях. Беру 10 лидов из source, прогоняю через mapping в режиме
--dry-run, печатаю payload, который ушёл бы в target. Глазами проверяю: статус сматчился, ответственный сматчился, кастомные поля на месте, телефон в правильном формате. -
Лимиты Битрикса проверены. Если у клиента free-tier Bitrix24, у него лимит на количество лидов в аккаунте (зависит от тарифа). На таком тарифе миграция 100 000 лидов просто не доедет — Битрикс перестанет принимать
crm.lead.addпосле превышения. Проверяется до начала миграции. -
Сделан backup target-инстанса. Если target — пустой новый аккаунт, можно пропустить. Если в target уже есть какие-то данные (например, миграция инкрементальная или вы доливаете в существующий) — backup обязателен. Битрикс позволяет выгрузить аккаунт через раздел «Битрикс24.Маркет» → «Резервное копирование».
-
REGISTER_SONET_EVENT: "N"добавлен во все*.add-вызовы. Без этого после миграции лента Битрикса станет нечитаемой на пару дней. -
migration.logпишется в файл. Не просто в stdout — в файл, который не удалится после закрытия терминала. Минимально для каждой записи логирую:source_id,target_id,status(success/skipped/error),timestamp,error_messageесли есть. Этот лог — единственный способ ответить на вопрос «а что с нашим лидом #12345?» через неделю после миграции.
typescript
// migrate-toolkit/src/log.tsimport { appendFileSync } from "fs";const LOG_PATH = `migration-${new Date().toISOString().slice(0, 10)}.log`;export function logRecord(entry: { entity: string; sourceId: string; targetId?: string; status: "success" | "skipped" | "error"; message?: string;}) { const line = JSON.stringify({ ...entry, ts: new Date().toISOString() }); appendFileSync(LOG_PATH, line + "\n");}
Плоский JSON — потом легко грепать и парсить:
bash
# Сколько лидов перенеслосьgrep '"entity":"lead"' migration-2026-05-04.log | grep '"status":"success"' | wc -l# Что упалоgrep '"status":"error"' migration-2026-05-04.log | jq .
Что забрать из статьи
Три вещи, которые отделяют рабочий migration toolkit от одноразового скрипта:
-
Идемпотентность через
ORIGINATOR_ID+ORIGIN_ID. Это нативный механизм Битрикса, не велосипед. Повторный прогон не плодит дубли — Битрикс сам сверяет пару полей и возвращает существующий ID. -
Маппинг справочников и пользователей до начала, не во время. Статусы — по
STATUS_IDс фолбэком наNAME. Пользователи — по email с фолбэком на админа. Без этих двух маппингов миграция упадёт на пятой записи. -
migration.logкак источник правды. Плоский JSON-лог по каждой записи. Это единственное, что позволит через неделю ответить на вопрос «дошло ли в target?» без переоткрытия аккаунтов руками.
Migration toolkit с этими тремя свойствами я гонял на одном из проектов в этом году. Около 80 000 записей суммарно по четырём сущностям, время прогона около 4.5 часов, повторных прогонов было два (после правок в маппинге кастомных полей), дублей в target — ноль.
Яков Радченко. Делаю веб-продукты на Next.js. Следующая статья — про inbound webhook от Битрикса в Next.js: как принимать события из CRM и не упасть от тысячи одновременных запросов из лента-обновления.
ссылка на оригинал статьи https://habr.com/ru/articles/1031780/