Зачем переносить аккаунт Битрикс между инстансами
В предыдущей статье (как отдавать лиды из 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 выключается или используется только как архив.
Конфиг — две переменные окружения:
# .env BITRIX_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. Базовый вызов:
// migrate-toolkit/src/source.ts import { 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; } }
Три момента, на которые натыкаются почти все:
start— это смещение, не номер страницы. Если на странице 50 записей и вы прочитали 10 страниц — следующийstart = 500. Битрикс отдаёт это значение вresponse.next, и проще доверять ему, чем считать самому.Лимит 50 записей на страницу — жёсткий. Можно попросить меньше через
?limit=20, но больше — нет, отрежет молча. Я держуPAGE_SIZE = 50константой и не трогаю.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 на этот случай:
// migrate-toolkit/src/client.ts const MAX_ATTEMPTS = 4; const REQUEST_TIMEOUT_MS = 15_000; // больше, чем для outbound — list тяжелее add const 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 существующей записи. Это нативный механизм Битрикса, не наш велосипед.
Вот как это выглядит в коде:
// migrate-toolkit/src/leads.ts import { 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.
Два важных момента, на которых ловятся:
ORIGINATOR_ID— строка, и она должна быть стабильной между прогонами. Если первый раз вы записалиORIGINATOR_ID = "old-bitrix", а второй раз —ORIGINATOR_ID = "bitrix-old-prod", то для Битрикса это разные источники, и он создаст дубли. Я фиксирую константу в коде и не трогаю до конца миграции.params: { REGISTER_SONET_EVENT: "N" }— без этого каждый созданный лид породит запись в живой ленте Битрикса. После миграции 50 000 лидов лента превратится в нечитаемое полотно. У менеджеров будет сердечный приступ. Этот флаг я ставлю на все миграционные*.add‑вызовы.
Маппинг сложных полей
Простые поля (имя, телефон, email) переносятся как есть. Сложности начинаются на трёх типах полей.
Enum‑значения (статусы, типы, источники)
В Битриксе статус лида хранится как ID элемента справочника, например STATUS_ID: "NEW" или SOURCE_ID: "5". Проблема в том, что ID статусов в source и target могут не совпадать, особенно если справочники в target правились руками.
Поэтому первый шаг миграции — вытянуть оба справочника через crm.status.list и построить маппинг по символьным значениям:
// migrate-toolkit/src/mapping/status.ts import { 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 — это единственное поле, которое стабильно совпадает у одного и того же человека в двух инстансах:
// migrate-toolkit/src/mapping/user.ts const 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:
// migrate-toolkit/src/mapping/file.ts import { 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), поэтому я этот случай не реализовывал — но если у вас два независимо выросших аккаунта, готовьтесь к ещё одному маппингу.
Подводные камни
Кратко то, что прилетает посреди миграции и стоит знать заранее.
Rate limit ~2 RPS. На 50 000 лидов это минимум ~8-10 минут только чистого чтения, плюс столько же на запись, плюс задержки сети. Реалистично закладывать 30–40 минут на 50 000 записей для одной сущности. Полная миграция (лиды + сделки + контакты + компании + связи) на средней базе — 2–4 часа.
Разные ID кастомных полей в source и target. Поле
UF_CRM_1234567890в одном инстансе ≠UF_CRM_0987654321в другом, даже если они называются одинаково. Перед миграцией снимаю снапшот черезcrm.userfield.listдля обоих инстансов и держу маппинг.FILES API через base64 раздувает payload. См. выше. Плюс — каждый файл это отдельный round‑trip к source за скачиванием, что ещё умножает время миграции.
Битрикс не возвращает
nextпосле последней страницы. Я в цикле проверяю иresponse.next=== undefined, иleads.length < PAGE_SIZE. Если проверять только что‑то одно — на одних версиях Битрикса будет бесконечный цикл, на других — обрубите последнюю неполную страницу.Связи между сущностями переносятся в правильном порядке. Сначала компании, потом контакты (которые ссылаются на компании), потом сделки/лиды (которые ссылаются на контакты). Если перенести лид раньше контакта —
CONTACT_IDв target будет указывать в пустоту. Я кодирую порядок жёстко вmigrate.ts:
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?» через неделю после миграции.
// migrate-toolkit/src/log.ts import { 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 — потом легко грепать и парсить:
# Сколько лидов перенеслось 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 и не упасть от тысячи одновременных запросов из лента‑обновления.