Зачем переносить аккаунт Битрикс между инстансами
В предыдущей статье (как отдавать лиды из 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-инстанс, прохожу пять пунктов:
-
Маппинг проверен на 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.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 от одноразового скрипта:
-
Идемпотентность через
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 и не упасть от тысячи одновременных запросов из лента-обновления.
Автор: yakov_etern8


