О чём это

Типовая задача на российском рынке: есть публичный сайт (лендинг, маркетплейс, каталог), на нём формы — заявка, регистрация, заявка партнёра. Эти лиды должны попадать в 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.ts
import { 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.ts
const 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_at
FROM public.leads
WHERE 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.

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