# Синхронизация контактов с 1С или ERP

**Сложность:** продвинутый | **Скоупы:** crm | **Стек:** cURL / JavaScript

Переносим контакты между учётной системой и Битрикс24: новые записи создаются с обеих сторон, правки существующих едут из учётной системы в Битрикс24, повторный запуск не задваивает клиентов. Обмен идёт по расписанию и разбирает только то, что изменилось с прошлого раза.

## Что понадобится

- API-ключ Вайбкод со скоупом `crm`
- Доступ к API вашей учётной системы
- Node.js 18 или новее

Во всех примерах `$VIBE_URL` — базовый адрес `https://vibecode.bitrix24.tech`, `$VIBE_API_KEY` — ваш API-ключ.

**Сторона учётной системы в этом рецепте не описана.** У 1С, SAP и самописной базы разные протоколы, поэтому всё, что происходит на их стороне, вынесено в пять функций-заглушек: `fetchErpContacts` и `createErpContact` — чтение и запись записей, `findErpContactByKey` — поиск существующей записи по ключу, `loadSyncCursor` и `saveSyncCursor` — хранение отметки последнего запуска. Эти пять функций вы заменяете своим кодом. Всё остальное — вызовы Вайбкод — работает как показано.

## Как устроено решение

1. Выбираем ключ сопоставления. Записи связываются по значению, которое есть в обеих системах и не меняется — ИНН, внешний код клиента или адрес электронной почты.
2. Читаем из Битрикс24 контакты, изменённые после прошлого запуска, а не всю базу.
3. Сопоставляем их с выгрузкой из учётной системы и раскладываем на три группы: совпавшие, только в Битрикс24, только в учётной системе.
4. Недостающие контакты создаём в Битрикс24 одним пакетным вызовом, расхождения в совпавших — обновляем.
5. Сохраняем отметку времени запуска, от неё отсчитывается следующий цикл.

Примеры шагов показывают отдельные вызовы. Готовый к запуску скрипт — в разделе «Полный код».

## Шаг 1. Ключ сопоставления и поле под внешний код

Связывать записи по имени нельзя — «Иван Петров» встречается многократно. Нужен внешний код клиента, ИНН или адрес электронной почты.

Внешний код хранится в пользовательском поле контакта. Их имена на каждом портале свои и выглядят как `ufCrm_1729594209` — список возвращает [`GET /v1/contacts/fields`](/docs/entities/contacts/fields).

### cURL

```bash
curl -s -H "X-Api-Key: $VIBE_API_KEY" "$VIBE_URL/v1/contacts/fields"
```

### JavaScript

```javascript
const res = await fetch(`${VIBE_URL}/v1/contacts/fields`, {
  headers: { 'X-Api-Key': VIBE_API_KEY },
})
const { data } = await res.json()
const custom = Object.keys(data.fields).filter(name => name.startsWith('ufCrm_'))
```

```json
{
  "success": true,
  "data": {
    "fields": {
      "id": { "type": "number", "readonly": true, "label": "ID" },
      "email": { "type": "multifield", "readonly": false, "label": "Email" },
      "ufCrm_1729594209": { "type": "string", "readonly": false, "label": "Код клиента в 1С" }
    }
  }
}
```

Пользовательские поля отличаются от объявленных префиксом `ufCrm_` в имени. Подпись поля лежит в `label` — по ней среди пользовательских полей находится нужное, то, где хранится внешний код.

Имя поля выясняется один раз и записывается в конфигурацию скрипта. Если внешнего кода нет, ключом становится адрес электронной почты — по нему фильтр работает без дополнительной настройки.

## Шаг 2. Что изменилось с прошлого запуска

Полная выгрузка базы на каждый цикл не нужна. [`POST /v1/contacts/search`](/docs/entities/contacts/search) с фильтром по `updatedTime` отдаёт только записи, изменённые после отметки прошлого запуска. Синтаксис операторов — [фильтрация](/docs/filtering).

### cURL

```bash
curl -s -X POST -H "X-Api-Key: $VIBE_API_KEY" -H "Content-Type: application/json" \
  "$VIBE_URL/v1/contacts/search" \
  -d '{
    "filter": { ">updatedTime": "2026-07-20T00:00:00Z" },
    "select": ["id", "name", "lastName", "email", "phone", "updatedTime"],
    "sort": { "updatedTime": "asc" },
    "limit": 50,
    "offset": 0
  }'
```

