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

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

Конкретика здесь относится к Shopify: названия заголовков, способ подписи и правила повторов взяты из его документации. Если у вас другая CMS, механизмы надо сверять с её собственным описанием — принцип разделения останется, а детали могут отличаться.

Сначала убедиться, что письмо не подложное

Адрес приёмника публичный, по определению. Любой может отправить на него JSON, очень похожий на заказ. Поэтому первое, что делает приёмник, — проверяет подпись. Для HTTPS-доставки Shopify кладёт подпись в заголовок X-Shopify-Hmac-SHA256, а проверка строится на сыром теле запроса и секрете приложения (документация Shopify по проверке доставок). Вы считаете HMAC-SHA256 от тех же байтов, которые пришли, кодируете результат так, как принято в этом заголовке, и сравниваете со значением из запроса.

Главная ловушка — слово «сырое». Типичный веб-фреймворк любезно разбирает JSON и отдаёт вам готовый объект. Если потом сериализовать его обратно, порядок ключей, пробелы, экранирование символов и формат чисел могут отличаться от оригинала. Для человека это тот же заказ, для хеш-функции — совсем другие байты, и подпись не сойдётся. Бывает и обратная неприятность: промежуточный слой перекодировал тело, и вы проверяете не то, что отправил магазин. Поэтому в приёмнике нужно получить тело как последовательность байтов до любого разбора, посчитать подпись, и только потом превращать байты в структуру.

Разберём это на условном примере. Магазин отправил тело {"id":1001,"total":"10.50"}. Фреймворк разобрал его, а при обратной сериализации написал {"id": 1001, "total": "10.50"}: добавился пробел после двоеточия. Для приёмника это тот же заказ, но HMAC-SHA256 от этих двух строк даст разные значения, и настоящий запрос будет отвергнут как подложный. Хуже того, такая ошибка проявляется выборочно: на коротких заказах без спецсимволов подпись может сходиться, а на заказе с кириллицей в адресе или необычным числом ломаться. Поэтому в тестах приёмника стоит держать хотя бы один пример с нестандартными пробелами, экранированными символами и юникодом в теле. Если проверка проходит на нём, значит, байты действительно не трогают до расчёта подписи.

Практическое следствие для устройства сервиса: обработчик подписи должен стоять в самом начале цепочки и получать тело раньше любого промежуточного слоя, который умеет разбирать JSON. Именно эти же байты потом идут в колонку raw_body, так что одна копия тела служит и для проверки, и для хранения (документация Shopify по проверке доставок).

Порядок действий в обработчике выглядит так:

  1. Прочитать тело целиком как байты и ничего с ним не делать.
  2. Взять секрет приложения из окружения процесса, а не из репозитория.
  3. Посчитать HMAC-SHA256 от байтов тела с этим секретом.
  4. Сравнить с X-Shopify-Hmac-SHA256 функцией сравнения за постоянное время — обычный оператор равенства останавливается на первом отличии и в теории выдаёт информацию по времени ответа.
  5. При несовпадении вернуть отказ (например, 401) и не записывать ничего в очередь.

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

Запись раньше ответа

Подпись проверена — событие подлинное. Теперь самое важное решение во всей схеме: когда говорить магазину «принято». Ответ 200 для отправителя означает «можешь забыть об этом событии». Значит, к моменту ответа событие уже должно существовать где-то, что переживёт перезапуск процесса, нехватку памяти и внезапную перезагрузку машины. Очередь только в оперативной памяти процесса этому условию не удовлетворяет.

Вот короткая временная линия правильного порядка:

  1. Пришёл запрос, подпись сошлась.
  2. Из заголовка X-Shopify-Webhook-Id взят идентификатор доставки.
  3. Строка с идентификатором, темой события, сырым телом и временем получения записана в постоянное хранилище, и запись подтверждена (транзакция зафиксирована).
  4. Только теперь магазину отправлен ответ 200.
  5. Позже, независимо от HTTP-запроса, worker берёт запись и начинает работать с CRM.

