Migration toolkit для 1С Битрикс: переносим аккаунт между инстансами через crm.*.list + идемпотентность по ORIGINATOR_ID. 1С-Битрикс.. 1С-Битрикс. bitrix24.. 1С-Битрикс. bitrix24. Node.JS.. 1С-Битрикс. bitrix24. Node.JS. ORIGINATOR_ID.. 1С-Битрикс. bitrix24. Node.JS. ORIGINATOR_ID. REST API.. 1С-Битрикс. bitrix24. Node.JS. ORIGINATOR_ID. REST API. TypeScript.. 1С-Битрикс. bitrix24. Node.JS. ORIGINATOR_ID. REST API. TypeScript. идемпотентность.. 1С-Битрикс. bitrix24. Node.JS. ORIGINATOR_ID. REST API. TypeScript. идемпотентность. миграция CRM.. 1С-Битрикс. bitrix24. Node.JS. ORIGINATOR_ID. REST API. TypeScript. идемпотентность. миграция CRM. Проектирование API.

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

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

# .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. Базовый вызов:

typescript

// 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;
  }
}

Три момента, на которые натыкаются почти все:

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.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 существующей записи. Это нативный механизм Битрикса, не наш велосипед.

Вот как это выглядит в коде:

typescript

// 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.

Два важных момента, на которых ловятся:

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.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 — это единственное поле, которое стабильно совпадает у одного и того же человека в двух инстансах:

typescript

// 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:

typescript

// 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), поэтому я этот случай не реализовывал — но если у вас два независимо выросших аккаунта, готовьтесь к ещё одному маппингу.


Подводные камни

Кратко то, что прилетает посреди миграции и стоит знать заранее.

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.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 — потом легко грепать и парсить:

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 и не упасть от тысячи одновременных запросов из лента-обновления.

Автор: yakov_etern8

Источник