### JavaScript

```javascript
const res = await fetch(`${VIBE_URL}/v1/contacts/search`, {
  method: 'POST',
  headers: { 'X-Api-Key': VIBE_API_KEY, 'Content-Type': 'application/json' },
  body: JSON.stringify({
    filter: { '>updatedTime': since },
    select: ['id', 'name', 'lastName', 'email', 'phone', 'updatedTime'],
    sort: { updatedTime: 'asc' },
    limit: 50,
    offset,
  }),
})
const { data, meta } = await res.json()
```

```json
{
  "success": true,
  "data": [
    {
      "id": 2521,
      "name": "Пробный",
      "lastName": "Рецепт",
      "email": "probe@example.com",
      "phone": "+70000000001",
      "updatedTime": "2026-07-21T08:59:22.000Z"
    }
  ],
  "meta": { "total": 1, "hasMore": false, "durationMs": 574 }
}
```

Отметку времени контакта несёт поле `updatedTime`, не `updatedAt` — у сделок это поле называется иначе. Признак «есть ли ещё страницы» — `meta.hasMore`.

Страницы перебираются параметром `offset`, и его шаг кратен 50: `offset: 25` вернёт ту же первую страницу, что и `offset: 0`. Берите `limit` кратным 50 и увеличивайте `offset` на ту же величину.

Адреса и телефоны в ответе приходят строками — `"email": "probe@example.com"`. Полный набор контактных значений с типами лежит в поле `fm`.

## Шаг 3. Сопоставление и три группы

Сопоставление — обычный код на стороне скрипта: строим указатель по ключу и проходим выгрузку учётной системы. Вызовов к API на этом шаге нет, поэтому и примера на cURL здесь нет — показывать нечего.

```javascript
// bitrixKey и erpKey обязаны доставать ОДИН И ТОТ ЖЕ признак с двух сторон:
// либо внешний код у обеих, либо почту у обеих. Указатель по почте против
// поиска по внешнему коду не совпадёт никогда — обмен начнёт плодить дубли.
function match(bitrixContacts, erpContacts, bitrixKey, erpKey) {
  const index = new Map(bitrixContacts.map(c => [bitrixKey(c), c]).filter(([k]) => k))
  const paired = []
  const onlyInErp = []
  const pairedIds = new Set()

  for (const erp of erpContacts) {
    const found = index.get(erpKey(erp))
    if (found) {
      paired.push({ bitrix: found, erp })
      pairedIds.add(found.id)
    } else {
      onlyInErp.push(erp)
    }
  }

  const onlyInBitrix = bitrixContacts.filter(c => !pairedIds.has(c.id))
  return { paired, onlyInBitrix, onlyInErp }
}
```

Ключ нормализуйте перед сравнением — адрес приводите к нижнему регистру, из ИНН и телефона убирайте пробелы и дефисы. Иначе `Ivanov@Example.com` и `ivanov@example.com` разъедутся в две записи.

## Шаг 4. Создание недостающих контактов пачкой

Создавать по одному не нужно. [`POST /v1/contacts/batch`](/docs/batch) принимает массив `items` и возвращает результат по каждому элементу отдельно.

### cURL

```bash
curl -s -X POST -H "X-Api-Key: $VIBE_API_KEY" -H "Content-Type: application/json" \
  "$VIBE_URL/v1/contacts/batch" \
  -d '{
    "action": "create",
    "items": [
      {
        "name": "Иван",
        "lastName": "Петров",
        "email": [{ "value": "petrov@example.com", "typeId": "WORK" }],
        "phone": [{ "value": "+70000000001", "typeId": "WORK" }],
        "sourceId": "OTHER",
        "sourceDescription": "Импорт из учётной системы"
      },
      {
        "name": "Мария",
        "lastName": "Сидорова",
        "email": [{ "value": "sidorova@example.com", "typeId": "WORK" }]
      }
    ]
  }'
```

### JavaScript