Если процесс умрёт между шагами 2 и 3, ответа не было, и отправитель повторит доставку. Если умрёт между 3 и 4, событие уже записано, а магазин повторит доставку, потому что не получил подтверждения; запись при этом встретит свой же идентификатор (о чём ниже). Если же поменять местами шаги 3 и 4, появляется самая неприятная ситуация: магазин считает событие доставленным, а у вас его нет. Именно этот сбой не оставляет следов, и о нём вы узнаете от недовольного менеджера, а не из логов.

Удобно прочитать эту временную линию как таблицу сбоев: в каждой точке отказа есть своё наблюдаемое последствие.

Что происходит при падении процесса в разных точках
Где оборвалосьЧто знает магазинЧто есть у васИтог
До записи в базуОтвета 200 нетНичегоМагазин повторит доставку, потери нет
После записи, до ответаОтвета 200 нетСтрока в таблицеПовтор встретит тот же X-Shopify-Webhook-Id и не создаст вторую строку
После ответаСобытие доставленоСтрока в таблицеДальше работает worker, HTTP уже ни при чём
Ответ раньше записиСобытие доставленоНичегоСобытие потеряно, и отправитель его не повторит

Первые три строки безопасны по построению, и только последняя требует ошибки в порядке действий. Поэтому в коде обработчика стоит проверять не «успели ли мы ответить», а «вернулась ли вставка без исключения», и ответ 200 формировать строго после этого. При сбое самой записи (база недоступна, закончилось место) правильно вернуть ошибку сервера: магазин получит отказ и попробует снова, пока у него остаются попытки.

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

Таблица событий и уникальный ключ

Идентификатор доставки полезен только тогда, когда база сама не позволит записать его дважды. Это решается ограничением уникальности: в PostgreSQL PRIMARY KEY задаёт уникальность и запрещает NULL, а UNIQUE можно навесить на несколько столбцов сразу (документация PostgreSQL об ограничениях). Там же важная мелочь: пустая строка — не NULL. Значит, если приёмник по ошибке запишет вместо отсутствующего идентификатора пустую строку, ограничение от NULL не сработает, а две такие записи будут конфликтовать между собой или, наоборот, спрячут разные события за одним «пустым» ключом. Приёмник должен отвергать запрос без X-Shopify-Webhook-Id до записи, а не подставлять заглушку.

Минимальная структура такой таблицы:

CREATE TABLE webhook_events (
  webhook_id  text PRIMARY KEY,
  topic       text NOT NULL,
  raw_body    bytea NOT NULL,
  received_at timestamptz NOT NULL DEFAULT now(),
  status      text NOT NULL DEFAULT 'new',
  attempts    integer NOT NULL DEFAULT 0,
  next_try_at timestamptz NOT NULL DEFAULT now()
);

Тело хранится сырым: если логика разбора потом изменится или найдётся ошибка, событие можно прогнать заново, не обращаясь к магазину. Поля status, attempts и next_try_at нужны worker-у, и о них речь ниже.

Вставка делается в режиме «если такой ключ уже есть — ничего не делать». Тогда повторная доставка того же события не создаёт второй строки и не превращается в ошибку: приёмник просто отвечает 200, ведь событие у него уже хранится.

Быстрый ответ и медленная работа

Теперь понятно, почему приёмник не звонит в CRM. Его ответ должен определяться только тем, что находится у него под рукой: подпись, запись в свою базу. Это свойства вашего сервера, и их можно сделать устойчивыми. Доступность CRM — свойство чужой системы, и привязывать к ней HTTP-ответ значит передавать ей управление вашей приёмкой. Документация Shopify как раз рекомендует при долгой обработке использовать постоянную очередь, а не делать работу внутри запроса.

