Перейти к содержимому
Назад в Академию

Webhooks: как принять уведомление об оплате и не посчитать его дважды

13.09Server-side и API-интеграции
Содержание статьи

Магазин бытовой техники в Алматы из урока про S2S-трекинг оплаты получает уведомления об оплатах от платёжного сервиса (цифры в уроке условные). Ещё до перехода на outbox его обработчик записывал оплату в CRM, отправлял покупку в Meta и только потом отвечал платёжному сервису. Однажды CRM отвечала медленно, платёжный сервис не дождался ответа и прислал уведомление по заказу T-287 ещё раз, и в CRM у T-287 стало две оплаты, 72 000 ₸ вместо 36 000 ₸. Meta вторую покупку отбросила, потому что event_id у обеих был T-287, а в CRM такой защиты никто не сделал.

Чинить пришлось две вещи: порядок приёма и обработку повторов. Как работает исправленный обработчик, разберём на следующем заказе из урока про S2S-трекинг: T-310 на 48 000 ₸ и уведомление о его оплате E17.

Сохранить событие до ответа отправителю

Пример уведомления E17 для заказа T-310

  1. Проверка

    Проверить отправителя и тип события

  2. Таблица входящих

    Сохранить E17

    Уникальный индекс по ID события

  3. Ответ 200

    Уведомление принято

  4. Отдельный обработчик

    В одной транзакции: T-310 оплачен, outbox для Meta и CRM, E17 обработан

Если база недоступна и E17 не сохранён, ответ 200 отправлять нельзя.

В примере E17 сначала проверяют и сохраняют с уникальным ID, затем подтверждают 200; обновление заказа и outbox выполняет отдельный обработчик.

Что такое webhook

Webhook - это HTTP-запрос, который система сама отправляет на ваш адрес, когда у неё что-то произошло: прошла оплата, оформлен возврат, менеджер перевёл сделку в другой статус. Другой способ узнавать о переменах - опрос (polling), когда ваш сервер раз в несколько минут спрашивает API, нет ли чего нового. Webhook не противоположность API, а его часть, просто первый шаг делает другая сторона.

У магазина два таких отправителя. Платёжный сервис сообщает об оплатах и возвратах. CRM сообщает о сделках: amoCRM присылает событие status_lead, когда у сделки сменился статус (вебхуки amoCRM), а в Битрикс24 то же делает исходящий вебхук (вебхуки Битрикс24). Подписка на эти события настраивается через API обеих CRM.

Уведомление не обязано нести все данные. Битрикс24, например, присылает номер сделки в data[FIELDS][ID], а все её поля обработчик дочитывает отдельным запросом к API. И «мгновенно» здесь не обещание: у отправителя свои очереди и повторы, поэтому время доставки измеряют, а не обещают клиенту.

Сначала сохранить, потом ответить

Отправитель ждёт ответа недолго. Подробно это расписано в документации Stripe: вернуть 2xx нужно раньше, чем начнётся долгая обработка, которая может не уложиться в тайм-аут, а сами события обрабатывать через асинхронную очередь (webhooks Stripe). Пример у них почти про наш магазин: ответить 200 нужно раньше, чем счёт будет отмечен оплаченным в учётной системе.

После исправления обработчик магазина работает так:

  1. Проверяет, что запрос пришёл от платёжного сервиса (об этом в следующем разделе).
  2. Смотрит тип события. Магазину нужны оплаты и возвраты, на остальное лучше вовсе не подписываться, Stripe советует так же.
  3. Записывает E17 в таблицу входящих событий. На поле с ID события стоит уникальный индекс, поэтому вторую строку с E17 база не примет.
  4. Отвечает 200.
  5. Отдельный обработчик берёт E17 из таблицы и в одной транзакции ставит заказу T-310 статус «оплачен», добавляет строки outbox для Meta и CRM и отмечает E17 обработанным. Дальше строки outbox уходят так, как описано в уроке про S2S-трекинг.

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