```javascript
const res = await fetch(`${VIBE_URL}/v1/contacts/batch`, {
  method: 'POST',
  headers: { 'X-Api-Key': VIBE_API_KEY, 'Content-Type': 'application/json' },
  body: JSON.stringify({
    action: 'create',
    items: onlyInErp.map(erp => ({
      name: erp.name,
      lastName: erp.lastName,
      email: [{ value: erp.email, typeId: 'WORK' }],
      phone: [{ value: erp.phone, typeId: 'WORK' }],
      sourceId: 'OTHER',
      sourceDescription: 'Импорт из учётной системы',
    })),
  }),
})
const { data } = await res.json()
```

```json
{
  "success": true,
  "data": {
    "results": [
      { "index": 0, "success": true, "id": 2523 },
      { "index": 1, "success": true, "id": 2525 }
    ],
    "summary": { "total": 2, "succeeded": 2, "failed": 0 }
  }
}
```

Поле `index` указывает на позицию в отправленном массиве `items` — по нему сопоставляется, какой записи учётной системы какой `id` в Битрикс24 достался. Эти пары надо сохранить у себя, иначе на следующем цикле сопоставление придётся строить заново.

Отказ по одной записи не отменяет остальные. Неуспешный элемент приходит в том же массиве `results` в форме `{ "index": 3, "success": false, "error": "<код>", "message": "<пояснение>" }` — разбирайте массив целиком, а не только `summary`.

Адрес и телефон передаются как `[{ "value": …, "typeId": "WORK" }]`. Форма с ключами `VALUE` и `VALUE_TYPE` отклоняется с `INVALID_MULTIFIELD_SHAPE`. Одиночную строку эндпоинт тоже принимает.

Тем же эндпоинтом идут обновления и удаления — меняется только `action`:

```json
{ "action": "update", "items": [{ "id": 2523, "fields": { "post": "Директор" } }] }
```

```json
{ "action": "delete", "ids": [2523, 2525] }
```

## Шаг 5. Обновление совпавших записей

Расхождения в совпавших парах правятся точечно — [`PATCH /v1/contacts/:id`](/docs/entities/contacts/update) принимает только изменившиеся поля.

### cURL

```bash
curl -s -X PATCH -H "X-Api-Key: $VIBE_API_KEY" -H "Content-Type: application/json" \
  "$VIBE_URL/v1/contacts/2521" \
  -d '{ "post": "Директор" }'
```

### JavaScript

```javascript
function diff(bitrix, erp) {
  const changes = {}
  if (erp.name && bitrix.name !== erp.name) changes.name = erp.name
  if (erp.lastName && bitrix.lastName !== erp.lastName) changes.lastName = erp.lastName
  if (erp.post && bitrix.post !== erp.post) changes.post = erp.post
  return changes
}

const changes = diff(pair.bitrix, pair.erp)
if (Object.keys(changes).length > 0) {
  await fetch(`${VIBE_URL}/v1/contacts/${pair.bitrix.id}`, {
    method: 'PATCH',
    headers: { 'X-Api-Key': VIBE_API_KEY, 'Content-Type': 'application/json' },
    body: JSON.stringify(changes),
  })
}
```

```json
{
  "success": true,
  "data": {
    "id": 2521,
    "name": "Пробный",
    "lastName": "Рецепт",
    "post": "Директор",
    "updatedTime": "2026-07-21T09:03:14.000Z"
  }
}
```

Ответ возвращает контакт целиком с новым `updatedTime` — по нему видно, что правка действительно применилась.

Отправляйте запрос только когда `changes` непустой. Обновление без изменений всё равно двигает `updatedTime`, и на следующем цикле контакт снова попадёт в выборку изменённых — цикл начнёт перебирать сам себя.

## Ограничения

**Очередь портала.** Цикл доверок и пакетных записей упирается в очередь портала раньше других отказов. Портал выполняет ограниченное число запросов к API одновременно, остальные ждут в очереди. Предел задаётся настройками платформы и на конкретное число полагаться нельзя. Цикл, который шлёт запросы подряд, рано или поздно получит `429`. Код `QUEUE_OVERFLOW` означает, что очередь переполнена и запрос отклонён сразу, `QUEUE_TIMEOUT` — что запрос прождал места дольше 30 секунд и до Битрикс24 не дошёл, поэтому повторить его безопасно.