Worker устроен скучно, и это достоинство. Он в цикле выбирает записи со статусом «новое» и наступившим временем следующей попытки, берёт одну в работу так, чтобы два worker-а не схватили одну и ту же (в PostgreSQL для этого подходит выборка с блокировкой строк и пропуском уже занятых), разбирает тело, вызывает CRM и по результату меняет статус. Успех — статус «выполнено». Ошибка, похожая на временную, — увеличить счётчик попыток и отодвинуть next_try_at. Ошибка, которая повторением не лечится (скажем, CRM отвергает данные как некорректные), — статус «требует разбора», чтобы запись не крутилась вечно и была видна человеку.

Паузу между попытками разумно наращивать: минута, пять, пятнадцать, час. Условный расчёт: если CRM лежит три часа, а поток — шесть событий в минуту, к её возвращению в таблице накопится около 1080 записей. Это ничтожный объём для базы — тело заказа занимает считанные килобайты, — но важна не память, а то, что после восстановления эти 1080 вызовов нельзя обрушить на CRM одновременно. Ограничьте число параллельных исполнителей и скорость запросов, иначе только что поднявшаяся система будет снова положена вашей же «помощью». Цифры здесь иллюстрация: реальный поток вашего магазина нужно взять из его собственной статистики.

Есть вопрос порядка. События об одном заказе могут прийти не в том порядке, в каком происходили, а при повторах и параллельной обработке порядок тем более не гарантирован. Поэтому исполнитель не должен слепо применять «изменилось поле X» — надёжнее, когда обработка событий заказа допускает перестановку: например, worker сравнивает отметку времени обновления заказа из тела с тем, что уже записано в CRM, и не откатывает данные назад. Это решение вашей интеграции, документация магазина за вас его не принимает.

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

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

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

Когда магазин присылает одно и то же дважды

Повторы — не сбой, а часть договора. Shopify допускает повторные доставки, и именно для их распознавания служит X-Shopify-Webhook-Id. Причины повтора бывают разные: ваш ответ не дошёл из-за обрыва сети, вы ответили слишком поздно, или отправитель сам продублировал доставку. Для вас они неразличимы, и не нужно в них разбираться — достаточно, чтобы повторная обработка была безвредной.

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

Разберём это на условном сценарии с хронологией. Worker берёт запись заказа 1001, вызывает CRM, та создаёт сделку и начинает отвечать, но процесс worker-а убивают до того, как он записал статус «выполнено». В таблице запись по-прежнему «в работе». После перезапуска другой worker подбирает её и снова обращается к CRM. Если в первый раз сделка была создана с внешним номером «shop-1001», то второй вызов либо получит отказ как дубль, либо найдёт существующую сделку поиском по этому номеру, и оба варианта безвредны. Если же при каждой попытке генерировался новый случайный ключ, в CRM появятся две сделки, и ни одна из защит на входе этого не заметит.

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

Цена игнорирования этой тонкости хорошо видна на примере соседней темы: в материале о повторной доставке сообщений, из-за которой деньги списались дважды разбирается, к чему приводит обработка без защиты от повторов. В нашем случае вместо двойного списания будет двойная сделка или двойное письмо клиенту, но механика та же.

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

Отправитель не будет ждать вечно

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

Из этого следуют практические выводы для приёмника:

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

Здесь же проявляется смысл отдельного VPS. Приёмник и очередь маленькие, но им нужна своя среда, где чужие нагрузки не отнимают память и диск. Для начала подойдёт небольшая конфигурация: у MAATRIX это, например, тариф Ba (1 vCPU, 2 ГБ RAM, 40 ГБ NVMe) или Uab (2 vCPU, 4 ГБ, 80 ГБ). На машине с root-доступом вы сами ставите обратный прокси с сертификатом, PostgreSQL и процесс worker-а. Сколько событий в секунду выдержит конкретная конфигурация, зависит от вашей нагрузки и настроек, поэтому указанные ресурсы — не обещание ёмкости, а отправная точка, которую стоит проверить нагрузочной пробой на вашем потоке. Обратите внимание: один VPS — это одна точка отказа, и размещение второго сервера рядом само по себе не создаёт отказоустойчивость приложения; её нужно проектировать отдельно. Для автоматизации самой интеграции в каталоге MAATRIX также упоминается установка n8n, но наличие образа и ОС проверяйте в форме заказа.