Если на третьем шаге база недоступна, отвечать 200 нельзя. Для отправителя 200 значит «приняли», и повторять уведомление он не станет: оплата пропадёт, а панель мониторинга останется зелёной. Ответ с ошибкой (5xx) заставит отправителя прислать его позже, когда база уже поднимется.

Кто на самом деле прислал запрос

Адрес обработчика открыт всему интернету. Если сервер верит любому POST-запросу со словами «оплата прошла», заказ можно «оплатить», не заплатив. HTTPS шифрует канал, но не говорит, кто на другом конце, поэтому отправителя узнают тем способом, который он сам описал:

  • Stripe подписывает каждое уведомление. В заголовке Stripe-Signature лежат время отправки и подпись HMAC SHA-256 от строки «время.сырое тело», ключ - секрет обработчика. Если фреймворк разобрал JSON и собрал его заново, байты изменятся и подпись не сойдётся. Время входит в подписанную строку, поэтому подменить его нельзя, и на этом держится проверка возраста: библиотеки Stripe по умолчанию отклоняют подпись старше пяти минут, так что перехваченный запрос не прислать повторно через час.
  • Битрикс24 кладёт в исходящий вебхук auth[application_token]. Обработчик сравнивает его с токеном из настроек вебхука, и если они не совпали, запросу не верят.
  • В описании вебхуков amoCRM подписи нет (формат вебхуков amoCRM). Такое уведомление принимают как сигнал «у сделки что-то изменилось», а статус перед начислением перечитывают через API.

Подпись подтверждает отправителя, но не содержание заказа. Поэтому, как и в уроке про S2S-трекинг, сервер ещё сверяет сумму, валюту и состояние заказа: 48 000 ₸ в уведомлении должны совпасть с 48 000 ₸ в T-310.

Секреты держат вне логов. Токен Битрикс24, секрет подписи и адрес входящего вебхука с секретным кодом не должны попадать в access log, в скриншот для поддержки или в командный чат.

Повтор - то же событие, а не новая оплата

Отправитель не видит, что вы сделали с уведомлением. Он видит только ответ, и если ответ не пришёл вовремя или пришёл с ошибкой, присылает уведомление снова. Stripe в рабочем режиме повторяет доставку до трёх суток с растущими паузами. amoCRM ждёт ответа не больше двух секунд, после неудачи повторяет доставку через 5 минут и ещё через 15, а если в ответ пришла ошибка сервера (499 или 5xx), то ещё дважды, через 15 минут и через час (формат вебхуков amoCRM). Больше 100 невалидных ответов за два часа - и вебхук отключат. А Stripe к тому же предупреждает, что одно и то же событие изредка приходит дважды.

Значит, обработчик должен узнавать повтор и не делать работу второй раз. Узнают его по ID события, который дал отправитель: у Stripe это поле id события, в нашем магазине - E17. А обработку делают идемпотентной: сколько бы раз ни пришёл E17, у T-310 одна оплата на 48 000 ₸. А иногда Stripe создаёт на одно действие два события с разными id. Их узнают по ID объекта в data.object и типу события, поэтому оплату привязывают ещё и к номеру операции платёжного сервиса.

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

Вторая - сначала поискать E17 в таблице и только потом вставить строку. Если два запроса с E17 пришли одновременно, оба увидят пустую таблицу и оба вставят. Искать дубль должна сама база: на ID события стоит уникальный индекс, вторая вставка падает, обработчик понимает эту ошибку как «уже было» и отвечает 200.

За пределами вашей базы транзакции уже нет. Отметку «E17 обработан» и запись оплаты в CRM одной транзакцией не сохранить: CRM могла записать оплату, а её ответ до вашего сервера не дошёл. Поэтому внешний вызов тоже несёт ключ, по которому получатель узнает повтор. Meta убирает повторные серверные события с одинаковыми event_name и event_id. В CRM оплату записывают вместе с номером операции платёжного сервиса и перед созданием ищут оплату с тем же номером. AWS в описании шаблона transactional outbox говорит о том же: из outbox сообщение может уйти дважды, поэтому получатель должен помнить, что уже обработал.