```json
{
  "success": false,
  "error": {
    "code": "QUEUE_OVERFLOW",
    "message": "Portal queue overloaded — 128 Bitrix24 calls already pending",
    "userMessage": "Слишком много одновременных запросов к Bitrix24 — повторите через несколько секунд.",
    "hint": "Honor the Retry-After header. Use exponential backoff with jitter for repeated failures.",
    "retryAfter": 3
  }
}
```

Рекомендованная пауза приходит в заголовке `Retry-After` и дублируется в `error.retryAfter`. Повторяйте с нарастающей задержкой, а не мгновенно — готовая функция повтора есть в [Лимитах и оптимизации](/docs/optimization).

**Постраничный обход и окно.** Постраничный обход через `offset` несовместим с оконным разбором широкого диапазона дат: фильтр `>updatedTime` со старой отметкой охватывает больше 14 дней, окно включается автоматически, и вторая страница возвращает `400 UNSTABLE_OFFSET_PAGINATION`. Поэтому запрос идёт с `autoWindow: false` и сортировкой по `id`.

**Предел пакета.** Пакетный вызов ограничен по числу элементов в одном запросе. Точное значение задаёт администратор платформы, превышение отклоняет весь пакет с кодом `BATCH_LIMIT_EXCEEDED`. Разбивайте выгрузку на порции и отправляйте их последовательно.

**Дубли при инкрементальном чтении.** Инкрементальное чтение отвечает на вопрос «что изменилось», но не на вопрос «чего не хватает». Указатель сопоставления строится из среза изменений, поэтому запись, чей двойник с прошлого раза не менялся, в нём отсутствует — и выглядит как новая. Создавать её нельзя: скрипт сперва доискивает такую запись в Битрикс24 по ключу отдельным запросом и только при пустом ответе создаёт. Без этой доверки обмен плодит дубли на каждом прогоне, и симметричное окно на обеих сторонах от этого не спасает.

**Правки — в одну сторону.** Правки существующих записей едут только в одну сторону — из учётной системы в Битрикс24. Новые записи при этом создаются с обеих сторон. Обратная передача правок из Битрикс24 в учётную систему в рецепт не входит: она требует правила разрешения конфликтов по каждому полю, а без него одна и та же запись переписывалась бы на каждом запуске туда и обратно.

**Кто выигрывает конфликт.** Направление правки при расхождении решает автор скрипта. В примере при конфликте выигрывает учётная система: её значения перезаписывают поля Битрикс24. Если менеджер поправил телефон в карточке, а в учётной системе остался старый — правка менеджера пропадёт. Другой вариант — сравнивать отметки времени с обеих сторон и брать более свежую.

**Отметка последнего запуска.** Отметка последнего запуска должна переживать перезапуск процесса. Хранить её в переменной внутри скрипта нельзя: после перезагрузки сервера цикл начнётся с нуля и перечитает всю базу.

**Таблица сопоставления вне Вайбкод.** Соответствие «запись учётной системы — контакт Битрикс24» хранится вне Вайбкод. Если его потерять, повторный запуск сопоставит записи заново по ключу, а для тех, у кого ключ пустой, создаст дубли.

**Журнала обмена нет.** Журнал обмена скрипт не ведёт. Ответ пакетного вызова даёт `index`, `success` и `id` по каждой записи — сохраняйте эти строки у себя, иначе после сбоя нельзя ответить, какие записи прошли, а какие нет.

**Удаление не переносится.** Удаление не синхронизируется. Контакт, удалённый в учётной системе, останется в Битрикс24 — обмен переносит создание и изменение.

**Сон BLACKHOLE-сервера.** Если обмен запускается на BLACKHOLE-сервере, отключите автоматический сон. Таймер простоя считает **входящие** обращения к серверу, а обмен делает только исходящие вызовы — с точки зрения таймера сервер простаивает и засыпает вместе с расписанием. Отключается через [`PATCH /v1/infra/servers/:id/sleep`](/docs/infra/lifecycle/sleep) со значением `sleepAfterMinutes: null`. На вытесняемых тарифах (`bc-agent`, `bc-micro`) этого недостаточно: облако всё равно перезапускает машину примерно раз в сутки. Отметка последнего запуска перезапуск переживёт, но за время сна расхождение накапливается, и первый цикл после пробуждения пойдёт большой пачкой — рассчитывайте размер порции на этот случай, а не на обычный час.

