Как корректно описать приём и обработку webhook

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

Представим обычную интеграцию с платёжным сервисом. После успешной оплаты он отправляет нашему приложению webhook payment.succeeded. Webhook здесь представляет собой HTTP‑запрос, которым одна система сообщает другой о произошедшем событии.

Системный аналитик описал обработку так:

При получении payment.succeeded система должна:

1. Найти заказ по paymentId.
2. Перевести заказ в статус PAID.
3. Начислить пользователю 500 бонусов.
4. Отправить письмо об успешной оплате.
5. Вернуть HTTP 200.

Разработчик реализовал требования буквально. На тестах всё работает.

Через несколько недель появляется инцидент:

10:00:00  получен payment.succeeded, eventId = evt_123
10:00:01  заказ переведён в PAID
10:00:01  начислено 500 бонусов
10:00:02  письмо отправлено
10:00:03  соединение разорвано

10:01:00  снова получен payment.succeeded, eventId = evt_123
10:01:01  заказ снова переведён в PAID
10:01:01  начислено ещё 500 бонусов
10:01:02  отправлено второе письмо

Платёжный сервис утверждает, что работает штатно. И он вполне может быть прав.

Многие webhook‑провайдеры повторяют доставку, если не получили ожидаемого подтверждения. Например, ЮKassa считает уведомление подтверждённым только после HTTP 200 и при любом другом ответе продолжает попытки доставки в течение 24 часов. Stripe в production повторяет неуспешную доставку до трёх дней. При этом универсального правила нет: GitHub вообще не выполняет автоматическую повторную доставку failed webhook, её нужно инициировать отдельно.

Получается, проблема находится не столько в коде, сколько в требованиях. Они описывают happy path одного запроса, но ничего не говорят о свойствах самой доставки.

Первая очевидная проблема: обработка должна быть идемпотентной

Самое очевидное исправление выглядит так:

Если eventId уже был обработан, повторно ничего не делать.

И это действительно необходимо. Stripe прямо рекомендует хранить идентификаторы обработанных событий и отбрасывать повторно полученные. Twilio Event Streams также предупреждает о дубликатах при модели at‑least‑once и рекомендует дедупликацию по id.

Но на этом хорошая спецификация не заканчивается.

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

Два HTTP‑запроса могут содержать одинаковый eventId. Тогда перед нами явно повторная доставка одного события:

evt_123   payment.succeeded   payment_456
evt_123   payment.succeeded   payment_456

Но возможна и другая ситуация:

evt_123   payment.succeeded   payment_456
evt_987   payment.succeeded   payment_456

Это уже два разных события провайдера об одном и том же бизнес‑объекте. Stripe отдельно предупреждает, что иногда могут быть созданы два отдельных объекта Event, и в таких случаях рекомендует дополнительно учитывать идентификатор объекта и тип события.

Поэтому аналитику недостаточно написать «исключить дубликаты». Нужно определить уровень идемпотентности.

Например, для начисления бонусов реальная защита может выглядеть не как «один раз обработать eventId», а как более сильное бизнес‑правило:

За один paymentId бонус за успешную оплату
может быть начислен не более одного раза.

Тогда даже два разных события не приведут к двойному начислению.

Здесь проявляется ещё одна тонкость. Операция сама по себе практически идемпотентна. Десять повторных присваиваний дадут тот же статус.

status = PAID

А операция

bonus = bonus + 500

нет.

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

Даже проверка eventId может не спасти

Допустим, разработчик реализовал требования так:

1. Проверить, есть ли eventId в таблице processed_events.
2. Если есть, вернуть 200.
3. Если нет, выполнить бизнес-логику.
4. Записать eventId в processed_events.

На первый взгляд проблема решена.

Теперь два одинаковых webhook приходят почти одновременно.

Request A                  Request B

SELECT evt_123
→ нет

                           SELECT evt_123
                           → нет

начислить 500              начислить 500