Ключ события и ключ заказа

T-310 - номер заказа, E17 - номер события. У одного заказа событий бывает несколько: оплата, частичный возврат, полный возврат. Если узнавать повторы по номеру заказа, возврат по T-310 обработчик отбросит как «уже было».

Время события на роль ключа тоже не годится. Stripe пишет, что поле created хранит время в секундах и у разных событий оно может совпасть, поэтому ни порядок, ни повтор по нему не определяют. Если отправитель не даёт ID события, ключ собирают из того, что однозначно описывает факт.

amoCRM как раз такой случай. В уведомлении о смене статуса есть ID сделки, старый и новый статус, а ID события нет. Если правило магазина - одна оплата на сделку, ключом служит ID сделки вместе со статусом «Оплачено». Тогда менеджер может ошибиться, вернуть сделку назад и снова перевести в «Оплачено», а оплата всё равно останется одна.

На выходе у каждого получателя свой ключ. В Meta покупка уходит с event_id T-310, потому что по правилу из урока про S2S-трекинг на заказ приходится одна покупка. Какой ключ у какого события и получателя, записывают в карточку события, в строку «Повтор», как в уроке про спецификацию событий.

События приходят не по порядку

Stripe прямо пишет, что события могут прийти не в том порядке, в котором возникли. С заказом T-322 могло случиться так: сервер магазина десять минут перезапускался, первая доставка уведомления об оплате не прошла, и платёжный сервис отложил повтор. За эти минуты покупатель передумал, магазин оформил возврат, и уведомление о возврате дошло раньше, чем повтор уведомления об оплате. Если обработчик просто ставит заказу статус из последнего пришедшего уведомления, T-322 вернётся в «оплачен», и в Meta уйдёт покупка, деньги за которую уже вернули.

Вариантов защиты два. Можно хранить историю заказа и не применять уведомление, если у заказа уже есть более позднее событие. Можно на каждое уведомление спрашивать у API отправителя текущее состояние: Stripe так и советует дочитывать недостающие объекты. Для оплаты и возврата второй вариант надёжнее: API отвечает текущим состоянием заказа, в каком бы порядке ни пришли уведомления. На T-322 API ответит «возвращён», и покупка в Meta не уйдёт.

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

Если обработка не удалась

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

В очередь разбора и в логи кладут ID события, тип, номер заказа, время и код ошибки. Полное тело уведомления с телефоном и адресом покупателя там лишнее.

Чек-лист обработчика

Выполнено 0 из 7

Упражнение: отправитель вашего проекта

Сначала короткая задача. E17 сохранён, но ответ 200 потерялся в сети, и платёжный сервис прислал E17 ещё раз. Что должен сделать обработчик: ответить ошибкой, раз такой ID уже есть, или начислить оплату заново?

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

Теперь возьмите платёжный сервис или CRM своего проекта или клиента, найдите их документацию по уведомлениям и заполните последний столбец:

ВопросStripeamoCRMВаш отправитель
Как узнать отправителяПодпись в заголовке Stripe-SignatureВ описании подписи нет, статус сделки перечитывают через API
Как повторяет доставкуДо трёх суток с растущими паузамиЧерез 5 и 15 минут, при 499 или 5xx ещё через 15 минут и через час
Есть ли ID событияДа, поле idНет, только ID сделки и статусы
Обещан ли порядокНетВ описании не сказано

Пустая клетка после поиска по документации - это вопрос в поддержку отправителя до начала разработки, угадывать тут нельзя. Если ID события нет, сразу запишите, из чего будет ключ. Если подпись не описана, решите, каким запросом к API обработчик подтвердит событие. Заполненная таблица - первая страница ТЗ для разработчика. Её же стоит показать клиенту, когда он спросит, почему приём оплат занял не один вечер.

Видео: webhook и запрос API

Что такое Webhook и чем отличается от API?

Короткое введение от Merion Academy: чем webhook отличается от обычного запроса к API.

Нужна помощь с аналитикой вашего проекта?