Migration toolkit для 1С Битрикс: переносим аккаунт между инстансами через crm.*.list + идемпотентность по ORIGINATOR_ID

от автора

Зачем переносить аккаунт Битрикс между инстансами

В предыдущей статье (как отдавать лиды из 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-инстанс, прохожу пять пунктов:

  1. Маппинг проверен на 10 случайных записях. Беру 10 лидов из source, прогоняю через mapping в режиме --dry-run, печатаю payload, который ушёл бы в target. Глазами проверяю: статус сматчился, ответственный сматчился, кастомные поля на месте, телефон в правильном формате.

  2. Лимиты Битрикса проверены. Если у клиента free-tier Bitrix24, у него лимит на количество лидов в аккаунте (зависит от тарифа). На таком тарифе миграция 100 000 лидов просто не доедет — Битрикс перестанет принимать crm.lead.add после превышения. Проверяется до начала миграции.

  3. Сделан backup target-инстанса. Если target — пустой новый аккаунт, можно пропустить. Если в target уже есть какие-то данные (например, миграция инкрементальная или вы доливаете в существующий) — backup обязателен. Битрикс позволяет выгрузить аккаунт через раздел «Битрикс24.Маркет» → «Резервное копирование».

  4. REGISTER_SONET_EVENT: "N" добавлен во все *.add-вызовы. Без этого после миграции лента Битрикса станет нечитаемой на пару дней.

  5. 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 от одноразового скрипта:

  1. Идемпотентность через ORIGINATOR_ID + ORIGIN_ID. Это нативный механизм Битрикса, не велосипед. Повторный прогон не плодит дубли — Битрикс сам сверяет пару полей и возвращает существующий ID.

  2. Маппинг справочников и пользователей до начала, не во время. Статусы — по STATUS_ID с фолбэком на NAME. Пользователи — по email с фолбэком на админа. Без этих двух маппингов миграция упадёт на пятой записи.

  3. migration.log как источник правды. Плоский JSON-лог по каждой записи. Это единственное, что позволит через неделю ответить на вопрос «дошло ли в target?» без переоткрытия аккаунтов руками.

Migration toolkit с этими тремя свойствами я гонял на одном из проектов в этом году. Около 80 000 записей суммарно по четырём сущностям, время прогона около 4.5 часов, повторных прогонов было два (после правок в маппинге кастомных полей), дублей в target — ноль.


Яков Радченко. Делаю веб-продукты на Next.js. Следующая статья — про inbound webhook от Битрикса в Next.js: как принимать события из CRM и не упасть от тысячи одновременных запросов из лента-обновления.

ссылка на оригинал статьи https://habr.com/ru/articles/1031780/