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

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

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

  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 на этот случай:

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

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

  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 и построить маппинг по символьным значениям:

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


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

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

  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:

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?» через неделю после миграции.

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

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

Комментарии (0)