Полный перечень кодов — [Ошибки](/docs/errors).

## Полный код

```javascript
// erp-sync.js — обмен контактами между учётной системой и Битрикс24
const VIBE_URL = process.env.VIBE_URL ?? 'https://vibecode.bitrix24.tech'
const VIBE_API_KEY = process.env.VIBE_API_KEY
if (!VIBE_API_KEY) throw new Error('Переменная окружения VIBE_API_KEY не задана')
const PAGE = 50
const BATCH = 50
// Имя пользовательского поля с внешним кодом, выясненное на шаге 1.
// Пусто — ключом сопоставления служит адрес электронной почты.
const EXTERNAL_KEY_FIELD = process.env.ERP_KEY_FIELD ?? null

const headers = { 'X-Api-Key': VIBE_API_KEY, 'Content-Type': 'application/json' }

// Любой отказ 429 (QUEUE_OVERFLOW, QUEUE_TIMEOUT, RATE_LIMITED) означает,
// что запрос до Битрикс24 не дошёл, — повторить его безопасно, контакты не задвоятся. Сколько ждать,
// говорит заголовок Retry-After, а при его отсутствии поле error.retryAfter.
// К паузе добавляется случайная доля
// секунды, чтобы параллельные копии синхронизации не пошли на повтор разом.
// Без этого повтора длинная выгрузка обрывается на первой же занятой очереди.
const MAX_RETRIES = 5

async function apiCall(url, init = {}, onError) {
  for (let attempt = 0; ; attempt++) {
    const res = await fetch(url, init)
    const body = await res.json().catch(() => null)

    if (res.status === 429 && attempt < MAX_RETRIES) {
      // Рекомендованную паузу берём как есть: умножать её на номер попытки
      // нельзя — из Retry-After: 3 на пятой попытке вышло бы 48 секунд.
      // Экспонента нужна только когда сервер паузу не назвал.
      const advised = res.headers.get('Retry-After') ?? body?.error?.retryAfter ?? null
      // Потолок в 60 с: рекомендованную паузу мы уважаем, но неожиданно
      // большое значение от промежуточного прокси не должно вешать цикл.
      const base = advised === null ? Math.min(2 ** attempt, 30) : Number(advised)
      const wait = Math.min(base, 60) + Math.random()
      console.warn(`Очередь портала занята, повтор через ${Math.round(wait)} с`)
      await new Promise(resolve => setTimeout(resolve, wait * 1000))
      continue
    }

    if (!body?.success) {
      if (onError) return onError(body, res)
      throw new Error(body?.error?.message ?? `запрос отклонён (${res.status})`)
    }
    return body
  }
}

// --- сторона учётной системы: замените своим кодом -------------------------
async function fetchErpContacts(since) {
  // Верните массив вида
  // { externalKey, name, lastName, email, phone, post },
  // изменённый после отметки since — ТЕМ ЖЕ окном, что и сторона Битрикс24.
  // Полная выгрузка здесь против инкрементальной там означает, что записи,
  // чей контакт с прошлого раза не менялся, не найдутся в указателе и будут
  // созданы заново на каждом прогоне.
  return []
}
async function createErpContact(contact) { return { externalKey: String(contact.id) } }
// Зеркало доверки для стороны учётной системы: по ключу вернуть существующую
// запись или null. Без неё контакт, изменившийся в Битрикс24, выгружается
// повторно каждый раз, когда его двойник в учётной системе не менялся.
async function findErpContactByKey(key) { return null }
async function loadSyncCursor() { return process.env.SYNC_SINCE ?? '1970-01-01T00:00:00Z' }
async function saveSyncCursor(iso) { console.log('новая отметка:', iso) }
// ---------------------------------------------------------------------------

const normalize = value => String(value ?? '').trim().toLowerCase()

async function fetchChangedContacts(since) {
  const all = []
  for (let offset = 0; ; offset += PAGE) {
    const body = await apiCall(`${VIBE_URL}/v1/contacts/search`, {
      method: 'POST',
      headers,
      body: JSON.stringify({
        filter: { '>updatedTime': since },
        select: EXTERNAL_KEY_FIELD
          ? ['id', 'name', 'lastName', 'post', 'email', 'phone', 'updatedTime', EXTERNAL_KEY_FIELD]
          : ['id', 'name', 'lastName', 'post', 'email', 'phone', 'updatedTime'],
        // Фильтр по дате с диапазоном шире 14 дней включает оконный обход, а он
        // несовместим с offset: второй страницей приходит 400
        // UNSTABLE_OFFSET_PAGINATION. Отключаем окно и сортируем по id —
        // это даёт обычную постраничную выдачу, устойчивую к offset.
        autoWindow: false,
        sort: { id: 'asc' },
        limit: PAGE,
        offset,
      }),
    })
    all.push(...body.data)
    if (!body.meta?.hasMore) return all
  }
}

// Обе стороны ключуются одним и тем же признаком. Смешивать нельзя: указатель,
// построенный по почте, никогда не совпадёт с внешним кодом, и тогда каждый
// запуск считал бы все записи новыми и создавал дубли.
const bitrixKey = contact => normalize(EXTERNAL_KEY_FIELD ? contact[EXTERNAL_KEY_FIELD] : contact.email)
const erpKey = erp => normalize(EXTERNAL_KEY_FIELD ? erp.externalKey : erp.email)

function match(bitrixContacts, erpContacts) {
  const index = new Map()
  for (const contact of bitrixContacts) {
    const key = bitrixKey(contact)
    if (key) index.set(key, contact)
  }

  const paired = []
  const onlyInErp = []
  const pairedIds = new Set()

  for (const erp of erpContacts) {
    const key = erpKey(erp)
    const found = key ? index.get(key) : undefined
    if (found) {
      paired.push({ bitrix: found, erp })
      pairedIds.add(found.id)
    } else {
      onlyInErp.push(erp)
    }
  }

  return {
    paired,
    onlyInErp,
    onlyInBitrix: bitrixContacts.filter(c => !pairedIds.has(c.id)),
  }
}

async function findInBitrixByKey(erp) {
  // В фильтр уходит ИСХОДНОЕ значение, без приведения к нижнему регистру:
  // нижний регистр нужен для сравнения в памяти, а портал сравнивает как есть.
  // Внешний код «A-1001», сохранённый в верхнем регистре, по «a-1001» не найдётся,
  // доверка вернёт пусто — и создаст тот самый дубль, ради которого она добавлена.
  const key = EXTERNAL_KEY_FIELD ? erp.externalKey : erp.email
  if (!key) return null
  const body = await apiCall(`${VIBE_URL}/v1/contacts/search`, {
    method: 'POST',
    headers,
    body: JSON.stringify({
      filter: EXTERNAL_KEY_FIELD ? { [EXTERNAL_KEY_FIELD]: key } : { email: key },
      select: ['id', 'name', 'lastName', 'post', 'email', 'phone'],
      limit: 1,
    }),
  })
  return body.data[0] ?? null
}

async function createInBitrix(erpContacts) {
  const created = []
  for (let i = 0; i < erpContacts.length; i += BATCH) {
    const chunk = erpContacts.slice(i, i + BATCH)
    const body = await apiCall(`${VIBE_URL}/v1/contacts/batch`, {
      method: 'POST',
      headers,
      body: JSON.stringify({
        action: 'create',
        items: chunk.map(erp => ({
          name: erp.name,
          lastName: erp.lastName,
          post: erp.post,
          email: erp.email ? [{ value: erp.email, typeId: 'WORK' }] : undefined,
          phone: erp.phone ? [{ value: erp.phone, typeId: 'WORK' }] : undefined,
          sourceId: 'OTHER',
          sourceDescription: 'Импорт из учётной системы',
          // Без записи внешнего кода созданный контакт не совпадёт на следующем запуске.
          ...(EXTERNAL_KEY_FIELD ? { [EXTERNAL_KEY_FIELD]: erp.externalKey } : {}),
        })),
      }),
    })

    for (const row of body.data.results) {
      const source = chunk[row.index]
      if (row.success) created.push({ externalKey: source.externalKey, bitrixId: row.id })
      else console.log(`не создан ${source.externalKey}: ${row.error} — ${row.message}`)
    }
  }
  return created
}

function diff(bitrix, erp) {
  const changes = {}
  if (erp.name && bitrix.name !== erp.name) changes.name = erp.name
  if (erp.lastName && bitrix.lastName !== erp.lastName) changes.lastName = erp.lastName
  if (erp.post && bitrix.post !== erp.post) changes.post = erp.post
  return changes
}

async function updateInBitrix(pairs) {
  let updated = 0
  for (const pair of pairs) {
    const changes = diff(pair.bitrix, pair.erp)
    if (Object.keys(changes).length === 0) continue

    // Третий аргумент отключает исключение: одна незаехавшая правка не должна
    // ронять весь обмен. Повтор при 429 обёртка всё равно делает.
    const body = await apiCall(`${VIBE_URL}/v1/contacts/${pair.bitrix.id}`, {
      method: 'PATCH',
      headers,
      body: JSON.stringify(changes),
    }, failed => failed)
    if (body?.success) updated++
    else console.log(`не обновлён #${pair.bitrix.id}: ${body?.error?.message}`)
  }
  return updated
}

