Как отдавать лиды из Next.js в 1С Битрикс: outbound без очередей и воркеров

от автора

О чём это

Типовая задача на российском рынке: есть публичный сайт (лендинг, маркетплейс, каталог), на нём формы — заявка, регистрация, заявка партнёра. Эти лиды должны попадать в 1С Битрикс, где с ними работает отдел продаж.

Подход «в лоб» выглядит так: в обработчике формы сделать await fetch('https://bitrix.../crm.lead.add', ...) и вернуть пользователю ответ после того, как Битрикс подтвердил создание лида.

Это плохо работает. Битрикс REST API нестабилен по latency — 200 мс в норме, 8 секунд при нагрузке на стороне CRM. Пользователь сайта в это время смотрит на крутилку. Если Битрикс упал или таймаутит — сайт отдаёт ошибку, хотя пользователь форму заполнил корректно.

В этой статье — паттерн, который я использовал на маркетплейсе недвижимости на Next.js 16 + PostgreSQL 16 + 1С Битрикс. Без Redis, без BullMQ, без отдельного воркера. Просто Next.js API route + after() + минимальный HTTP-клиент с retry и таймаутом.

Цифры проекта для контекста: 25 объектов недвижимости в каталоге (отдельная сущность ready_homes оставлена за скобками статьи), 57 API-роутов, PostgreSQL 16.13 на VPS, деплой через systemd + nginx, интеграция с Битрикс — исключительно outbound (сайт → CRM).

Архитектура в одну картинку

Три слоя, без очередей:

┌──────────────┐       POST /api/leads        ┌─────────────────────────┐│   Browser    │ ───────────────────────────▶ │  Next.js API Route      │└──────────────┘                              │  1. валидация (zod)     │                                              │  2. INSERT в PostgreSQL │                                              │  3. 200 OK пользователю │                                              │  4. after() → Битрикс   │                                              └────────┬────────────────┘                                                       │                                        ┌──────────────┼──────────────┐                                        ▼                             ▼                              ┌──────────────────┐          ┌──────────────────┐                              │   PostgreSQL     │          │   Битрикс REST   │                              │   leads table    │          │   crm.lead.add   │                              │   bitrix_id NULL │          │                  │                              └────────┬─────────┘          └────────┬─────────┘                                       │                             │                                       │        UPDATE leads         │                                       │  SET bitrix_id = $1         │                                       │  WHERE id = $2              │                                       └─────────────────────────────┘

Ключевая идея: лид сначала появляется в нашей базе, и пользователь получает ответ сразу. Отправка в Битрикс происходит фоново через after(), а результат (внешний ID лида в CRM) записывается в ту же строку PostgreSQL вторым апдейтом.

Это означает, что:

  • Падение Битрикса не ломает сайт.

  • Медленный Битрикс не тормозит пользовательский ответ.

  • Мы всегда знаем, какие лиды улетели в CRM (bitrix_id IS NOT NULL), а какие ещё нет.

  • Если нужно — можно ретраить в фоне, идя по bitrix_id IS NULL (я этого сознательно не делаю, почему — ниже).

after() в Next.js 16 — что это и почему именно он

Next.js 16 стабилизировал after() — API для выполнения работы после того, как ответ пользователю уже отправлен. Не «параллельно», не «в отдельном процессе» — именно после того, как response улетел в клиент. Раннее существование таких конструкций обычно реализовалось через Promise.resolve().then(...) без await или через fire-and-forget с рисками потерять задачу, если серверлесс-функция завершится до её выполнения.

after() решает это на уровне фреймворка: Next.js гарантирует, что переданная функция отработает, даже если response уже отдан, и что процесс не завершится до её окончания (в пределах ограничений платформы деплоя).

В случае с лидом в API route это выглядит так:

// Упрощённый фрагмент из src/app/api/leads/route.tsimport { after } from "next/server";export async function POST(request: Request) {  const payload = await request.json();  const parsed = LeadSchema.safeParse(payload);  if (!parsed.success) {    return Response.json({ error: "invalid" }, { status: 422 });  }  // Антидубль по телефону в окне 10 минут  const recent = await pgQuery<{ count: bigint }>(    `SELECT count(*)::bigint AS count     FROM public.leads     WHERE phone = $1 AND created_at > $2::timestamptz`,    [parsed.data.phone, new Date(Date.now() - 10 * 60 * 1000).toISOString()]  );  if (recent[0].count > 0n) {    return Response.json({ ok: true, deduplicated: true });  }  // Сохраняем в нашу БД. bitrix_id пока NULL.  const [lead] = await pgQuery<{ id: string }>(    `INSERT INTO public.leads (name, phone, source)     VALUES ($1, $2, $3)     RETURNING id`,    [parsed.data.name, parsed.data.phone, parsed.data.source ?? "website"]  );  // Ответ пользователю уходит прямо сейчас.  // Дальнейшая работа с Битриксом — уже после response.  after(async () => {    try {      const bitrixId = await sendToBitrix24({        title: `Заявка: ${parsed.data.name}`,        phone: parsed.data.phone,        sourceDescription: parsed.data.source,      });      if (!bitrixId) return;      await pgQuery(        `UPDATE public.leads SET bitrix_id = $1 WHERE id = $2`,        [bitrixId, lead.id]      );    } catch (error) {      console.error("[Leads API] Ошибка фоновой отправки в Битрикс:", error);    }  });  return Response.json({ ok: true, id: lead.id });}

