О чём это
Типовая задача на российском рынке: есть публичный сайт (лендинг, маркетплейс, каталог), на нём формы — заявка, регистрация, заявка партнёра. Эти лиды должны попадать в 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 });}
Что здесь критично:
-
Источник правды — наша БД. Лид сохранён в
public.leadsдо того, как мы вообще полезли в Битрикс. Если Битрикс лежит — у нас в базе всё равно есть заявка, с ней можно работать руками. -
Антидубль не на стороне CRM, а на стороне нашей БД. Окно 10 минут по телефону. Битрикс, к слову, сам по себе дубли принимает молча (если не настраивать на нём отдельную логику) — лучше не полагаться на него.
-
bitrix_idв таблице лидов. Это наша «связующая нить» — внешний ID лида в Битриксе.NULLозначает «в CRM ещё не улетел». Колонка не обязательная, и это осознанно: лид безbitrix_id— нормальное, валидное состояние на первые секунды жизни записи. -
Ошибка отправки в Битрикс не падает наружу. Логируется в
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());
Три наблюдения:
-
bitrix_id—text, неinteger. Битрикс возвращает ID как строку в JSON. Можно хранить как число, но зачем тратить силы на приведение типа, который используется только для обратной ссылки. -
bitrix_idnullable. Это явное признание того, что отправка в CRM — асинхронная и может не произойти. NULL означает «либо ещё не улетело, либо улетело, но с ошибкой». Разделять эти два состояния на уровне БД я не стал — пока не понадобилось. -
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% результата:
-
Источник правды — ваша БД, не CRM. Пишите лид в PostgreSQL сразу,
bitrix_idдополняйте потом. -
Пользовательский ответ не должен зависеть от доступности CRM.
after()в Next.js 16 — ровно для этого. -
Retry + таймаут + антидубль — минимальный джентльменский набор. Две попытки, 8 секунд таймаут, 10 минут окно дедупликации по ключу. Дальше — по необходимости.
Если делаете лендинг, маркетплейс, каталог с заявками — этого достаточно, чтобы не приделывать Redis с первого дня и при этом спать спокойно.
Яков Радченко. Делаю веб-продукты на Next.js. Следующая статья — про migration toolkit для переноса аккаунта Битрикс между инстансами: crm.*.list + пагинация + идемпотентность по ORIGINATOR_ID.
ссылка на оригинал статьи https://habr.com/ru/articles/1025026/