INSERT evt_123             INSERT evt_123

Оба процесса успели проверить таблицу до того, как другой записал событие. Дедупликация существует логически, но не защищает от конкурентной обработки.

Поэтому в технических требованиях стоит определить не только наличие проверки, но и требование к её атомарности. Например, (provider, event_id) может иметь уникальное ограничение в базе, а попытка зарегистрировать уже существующее событие должна завершаться без повторного выполнения бизнес‑эффекта.

Именно здесь системный анализ начинает отличаться от формулировки «не обрабатывать дубли». Аналитик должен учитывать, что два запроса могут выполняться одновременно.

В какой момент отвечать провайдеру

Вернёмся к исходным требованиям:

1. Изменить заказ.
2. Начислить бонусы.
3. Отправить письмо.
4. Вернуть 200.

Что произойдёт, если отправка письма займёт 20 секунд? Или сервис бонусов временно недоступен?

Чем больше действий выполняется до ответа, тем больше вероятность, что поставщик webhook не дождётся подтверждения и посчитает доставку неуспешной.

GitHub, например, требует вернуть 2xx в течение 10 секунд и рекомендует помещать webhook в очередь для последующей асинхронной обработки. Stripe также рекомендует быстро возвращать успешный ответ до выполнения сложной логики и обрабатывать события асинхронно.

Но просто перенести 200 OK в первую строку тоже опасно:

получили webhook
↓
вернули 200
↓
процесс упал
↓
событие потеряно

Провайдер уже считает доставку успешной и может больше её не повторить.

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

Например:

Payment provider
       |
       v
Webhook endpoint
       |
       | проверка подлинности
       | регистрация eventId
       | сохранение события
       v
      200
       |
       v
     Queue
       |
       v
     Worker
       |
       | изменение заказа
       | начисление бонусов
       | отправка письма
       v
    Завершено

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

И здесь снова нельзя механически написать «всегда возвращаем 200». Контракт зависит от поставщика. ЮKassa ожидает именно HTTP 200, GitHub принимает успешный 2xx, у других систем правила могут отличаться. Поэтому семантика ACK должна браться из документации конкретной интеграции, а не из общего представления о webhooks.

А что если события пришли не в том порядке

Исправляем дубликаты, добавляем очередь, запускаем систему.

Через месяц новый инцидент.

Платёж сначала успешно прошёл, а затем был возвращён:

10:00 payment.succeeded
10:01 payment.refunded

Но наше приложение получило события так:

10:02 payment.refunded
10:03 payment.succeeded

И в итоге сохранило:

status = PAID

хотя деньги уже возвращены.

Такое поведение нельзя считать невозможным. Stripe прямо не гарантирует порядок доставки событий. Shopify также предупреждает, что webhook могут приходить не по порядку даже для одного ресурса, например update способен быть доставлен раньше create.

Поэтому требования должны отвечать ещё на один вопрос: является ли webhook командой на безусловную смену состояния или лишь сигналом о том, что состояние изменилось?

Для некоторых интеграций безопаснее получить событие, найти объект и дополнительно запросить его актуальное состояние у системы‑источника. В других можно использовать timestamp, номер версии или sequence number, если поставщик их предоставляет.

Слепое правило

при payment.succeeded установить PAID

без модели переходов может вернуть объект в устаревшее состояние.

На уровне требований это можно выразить, например, так:

Событие payment.succeeded не должно переводить платёж
из финального состояния REFUNDED обратно в PAID.

При невозможности определить актуальное состояние
система запрашивает состояние paymentId у провайдера.

Какой именно механизм выбрать, зависит от контракта конкретного API. Но сценарий доставки событий не по порядку должен быть рассмотрен явно.

И наконец, webhook может вообще не прийти

До этого мы обсуждали повторную доставку. Есть противоположная проблема: событие можно не получить вообще.

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