Поток событий не равен правде

Допустим, всё сделано: подписи проверяются, события пишутся, worker повторяет, повторы обезврежены. Остаётся неприятный факт: вебхук — это уведомление, а не журнал истины. Событие могло не прийти вовсе: приёмник был недоступен дольше, чем отправитель повторял; в настройках приложения была снята подписка; из-за ошибки вы пометили запись «выполнено», хотя в CRM ничего не создалось. Ни один из этих сбоев не оставляет в очереди следа, по которому его можно найти.

Поэтому документация Shopify рекомендует периодическую сверку через API, и это не формальность. Идея простая: раз в какой-то период worker (или отдельное задание) запрашивает у магазина заказы, изменённые с момента последней успешной сверки, и сравнивает с тем, что есть в CRM. Расхождения превращаются в обычные записи очереди — «синтетические» события, которые обрабатываются тем же путём. Так у вас один маршрут исполнения вместо двух.

Рассмотрим, как сверка закрывает конкретный пропуск. Допустим, приёмник был недоступен из-за истёкшего сертификата, и магазин исчерпал попытки для нескольких событий об изменении заказов. Документация Shopify прямо оговаривает, что неуспешные доставки повторяются, но не бесконечно, поэтому такие события к вам уже не вернутся (документация Shopify). В очереди об этом нет ни строки, а потому проверять нечего. Сверка видит расхождение с другой стороны: она запрашивает у API заказы, обновлённые после последней точки, и находит, что заказ 1001 в магазине обновлялся в 11:40, а в CRM остался в состоянии 10:05.

Для найденного расхождения создаётся запись в той же таблице с составным идентификатором вроде «reconcile-1001-время обновления», и дальше она идёт обычным маршрутом: worker, поиск по внешнему номеру, обновление с проверкой отметки времени. Так восстановленное событие не отличается от пришедшего через вебхук ни по обработке, ни по защите от повторов. Для разбора причин имеет смысл хранить в записи пометку о происхождении, чтобы отчёт о расхождениях мог отделить пропуски, найденные сверкой, от обычного потока.

Несколько соображений, как сделать сверку полезной:

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

Как это выглядит в день аварии CRM

Соберём картину на условном примере. В 10:00 CRM уходит на обновление. Магазин продолжает слать события, приёмник проверяет подписи, пишет строки в webhook_events и отвечает 200. Worker пытается вызвать CRM, получает ошибки соединения, увеличивает счётчик и откладывает следующую попытку. Менеджеры не видят новых заказов, но ни одно событие не потеряно: они лежат в таблице в статусе «новое».

В 13:00 CRM возвращается. Ближайшая попытка worker-а проходит успешно, и он начинает разбирать накопленное. Лимит параллелизма удерживает нагрузку в рамках, события обрабатываются от старых к новым, повторы доставок отсеиваются ключом, а дубли действий в CRM — внешним номером заказа. Через некоторое время запрос SELECT count(*) FROM webhook_events WHERE status = 'new' показывает ноль. Затем ночная сверка через API сравнивает заказы магазина с CRM и либо ничего не находит, либо добавляет в очередь несколько пропущенных записей.

Никакого героизма не потребовалось: никто не перепосылал события из админки, не выгружал заказы вручную в таблицу. Очередь пережила недоступность CRM именно потому, что приёмник ничего не обещал за неё. Он принял то, что мог гарантировать — подлинность и сохранность, — а остальное оставил исполнителю, у которого есть время, повторы и память о том, на чём остановился.