Кратко
Идемпотентность вебхуков — забота принимающей стороны, потому что сеть умеет обещать только доставку не реже одного раза. Цикл доставки в Paymos состоит из 11 попыток примерно за 16 часов, а после него событие можно отправить повторно вручную. Отсекайте повторы по evt_-идентификатору из заголовка X-Webhook-Id, а заказ привязывайте к идентификатору ресурса внутри data. Первый у каждого адреса свой, второй везде один и тот же.
Платёжный вебхук может прийти дважды. Безвредным второй приход делает только приёмник, потому что доставка по сети умеет не больше чем «не реже одного раза»: отправитель, не дождавшийся ответа за таймаут, не отличит потерянный запрос от потерянного ответа и спрашивает снова. Цикл доставки в Paymos состоит из 11 попыток примерно за 16 часов, а после него событие можно отправить повторно из дашборда. В каждой доставке едет собственный evt_… в заголовке X-Webhook-Id. У счёта внутри data идентификатор другой, и он не меняется никогда. Отсекайте повторы по первому, заказы привязывайте ко второму, и повтор обойдётся в один поиск по индексу.
Сам контракт доставки живёт в документации. Дальше о том, почему он такой формы и чего просит от кода на вашей стороне.
Почему отправитель не может пообещать ровно одну доставку?
Потому что этого не может ни одна сеть.
Две стороны, говорящие через канал, который теряет сообщения, никогда не будут уверены в состоянии друг друга. Доказано это ещё в 1975 году, и в бюджет задача не упирается. Имя, под которым задача ходит до сих пор, дал Джим Грей тремя годами позже: парадокс двух генералов.
Посмотрите, во что это превращается на одном вебхуке. Отправитель открывает запрос, пишет тело, ждёт. Десятисекундный таймаут попытки истекает, в сокете пусто. Приёмник не увидел запрос? Или проверил подпись, закрыл заказ и потерял ответ на обратном пути? Со стороны отправителя это одно и то же молчание.
Остаётся выбор из двух, и для денег безопасен только один. Отправите один раз, и оплаченный счёт иногда не доедет до системы заказов. Отправите ещё раз, и обработчик иногда отработает дважды по одному платежу. Против второго можно заложиться в коде. Против первого нечем.
Почему все шлют «не реже одного раза»?
Крипта тут ни при чём. Google документирует доставку не реже одного раза как поведение Pub/Sub по умолчанию на любом типе подписки и просит подписчиков быть идемпотентными. Stripe пишет на странице вебхуков, что адрес время от времени получает одно событие несколько раз, и советует хранить обработанные идентификаторы. Одно ограничение, один вывод.
Разной у продуктов оказывается цена дубля. Повторный пинг аналитики искривит график. Повторный invoice.paid отгрузит вторую посылку или выпустит второй лицензионный ключ, и система заказов об этом не узнает: каждый запуск выглядел правильным сам по себе.
RFC 9110 формулирует само слово точно: метод идемпотентен, когда задуманный эффект нескольких одинаковых запросов совпадает с эффектом одного. Свойство это принадлежит приёмнику и тому, что он делает с событием. Отправитель передаёт только ключ; одинаковый эффект второго раза обеспечивает тот, кто событие принял.
Чего не чинит таблица исходящих сообщений?
Дублей она не чинит, и это не изъян подхода.
Изменение состояния и сообщение о нём должны уехать вместе, а транзакции, охватывающей базу и сеть, не бывает. Записали строку первой, и падение до отправки теряет сообщение. Отправили первым — падение до записи объявляет о том, чего не было. Стандартный ответ на эту развилку и есть таблица исходящих сообщений (transactional outbox): сообщение кладут в ту же базу, в той же транзакции, что и факт, который оно описывает, а наружу его выносит отдельный обработчик.
Читайте гарантию внимательно: она куда скромнее своей репутации. Сообщение существует тогда и только тогда, когда транзакция зафиксирована. Вторую половину Крис Ричардсон перечисляет прямо среди проблем подхода: выносящий обработчик может опубликовать сообщение, умереть до того, как отметит публикацию, и опубликовать снова после перезапуска.
Размен стоит назвать вслух. Исчезнувшее сообщение не оставляет следа нигде. Повторное приходит с тем же идентификатором, что и в первый раз, а идентификатор приёмник умеет положить в индекс. Работает ли у конкретного отправителя такая таблица, снаружи не видно. До вашего адреса доезжает свойство, которое она даёт.
По какому идентификатору отсекать повтор?
В одной доставке едут два идентификатора, и взять не тот — та самая ошибка, вокруг которой построена вся схема.
X-Webhook-Id несёт то же значение, что event_id в теле, evt_…, и называет доставку: копию одного перехода для одного адреса. Каждая повторная попытка несёт его же, ручная переотправка сохраняет его тоже.
Идентификатор ресурса внутри data — это inv_…, wdr_… или pcd_…, и он называет то, с чем случились деньги. Его видят все адреса, подписанные на это событие, и он же приезжает со всеми следующими состояниями ресурса. Полный конверт разобран в справочнике по телу события.
Теперь два способа ошибиться. Мерчант с двумя адресами, который делает ключом таблицы заказов event_id, видит два разных идентификатора на один платёж и проводит его дважды. Мерчант, который отсекает доставки по идентификатору счёта, блокирует вторую доставку по этому счёту. Второй доставкой окажется invoice.paid вслед за invoice.confirming, так что заказ не исполняется вовсе.
Stripe пришёл к тому же разделению с другой стороны: хранить идентификаторы событий, чтобы ловить повторы, и брать идентификатор объекта из data.object вместе с типом события, когда одно и то же описывают два разных события.
Почему строку пишут раньше, чем делают работу?
Это правило про восстановление после падения, и окупается оно в одном узком окне. Обработчик, который сначала исполняет заказ и только потом записывает событие, открывает между этими шагами зазор. Процесс, убитый внутри зазора, оставляет отгруженный заказ без единой записи в системе, и следующая доставка отгружает его заново.
Переверните обработчик. Проверьте подпись, вставьте доставку, ответьте, а остальное отдайте фоновой задаче на расписании, которым управляете вы.
create table webhook_delivery (
event_id text primary key, -- evt_…, эта доставка
resource_id text not null, -- inv_… / wdr_… / pcd_…, платёж
event_type text not null,
body jsonb not null,
received_at timestamptz not null default now(),
processed_at timestamptz
);
insert into webhook_delivery (event_id, resource_id, event_type, body)
values ($1, $2, $3, $4)
on conflict (event_id) do nothing
returning event_id;
Ноль строк в ответе означает, что доставка уже записана: отвечайте 2xx и останавливайтесь, решать больше нечего. Одна строка означает, что работа ваша, а 2xx, который вы отправите, расписывается в получении байтов и ничего не обещает про отгрузку.
Это же разделение удерживает обработчик внутри десятисекундного таймаута попытки. Зависший вызов к чужому сервису и выпуск лицензии, который в плохой день тормозит, оба живут за очередью со своими повторами.
Что делает повторную отправку безопасной?
Повторную отправку неудавшихся событий запускает человек из дашборда, и идентификатор доставки она сохраняет. Переотправка того, что уже лежит в вашей таблице, ловится на входе, ценой одного поиска по индексу.
Интересен другой случай. Адрес, который весь сбой отвечал ошибкой, встречает исправление с пустой таблицей, поэтому каждое переотправленное событие для него новое. При этом фактическое состояние за этими событиями может быть уже верным: кто-то в поддержке посмотрел дашборд и отметил заказ оплаченным, или ночная сверка нашла платёж раньше.
Таблица доставок ничего этого не видит. Она знает, какие доставки прочитала, и больше ничего. Защита на этот случай стоит на переходе: счёт, уже проведённый как оплаченный, не проводится второй раз, и проверка живёт в той же транзакции, что и запись, которую она защищает.
Сколько событие продолжает возвращаться?
Одиннадцать попыток. Задержки растут с минуты в начале до восьми часов в конце, и последняя приходится примерно через 16 часов после первой; расписание повторов опубликовано попытка за попыткой.
Читайте это как два разных факта про ваши же простои. Выкатка проходит незамеченной: нижние ступени стоят в минутах друг от друга, и событие вернётся раньше, чем кто-нибудь откроет дашборд. Сбой базы длиной в рабочий день незамеченным не пройдёт, и события, чей цикл истёк внутри него, помечаются неудавшимися.
А вот с самим адресом не происходит ничего. Когда падает одиннадцатая попытка, неудавшимся помечается только событие; адрес не трогают, он остаётся активным и подписанным. Счётчика нарушений нет, включать обратно нечего. Приёмник, лежавший полдня, возвращается к живому адресу и списку событий, которые ждут переотправки.
Дашборд показывает состояние доставки, число попыток и время следующей для сотни последних событий, новые сверху, и дальше этой сотни не листает. Считайте его окном в последний отрезок трафика. Архив — ваша собственная таблица доставок.
Что ломается на смене секрета вебхука?
Сутки после смены заголовок подписи несёт два значения v1 вместо одного, и доставка действительна, если сошлось любое из них. В этом окне приёмник и подбирает новый секрет, на своём графике выкатки.
Приёмник, который читает v1 как поле, а не как список, заваливает каждую доставку в этом окне. Каждый провал съедает одну попытку на лестнице. Через шестнадцать часов такие события помечаются неудавшимися, и первым заметным симптомом обычно становится неотгруженный заказ.
Значит, разбирайте v1 списком, сравнивайте каждого кандидата функцией, устойчивой к атакам по времени, и принимайте на первом совпадении. Страница проверки подписи расписывает, что именно хешируется и в каком порядке. Все официальные SDK Paymos так и делают, а в наборе проверок на соответствие контракту заголовок с двумя v1 закреплён тестовым вектором, поэтому интеграция на SDK получает это поведение даром.
Ещё одна деталь на ту же тему. Допуск по времени между отметкой X-Webhook-Timestamp и моментом проверки выбираете вы сами: на исходящей доставке Paymos своего окна не навязывает.
Как доказать, что обработчик идемпотентен?
Переотправкой, а не чтением кода. Проверить надо утверждение про второй запуск, а модульный тест обычно делает только первый.
Консоль в дашборде отправляет настоящие запросы с подписью HMAC — учётными данными самого мерчанта и только в тестовом режиме, — а консоль на странице вебхуков доводит событие до реальной доставки. Этого хватает, чтобы прогнать по приёмнику на стенде всю проверку целиком:
- Отправьте доставку, дайте обработчику закончить и запомните строку заказа, которую он создал.
- Отправьте то же событие ещё раз. Оно должно дойти до вашей таблицы, найти идентификатор и вернуть
2xx, не тронув заказ. - Задержите обработку на пару минут: таймаут истечёт через десять секунд, повтор придёт ещё минутой позже и застанет первый запуск на середине. Ради этого столкновения и стоит уникальный индекс.
- Переотправьте неудавшееся событие после починки приёмника и убедитесь, что уже проведённый заказ остаётся проведённым один раз.
- Сверьте свою таблицу доставок со счетами, которые API показывает оплаченными. Недостающая строка говорит о проблеме с подпиской или сетевым доступом, двойная отгрузка — о неверном ключе.
Прогоните эти пять до выкатки приёмника, и лестница повторов станет фоновым шумом. Одиннадцатая попытка обработается так же, как первая: идентификатор уже записан, и ниже по течению ничего не двигается.
| Что приёмник берёт ключом | Повтор одной доставки | Второй адрес, один платёж | confirming, затем paid | |
|---|---|---|---|---|
| event_id для обеих задач | Отсечён | Заказ проведён дважды | Оба обработаны | |
| Идентификатор счёта для обеих задач | Отсечён | Отсечён | paid потерян | |
| event_id отсекает, счёт привязывает | Отсечён | Отсечён | Оба обработаны |
Частые вопросы
Почему один и тот же платёжный вебхук пришёл дважды?
Доставка идёт не реже одного раза. Попытка, ушедшая в таймаут, повторяется даже тогда, когда приёмник её обработал: со стороны отправителя потерянный запрос и потерянный ответ выглядят одинаково. Ручная переотправка тоже даёт повтор.
Отсекать по event_id или по идентификатору счёта?
Доставки — по event_id, это то же значение, что в заголовке X-Webhook-Id. Сам заказ — по идентификатору счёта. У двух адресов, подписанных на один платёж, event_id разные, а идентификатор счёта один и тот же во всех состояниях, через которые счёт проходит.
Что обработчику вернуть на уже обработанное событие?
2xx, сразу. Узнанный повтор — успешная доставка. Ошибка в ответе просто вернёт событие на лестницу повторов без всякой причины.
Переотправленный вебхук приходит с новым идентификатором события?
Нет. Переотправка сохраняет evt_-идентификатор той же доставки, поэтому приёмник, записавший его в первый раз, узнаёт повтор за один поиск по индексу.
Сколько повторяется неудавшийся вебхук?
11 попыток примерно за 16 часов, задержки растут с одной минуты до восьми часов. После этого событие помечается неудавшимся и его можно отправить повторно вручную. Сам адрес остаётся активным.
Когда НЕ стоит использовать вебхуки для исполнения заказов
- Если приёмник живёт только на ноутбуке или внутри частной сети, доставке некуда приезжать. Адрес, недоступный из интернета, отклоняется, а редирект внутрь не выполняется. Пока идёт разработка, опрашивайте состояние счёта через API.
- Если платежей в день единицы и их и так смотрит человек, таблица доставок, очередь и ключ отсечения — больше механики, чем окупает объём.
- Если система заказов не умеет отказать в повторном переходе, начните с этого. Ключ отсечения перед путём исполнения, который всё равно отработает дважды, сужает окно, но не закрывает его.
- Если вам нужен поток одного конкретного события, подписка его не даст. Она работает по категориям, отдельные имена внутри категории не выбираются, и адрес, подписанный на счета, получает все события по счетам. Ветвитесь в обработчике.
Источники
- 1. HTTP Semantics (RFC 9110), 9.2.2 Idempotent Methods (accessed 2026-09-15)
- 2. Google Cloud Pub/Sub — Subscription overview (at-least-once delivery) (accessed 2026-09-15)
- 3. Stripe — Receive Stripe events in your webhook endpoint (accessed 2026-09-15)
- 4. Chris Richardson — Pattern: Transactional outbox (accessed 2026-09-15)
- 5. Документация Paymos — доставка и повторы (accessed 2026-09-15)
Последняя проверка: 15 сент. 2026 г.