async function sync() {
  const startedAt = new Date().toISOString()
  const since = await loadSyncCursor()
  console.log(`изменения с ${since}`)

  const [bitrixContacts, erpContacts] = await Promise.all([
    fetchChangedContacts(since),
    fetchErpContacts(since),
  ])
  console.log(`Битрикс24: ${bitrixContacts.length}, учётная система: ${erpContacts.length}`)

  const { paired, onlyInBitrix, onlyInErp } = match(bitrixContacts, erpContacts)

  // Инкрементальное чтение отвечает на вопрос «что изменилось», но НЕ на вопрос
  // «чего не хватает»: отсутствие записи в срезе изменений не означает, что её
  // нет в Битрикс24. Поэтому каждую несопоставленную запись доискиваем по ключу
  // — без этого обмен создаёт дубль на каждом прогоне.
  const trulyNew = []
  for (const erp of onlyInErp) {
    const found = await findInBitrixByKey(erp)
    if (found) paired.push({ bitrix: found, erp })
    else trulyNew.push(erp)
  }

  const created = await createInBitrix(trulyNew)
  const updated = await updateInBitrix(paired)

  let exported = 0
  for (const contact of onlyInBitrix) {
    // Та же логика, что и на импорте: отсутствие в срезе изменений не означает
    // отсутствия в учётной системе, поэтому сперва доверка по ключу.
    if (await findErpContactByKey(bitrixKey(contact))) continue

    const { externalKey } = await createErpContact(contact)
    // Внешний код возвращается в контакт Битрикс24. Без этой записи контакт
    // на следующем запуске снова окажется несопоставленным и выгрузится второй раз.
    if (EXTERNAL_KEY_FIELD && externalKey) {
      const body = await apiCall(`${VIBE_URL}/v1/contacts/${contact.id}`, {
        method: 'PATCH',
        headers,
        body: JSON.stringify({ [EXTERNAL_KEY_FIELD]: externalKey }),
      }, failed => failed)
      // Провал этой записи молча ломает сопоставление: контакт выгрузится
      // повторно на следующем прогоне, поэтому он не считается выгруженным.
      if (!body?.success) {
        console.log(`  ${contact.id}: внешний код не записан — ${body?.error?.message}`)
        continue
      }
    }
    exported++
  }

  await saveSyncCursor(startedAt)
  console.log(`создано ${created.length}, обновлено ${updated}, выгружено ${exported}`)
}

sync().catch(error => {
  console.error('обмен прерван:', error.message)
  process.exitCode = 1
})
```

Запуск по расписанию — задача планировщика операционной системы. Одна запись в `cron` вида `0 * * * * node /opt/sync/erp-sync.js` запускает обмен каждый час и переживает перезагрузку сервера.

## Смотрите также

- [Работа с файлами в полях CRM](/docs/recipes/crm-files)
- [Поиск контактов](/docs/entities/contacts/search)
- [Обновить контакт](/docs/entities/contacts/update)
- [Поля контакта](/docs/entities/contacts/fields)
- [Batch](/docs/batch)
- [Синтаксис фильтрации](/docs/filtering)
