Положим рядом две формы. На сайте интернет-магазина поле называется телефон, на сайте сервисного центра то же самое лежит в поле contact, причём иногда там телефон, а иногда адрес почты. Названия разные, структура разная, но из этого ещё не следует, что заявки разные. Верно и обратное: два одинаково выглядящих запроса с одним номером могут быть обращениями двух людей, которые звонят с рабочего телефона компании. Задача интеграции в том, чтобы научиться отличать эти ситуации, а не в том, чтобы просто перекачать JSON из точки А в точку Б.

Ниже разберём, как провести заявки нескольких сайтов через n8n так, чтобы в CRM появлялась одна понятная запись, а всё, что по дороге пошло не так, оставалось видимым и пригодным для повторной обработки.

Источник заявки и её личность — это разные вещи

Первая ошибка при объединении потоков состоит в том, что источник используют как идентификатор. «Это пришло с сайта B, значит, это заявка B». Но сайт — лишь канал доставки. У заявки есть как минимум три независимых понятия:

  • источник: с какого сайта и какой формы пришло событие;
  • попытка доставки: конкретный HTTP-запрос, который мог прийти один раз, два или пять (браузер повторил отправку, внешняя система переслала по таймауту);
  • бизнес-обращение: то, что менеджер увидит как одну задачу, «человек хочет расчёт стоимости».

Если смешать эти уровни, получатся либо дубли (одна доставка превратилась в две карточки), либо потери (два разных обращения слились в одно, потому что совпал телефон). Поэтому в рабочей схеме у каждой записи будет явный составной ключ: идентификатор сайта плюс идентификатор события на стороне сайта. Если форма сама не присылает номер отправки, его придётся добавить: либо скрытым полем при рендере страницы, либо на стороне n8n как отпечаток от стабильных частей запроса. Второй вариант слабее, об этом ниже.

Обратите внимание: ключ события не равен телефону клиента. Телефон описывает человека (и то неточно), ключ описывает факт доставки. Это принципиально разные вопросы: «видели ли мы уже это событие?» и «знаем ли мы уже этого человека?».

Карта двух форм и общей записи

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

Соответствие полей двух форм общей записи
Общая записьСайт «магазин»Сайт «сервис»Правило
site_idзадаётся в workflow: shopзадаётся в workflow: serviceне берётся из тела запроса
event_idorder_uuidrequest_idобязателен, иначе заявка уходит в карантин
nameимяfioобрезать пробелы, пустое — как отсутствующее имя
phoneтелефончасть contactпривести к цифрам, хранить и исходное значение
emailнеобязательное поле emailчасть contactопределить по наличию знака @
messageкомментарийproblemпустая строка допустима
rawвесь исходный JSONвесь исходный JSONсохраняется без изменений

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

Пустая строка и отсутствие значения — не одно и то же

В форме магазина поле «комментарий» необязательное. Что придёт в запросе, если посетитель его не заполнил? Зависит от формы: иногда ключ вообще отсутствует, иногда приходит "комментарий": "", иногда null. Для человека это одно и то же, для кода — три разных состояния, и они означают разное:

  • ключа нет — форма его не отправила; возможно, поле на этой странице вообще не показывали или версия формы старая;
  • пустая строка — поле было, человек ничего не ввёл;
  • null — клиентский код явно сообщил, что значения нет.

Та же разница существует и в базе. Документация PostgreSQL отдельно подчёркивает, что пустая строка не тождественна NULL (PostgreSQL: Constraints). Практический вывод: правило нужно записать один раз и применять к обоим сайтам одинаково. Например: для обязательных полей (event_id, телефон или почта) пустая строка после обрезки пробелов считается отсутствием, и заявка не проходит дальше; для необязательных пустая строка сохраняется как пустая, а отсутствующий ключ — как NULL, чтобы потом можно было отличить «не спрашивали» от «не ответили».

Типичная ловушка: условие вида «если поле заполнено» в узле IF проходит и для отсутствующего ключа, и для пустой строки одинаково, и вы теряете различие, которое, возможно, понадобилось бы через полгода при анализе качества форм. Поэтому нормализацию лучше делать одним узлом Code или Edit Fields в начале, с явными ветками, а не размазывать проверки по всему workflow.