Что здесь критично:

  1. Источник правды — наша БД. Лид сохранён в public.leads до того, как мы вообще полезли в Битрикс. Если Битрикс лежит — у нас в базе всё равно есть заявка, с ней можно работать руками.

  2. Антидубль не на стороне CRM, а на стороне нашей БД. Окно 10 минут по телефону. Битрикс, к слову, сам по себе дубли принимает молча (если не настраивать на нём отдельную логику) — лучше не полагаться на него.

  3. bitrix_id в таблице лидов. Это наша «связующая нить» — внешний ID лида в Битриксе. NULL означает «в CRM ещё не улетел». Колонка не обязательная, и это осознанно: лид без bitrix_id — нормальное, валидное состояние на первые секунды жизни записи.

  4. Ошибка отправки в Битрикс не падает наружу. Логируется в journalctl (у нас systemd), пользователю это не видно.

Минимальный HTTP-клиент к Битриксу: retry, таймаут, два параметра

Ровно эти четыре константы — то, с чего начинается любая нормальная интеграция с внешним API:

// src/lib/bitrix-runtime.tsconst MAX_ATTEMPTS = 2;const REQUEST_TIMEOUT_MS = 8000;const RETRY_DELAY_MS = 1500;

Почему именно такие значения:

  • MAX_ATTEMPTS = 2. Одна попытка — мало: сеть дрогнула, Битрикс один раз ответил 502 — лид потерян. Три и больше — уже начинаются вопросы: если Битрикс реально лежит 30 секунд, мы всё это время держим серверный процесс на одном запросе. Две попытки — достаточная страховка от случайных сетевых сбоев, но не превращает обработчик в долгого висящего клиента.

  • REQUEST_TIMEOUT_MS = 8000. Эмпирическое значение. p95 latency у Битрикс webhook на crm.lead.add — 400-700 мс на нормальном аккаунте. 8 секунд — это «что-то явно пошло не так, хватит ждать». AbortController с таким таймаутом режет запрос и даёт второй попытке шанс.

  • RETRY_DELAY_MS = 1500. Линейный backoff. Не экспоненциальный — при двух попытках это избыточная сложность.

Сам клиент (упрощённая версия того, что у нас живёт в BitrixWebhookClient под migration toolkit):