Shopify прямо рекомендует не полагаться исключительно на webhooks и использовать reconciliation jobs, то есть периодическую сверку данных с системой‑источником. Stripe предоставляет API для поиска событий, которые не удалось доставить endpoint’у. GitHub позволяет повторно доставить webhook из доступной истории, но автоматически failed delivery не повторяет.

Для платежей это может означать простой фоновый процесс:

Найти платежи, которые слишком долго находятся в промежуточном состоянии.

Для каждого paymentId:
    запросить актуальный статус у платёжного сервиса;
    сравнить его с локальным состоянием;
    устранить расхождение.

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

Есть и вопрос безопасности. Получатель должен убедиться, что запрос действительно пришёл от провайдера. Для этого многие сервисы подписывают webhook. Например, Stripe использует подпись с timestamp и рекомендует дополнительно проверять свежесть сообщения для защиты от replay‑атак. Для проверки подписи нужен исходный, неизменённый body запроса. Shopify также рассчитывает HMAC по raw request body.

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

Что в итоге было не так с исходными требованиями

Вернёмся к первоначальной спецификации:

При получении payment.succeeded:

1. Найти заказ.
2. Установить PAID.
3. Начислить бонус.
4. Отправить письмо.
5. Вернуть 200.

Формально она описывает функциональность. Но для реальной интеграции этого недостаточно.

Аналитику нужно дополнительно определить, какие гарантии доставки предоставляет внешняя система, что считается уникальным событием, как обрабатываются дубликаты, может ли обработка идти параллельно и какой бизнес‑эффект должен быть строго однократным. Нужно договориться, когда событие считается надёжно принятым и какой ответ подтверждает его получение.

  • Для событий, способных менять одно состояние, нужно учитывать нарушение порядка доставки.

  • Для критичных данных желательно предусмотреть восстановление после потерянных событий.

В результате требования к тому же сценарию могут выглядеть уже примерно так:

payment.succeeded

1. Проверить подлинность уведомления.

2. Идентифицировать событие по eventId.

3. Атомарно зарегистрировать eventId.
   Уже зарегистрированное событие повторно не обрабатывать.

4. Надёжно сохранить событие для последующей обработки.

5. Вернуть провайдеру подтверждение согласно его webhook-контракту.

6. Обработать событие асинхронно.

7. Перевести заказ в PAID только если такой переход
   допустим из его текущего состояния.

8. Для одного paymentId начисление бонуса
   может быть выполнено не более одного раза.

9. Ошибка бизнес-обработки должна приводить
   к повторной внутренней попытке, а не к потере события.

10. Для платежей с длительно неактуальным состоянием
    выполнить сверку с API платёжного сервиса.

Это всё ещё не полная техническая спецификация. Например, здесь не определены политика внутренних retry, сроки хранения идентификаторов событий и поведение при окончательной ошибке. Они зависят от конкретной системы.

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

Именно это часто определяет, будет ли интеграция работать только на схеме и тестовом стенде или переживёт реальную сеть, тайм‑ауты, параллельные запросы и отказы.

Как корректно описать приём и обработку webhook - 1

Когда требования выглядят понятными только на бумаге, а реальные проблемы проявляются уже в процессе работы, системному аналитику важно уметь видеть риски, проверять решения и выстраивать процесс от потребности до результата.

На бесплатных уроках разберём практические задачи системного анализа: работу с рисками, подход к собеседованиям и создание ИИ‑ассистента, который помогает автоматизировать рутинные действия аналитика. Присоединяйтесь:

  • 4 августа, 20:00. «Как аналитику работать с рисками». Записаться

  • 11 августа, 20:00. «Практическое собеседование системного аналитика». Записаться

  • 25 августа, 20:00. «Создаём ИИ‑ассистента для системного аналитика за 1 час». Записаться

Больше бесплатных уроков и других полезных подборок смотрите в дайджесте.

Автор: SiYa_renko

Источник

Оставить комментарий