Поле, в которое пишут что попало

Поле contact на сайте сервиса — отдельная история. Это единственный ввод, куда клиент вписывает и телефон, и почту. Разбирать его нужно осторожно: если есть знак @, это почта; если после удаления пробелов, скобок и дефисов остаются 10–11 цифр, это телефон; всё прочее — не угадываем, оставляем как есть в raw и помечаем запись признаком «контакт не распознан». Заявка при этом не отбрасывается: менеджер может прочитать текст и позвонить сам. Лучше принять неясную заявку и показать её человеку, чем молча выбросить из-за строгой проверки.

Две заявки с одним телефоном: дубль или человек вернулся?

Теперь главный вопрос объединения. Приходят два события с одним номером. Три сценария выглядят снаружи одинаково:

  1. Это одна и та же отправка, доставленная дважды (повтор запроса). Нужна одна запись.
  2. Человек действительно отправил форму снова через час, потому что не получил ответа. Это новое обращение по сути, но, возможно, не новая карточка клиента.
  3. Два разных человека вписали общий номер организации. Склеивать их нельзя.

Различает их не телефон, а ключ события. Если site_id и event_id совпали, это сценарий 1: тот же запрос. Если не совпали, это два отдельных обращения, и решать, объединять ли их в карточку клиента, должно бизнес-правило, а не техническое совпадение полей. Например: «обращения с одним нормализованным телефоном за последние 30 дней привязываются к одному контакту, но каждое остаётся отдельной сделкой», или наоборот, «создаётся новая сделка только если предыдущая закрыта». Какое правило выбрать, зависит от вашего процесса продаж, и это решение для руководителя отдела, а не для автоматизации. Автоматизация лишь исполняет выбранное правило одинаково для всех сайтов.

Отсюда и следует разделение на два уровня уникальности:

  • уникальность доставки: пара (site_id, event_id) встречается в журнале один раз;
  • связь с контактом: правило привязки по телефону или почте, которое можно менять, не трогая журнал доставок.

В PostgreSQL первый уровень выражается ограничением UNIQUE по нескольким столбцам; документация описывает, что UNIQUE может охватывать несколько столбцов, а PRIMARY KEY добавляет к уникальности запрет NULL (PostgreSQL: Constraints). Для нашего журнала удобно сделать составной первичный ключ: тогда запись без event_id просто не сможет попасть в таблицу, а ошибка станет явной.

Почему «сначала проверить, потом записать» не работает

Соблазнительная схема: узел запроса в базу «есть ли уже такое событие?», затем IF, затем вставка. Две одновременные доставки одного события (а повторы именно так и приходят, почти одновременно) обе увидят «нет» и обе пойдут дальше. Проверка и запись должны быть одним действием на стороне базы: вставка с обработкой конфликта по уникальному ключу. Тот, кто вставил строку, продолжает обработку; тот, кто получил конфликт, завершает работу, признав повтор. Это называется атомарным захватом события, и обеспечивает его именно ограничение уникальности в базе, а не логика ветвлений в workflow.

Важная оговорка: такой журнал фиксирует, что мы приняли событие, но не гарантирует создание записи в CRM. Это два разных действия в двух разных системах, общей транзакции между вашей базой и внешним API у вас нет. Поэтому журнал должен хранить состояние обработки, а не только факт получения, и об этом следующий раздел.

Нужен сервер под эту задачу?

Разверните VPS MAATRIX за пару минут: NVMe, AMD EPYC, root-доступ, локации Россия, США, Франция и Великобритания. Оплата картой РФ и по СБП.

АРЕНДОВАТЬ СЕРВЕР

Принять, записать судьбу, и только потом разговаривать с CRM

Порядок действий в workflow определяет, что вы увидите, когда что-то сломается. Разумная последовательность такая:

  1. Webhook принимает запрос.
  2. Нормализация и проверка обязательных полей.
  3. Вставка в журнал со статусом «принято» (с захватом по ключу).
  4. Вызов CRM.
  5. Обновление статуса: «создано» с идентификатором записи CRM либо «ошибка» с текстом причины.