async function bitrixRequest(method: string, payload: Record<string, unknown>) {  const url = `${BITRIX_WEBHOOK_URL}/${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) : {};      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 new Promise((r) => setTimeout(r, RETRY_DELAY_MS));    } finally {      clearTimeout(timer);    }  }  throw new Error(`${method}: retries exhausted`);}

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

  • AbortController с очисткой таймера в finally. Иначе в успешном сценарии таймер продолжит тикать и уронит следующий запрос через 8 секунд. Забытый clearTimeout — классика.

  • response.text()JSON.parse, а не response.json(). Битрикс при ошибках иногда отдаёт пустое тело с 200-м кодом или HTML. response.json() на это падает с невнятной ошибкой парсинга. Ручной парсинг даёт возможность положить в лог сырой текст ответа — это сэкономит часы отладки.

  • Ошибка может быть в двух местах. !response.ok (HTTP-ошибка) и json.error (Битрикс вернул 200, но внутри {error: "INVALID_CREDENTIALS"}). Обрабатываются одинаково — throw с описанием.

Аутентификация: incoming webhook, не OAuth

Для outbound-интеграции у Битрикса есть два пути: OAuth-приложение или incoming webhook (личный URL с токеном в пути).

Я выбрал webhook. Причины:

  • Нет приложения в Marketplace → нет апрувалов, нет периодической проверки токена.

  • URL можно хранить как обычную env-переменную BITRIX24_WEBHOOK_URL.

  • Скоупы задаются один раз в админке Битрикса при создании вебхука.

Для разовой миграции аккаунта (о ней отдельная статья) используется пара URL: BITRIX24_SOURCE_WEBHOOK_URL (откуда тянем) и BITRIX24_TARGET_WEBHOOK_URL (куда пишем). В рантайме хватает одного.

Минус webhook-подхода: токен в URL. Если случайно залогируешь полный URL куда-то в Sentry или публичный канал — сольётся доступ к CRM. Лечится простым правилом: ни в коде, ни в логах не писать URL целиком — только название метода и payload (без полей, содержащих токен).

PostgreSQL-схема: один столбец, который решает всё

Ключевое поле в таблице лидов — bitrix_id. Всё остальное — стандартные поля заявки.

-- Упрощённый фрагментCREATE TABLE IF NOT EXISTS public.leads (  id uuid PRIMARY KEY DEFAULT gen_random_uuid(),  name text NOT NULL,  phone text NOT NULL,  source text,  bitrix_id text,  created_at timestamptz NOT NULL DEFAULT now());

Три наблюдения:

  1. bitrix_idtext, не integer. Битрикс возвращает ID как строку в JSON. Можно хранить как число, но зачем тратить силы на приведение типа, который используется только для обратной ссылки.

  2. bitrix_id nullable. Это явное признание того, что отправка в CRM — асинхронная и может не произойти. NULL означает «либо ещё не улетело, либо улетело, но с ошибкой». Разделять эти два состояния на уровне БД я не стал — пока не понадобилось.

  3. id в PostgreSQL — uuid с gen_random_uuid(). Это обеспечивает идемпотентность на стороне нашей БД и позволяет передавать ID лида в Битрикс как «внешний источник» (у нас это используется в migration toolkit через ORIGINATOR_ID + ORIGIN_ID, но это тема другой статьи).

Для регистрации пользователей паттерн расширяется на две колонки:

ALTER TABLE public.user_profiles  ADD COLUMN registration_bitrix_id text,  ADD COLUMN registration_bitrix_synced_at timestamptz;

Вторая колонка — synced_at — позволяет отличать «никогда не отправляли» от «отправили тогда-то» и писать отчёты «сколько регистраций за неделю дошли до CRM».

Что я сознательно не делал

Это, пожалуй, самая важная часть статьи — потому что в большинстве материалов про «интеграцию с CRM» в первой же строке появляется Redis.

Не завёл Redis + BullMQ. Соблазн очевидный: очередь задач, ретраи по расписанию, dead letter queue. На масштабе 25 объектов в каталоге и десятков лидов в день это over-engineering. after() в Next.js 16 покрывает 99% сценариев. Redis добавлю, когда появится реальная нагрузка или потребность в задачах, переживающих рестарт процесса.

Не сделал inbound webhook от Битрикса. Этого часто ждут от «синхронизации»: Битрикс обновил статус сделки → прилетает в наш сайт. На текущем проекте это не нужно: вся логика «клиент работает со статусами» живёт внутри Битрикса, на сайте этой информации не надо. Если завтра понадобится — добавится endpoint /api/bitrix/webhook с проверкой шаред-секрета, и это тоже будет отдельная статья.

Не делаю двустороннюю синхронизацию объектов недвижимости. Объекты в маркетплейсе живут в PostgreSQL, модерация — в нашей же админке (projects.moderation_status + pending_changes). В Битрикс летят только лиды и заявки. Это продуктовое решение, а не техническое ограничение: бизнес-смысл «дать менеджеру объект редактировать в CRM» на этом проекте отсутствует.

Не гонюсь за 99.99% доставки лидов в CRM. Для этого нужны: очередь с персистентностью, воркер-ретраер по крону, мониторинг застрявших задач. Я осознанно остановился на варианте «после двух попыток — лог и руками». За всё время эксплуатации не было ни одного застрявшего лида — антидубль и текущий retry покрывают сетевые инциденты. Если бы масштаб был на два порядка выше — решение было бы другим.

Observability: минимальный набор

Из мониторинга в проекте есть:

  • Внешний uptime-monitor через GitHub Actions с cron: "*/5 * * * *". Дергает прод-эндпоинт, при падении — алерт в Telegram.

  • journalctl на VPS для рантайм-логов Next.js-процесса под systemd. Ошибки отправки в Битрикс видны через journalctl -u marketplace-next -f | grep Bitrix.

  • Быстрый smoke-тест в CI при каждом пуше в main.

Чего нет: Sentry, Grafana, Prometheus, отдельного дашборда по Битрикс-синку. Для текущего масштаба это избыточно — ошибка ловится через journalctl, а «не доехал ли лид» проверяется одним SQL-запросом:

SELECT id, name, created_atFROM public.leadsWHERE bitrix_id IS NULL  AND created_at < now() - interval '5 minutes'ORDER BY created_at DESC;

Когда проект вырастет — добавится Sentry и отдельный health-endpoint для Битрикс-клиента. Пока рано.

Что забрать из статьи

Три принципа, которые дают 90% результата:

  1. Источник правды — ваша БД, не CRM. Пишите лид в PostgreSQL сразу, bitrix_id дополняйте потом.

  2. Пользовательский ответ не должен зависеть от доступности CRM. after() в Next.js 16 — ровно для этого.

  3. Retry + таймаут + антидубль — минимальный джентльменский набор. Две попытки, 8 секунд таймаут, 10 минут окно дедупликации по ключу. Дальше — по необходимости.

Если делаете лендинг, маркетплейс, каталог с заявками — этого достаточно, чтобы не приделывать Redis с первого дня и при этом спать спокойно.


Яков Радченко. Делаю веб-продукты на Next.js. Следующая статья — про migration toolkit для переноса аккаунта Битрикс между инстансами: crm.*.list + пагинация + идемпотентность по ORIGINATOR_ID.

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