Ключевая мысль: статус «принято» появляется до обращения к CRM. Если CRM недоступна, отвечает ошибкой или процесс остановили посреди пути, в журнале всё равно остаётся строка с оригинальными полями. Заявка не растворилась, она просто застряла на известном этапе.

Адрес, на который смотрят сайты

Узел Webhook в n8n имеет два адреса: тестовый и производственный. Документация описывает, что test и production URL различаются, а production-адрес регистрируется при публикации workflow (n8n: Webhook). Для нескольких сайтов это означает простую дисциплину: в формы боевых сайтов прописывается только production-адрес, а тестовый используется для отладки на примере вручную отправленной заявки. Если в продакшен-форму случайно попал тестовый адрес, заявки будут приниматься лишь пока вы смотрите на открытый редактор, а после этого пропадать. Для трёх сайтов разумно завести три отдельных Webhook-узла с разными путями, так что site_id определяется по пути, а не по доверенному полю в теле: клиентскую часть сайта проще подделать или перепутать, чем путь, который вы сами назначили.

Заодно стоит вернуть сайту быстрый ответ, не дожидаясь CRM: принятое событие уже записано, и форма может показать посетителю «заявка принята». Долгое ожидание ответа внешней системы в запросе браузера только создаёт условия для повторных отправок, то есть для тех самых дублей.

Ключ события, которого нет

Что делать, если один из старых сайтов не присылает никакого идентификатора? Вариантов два. Правильный: добавить скрытое поле с уникальным значением, генерируемым при показе формы, чтобы повторная отправка той же страницы несла тот же ключ. Запасной: вычислять отпечаток из набора полей и временного окна. Запасной вариант плох тем, что при нём настоящее повторное обращение с теми же данными в пределах окна будет принято за повтор доставки и тихо отброшено. Если вы всё же идёте этим путём, отброшенные события стоит не удалять, а записывать со статусом «повтор», чтобы их можно было просмотреть.

Заметим, что новый случайный идентификатор, который workflow сам создаёт на каждый запрос, ничего не решает: при повторной доставке он будет другим, и два запроса окажутся разными событиями. Ключ должен приходить вместе с событием или вычисляться из его неизменных частей, а не рождаться заново при каждой попытке.

Когда потоки сходятся: роль Merge

Если сайтов несколько, возникает искушение завести один общий workflow с узлом Merge, к которому подходят все ветки. Здесь нужна аккуратность. Merge в n8n объединяет потоки по выбранному режиму, в том числе по совпадению полей, и позволяет работать отдельно с совпавшими, несовпавшими и всеми записями (n8n: Merge). Это инструмент для сопоставления двух наборов данных, например, пришедшей заявки и списка контактов. Он не нужен для того, чтобы просто «собрать три входа в один выход»: для этого достаточно, чтобы каждая ветка после нормализации приводила данные к одной форме и вела в общий блок дальше.

Если вы хотите использовать совпадение по полям, например, найти в выгрузке контактов существующий телефон, то стоит помнить, что результат зависит от выбранного режима: совпавшие записи пойдут в одну сторону, несовпавшие в другую. В нашем случае несовпадение означает «новый контакт», а совпадение «возможный существующий», и вторая ветка проходит через бизнес-правило из предыдущего раздела, а не склеивается автоматически. Конкретный набор режимов и параметров лучше сверить в вашей версии n8n, так как интерфейс узла менялся между версиями.

Практичнее всего получается такая архитектура: на каждый сайт свой короткий входной workflow или ветка (приём, нормализация, запись в журнал), а общая часть (поиск контакта, создание сделки, обновление статуса) выделена отдельно и принимает уже единую запись. Расхождения между сайтами остаются локализованы в одном месте, а добавление четвёртого сайта означает ещё одну короткую ветку с собственной картой полей. О продолжении такой цепочки, когда заявка идёт дальше в уведомление менеджеру, подробнее рассказывает материал заявка с сайта, CRM и Telegram на n8n.

Заявка, которая не дошла: сделать её видимой

Сбой неизбежен: CRM вернёт отказ из-за обязательного поля, токен истечёт, сеть моргнёт. Вопрос не в том, случится ли это, а в том, что вы увидите после. Журнал со статусами превращает «потеряно» в наблюдаемое состояние. Достаточно небольшой таблицы:

Поля журнала заявок
ПолеНазначение
site_id, event_idсоставной первичный ключ, захват события
rawисходное тело запроса без изменений
normalizedрезультат приведения к общей записи
statusпринято, отклонено проверкой, отправлено в CRM, создано, ошибка, повтор
crm_idидентификатор записи в CRM, пока пуст, если не создана
last_errorтекст последней ошибки
received_at, updated_atвремя приёма и последнего изменения статуса

Обратите внимание, что crm_id намеренно может быть пустым: это не ошибка схемы, а честное состояние «записи в CRM ещё нет». Пустой crm_id при статусе «принято» дольше нескольких минут и есть признак зависшей заявки.

Теперь можно построить отдельный небольшой workflow по расписанию, который выбирает из журнала строки со статусами «принято», «отправлено в CRM» или «ошибка», где время последнего изменения старше выбранного порога. Что делать с найденным, решаете вы: отправить уведомление в рабочий чат, повторить создание записи или отложить для ручного разбора. При повторе опасность та же, что и при доставке: CRM могла создать запись, но не успеть ответить. Если ваша CRM позволяет передать внешний идентификатор или найти запись по нему, используйте site_id и event_id именно так, чтобы повтор сначала искал, а потом создавал. Если такой возможности нет или вы не уверены в ней для своей CRM, лучше не повторять автоматически, а показывать менеджеру пару «заявка из журнала, возможно, уже есть в CRM» для ручной сверки. Возможности конкретной CRM различаются, поэтому здесь стоит свериться с её документацией, а не переносить допущение с одной системы на другую.

Отклонённое тоже надо хранить

Заявки, не прошедшие проверку обязательных полей, не должны исчезать. Статус «отклонено проверкой» вместе с оригиналом позволяет через неделю заметить закономерность: например, что у одного сайта обновили форму, и поле event_id перестало приходить. Без этой записи вы бы увидели лишь «странно мало заявок с сайта B», без объяснения причины.

Где всё это живёт

Для описанной схемы нужны три вещи: сам n8n, база для журнала и стабильный публичный адрес, доступный сайтам. Самостоятельно разворачиваемый n8n на VPS даёт удобную базу для такого решения: вы контролируете, где лежит журнал, и не зависите от лимитов чужого тарифа на число запусков. В каталоге MAATRIX предложены n8n и другие приложения с установкой, но точное наличие образа и операционной системы нужно проверять в форме заказа. Серверы построены на NVMe и процессорах AMD EPYC, доступен root-доступ, а линейка тарифов начинается с Ba (1 vCPU, 2 ГБ RAM, 40 ГБ NVMe) и идёт до Djed (16 vCPU, 32 ГБ, 320 ГБ). Для потока заявок обычных сайтов нагрузка, как правило, невелика, но ресурсы тарифа не равны обещанию ёмкости: подбирайте их по собственному наблюдению за длительностью выполнения workflow и размером журнала. Журнал можно хранить в той же базе, которую использует n8n, но отдельная база или хотя бы отдельная схема облегчает чистку и выгрузку.

Резервное копирование журнала стоит продумать самостоятельно: автоматические копии на всех тарифах публично не заявлены, так что регулярную выгрузку таблицы статусов настраивают отдельно. Эта таблица содержит оригиналы заявок, то есть персональные данные, поэтому доступ к ней нужно ограничить так же строго, как к самой CRM.

Один вопрос вместо трёх поисков

Если схема собрана целиком, то на вопрос менеджера «а заявка от Иванова с сервисного сайта дошла?» вы отвечаете одним запросом к журналу. Находите строку по телефону или по времени и видите путь: принята в 14:02, нормализована, отправлена в CRM, ответ получен с ошибкой в обязательном поле, повтор назначен. Даже если карточки в CRM ещё нет, заявка существует, видна и привязана к конкретному сайту и событию. Поэтому «пропала» превращается в «застряла на таком-то шаге», а такое состояние уже можно исправить. Два сайта с разными названиями полей перестают быть двумя разными мирами: у каждой заявки есть источник, ключ события, нормализованная запись и оригинал, а путь от формы до CRM читается так же легко, как строка таблицы.