Платёжные вебхуки без ошибок

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

Платёжные вебхуки без ошибок

Короткий ответ

Чтобы безопасно обработать вебхук SwapSS Pay, сделайте четыре вещи по порядку. Прочитайте тело запроса в сыром виде, до того как его разберёт фреймворк. Пересчитайте HMAC-SHA256 от строки t + "." + raw_body своим секретом подписи и сравните с полем v1 из заголовка Swap-Pay-Signature. Отклоните запрос, если метка времени t старше пяти минут. Затем найдите событие по его id: если вы его уже обработали, ничего не делайте и верните 200. Если все четыре условия выполнены, примените изменение состояния один раз и быстро верните 2xx.

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

Что такое вебхук и зачем его проверять

Вебхук это URL на вашем сервере, который мы вызываем, когда с платежом что-то происходит: он подтвердился, прошёл возврат, завершилась выплата. Вместо того чтобы опрашивать нас каждые несколько секунд, вы получаете уведомление. Загвоздка в том, что ваш эндпоинт открыт наружу. Любой, кто узнает URL, может прислать на него POST. Поэтому, прежде чем ваш код поверит хоть одному байту, он обязан убедиться, что вызов пришёл от нас, а не от того, кто подделал payment.confirmed, чтобы получить товар бесплатно.

Мы подписываем каждую доставку. Вы проверяете подпись. Это единственное, что стоит между вашей таблицей заказов и незнакомцем с curl.

Шаг 1: проверьте подпись

Каждая доставка несёт такой заголовок:

Swap-Pay-Signature: t=1717430400,v1=8f3c...

t это unix-время момента, когда мы подписали событие. v1 это HMAC-SHA256 в hex. Чтобы проверить:

  1. Возьмите сырое тело. Прочитайте точные байты запроса до того, как до них доберётся JSON-парсер. Это самая частая ошибка. Если проверять подпись по заново собранному объекту, порядок ключей и пробелы сместятся, и подпись не сойдётся никогда. Фреймворкам, которые автоматически парсят JSON, нужен доступ к сырому телу на этом маршруте.
  2. Соберите подписанную строку. Склейте значение t, точку . и сырое тело: t + "." + raw_body.
  3. Посчитайте и сравните. Возьмите HMAC-SHA256 от этой строки с вашим секретом подписи в роли ключа, переведите в hex и сравните с v1. Сравнивайте за постоянное время, а не через ==, чтобы не выдать ответ по байту через тайминг.

Если значения не совпали, верните 401 и остановитесь. Не записывайте тело как доверенное. Не парсите его, чтобы достать «только id».

Секрет подписи выдаётся один раз, при создании вебхука. Повторно мы его не покажем. Потеряли отзовите эндпоинт и создайте новый.

Шаг 2: проверьте время

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

Значение t закрывает эту дыру. Отклоняйте событие, если now - t больше пяти минут. Настоящая доставка приходит к вам меньше чем за секунду; пять минут это щедрый запас на рассинхрон часов и медленную сеть и при этом достаточно узкое окно, чтобы перехваченная копия быстро протухла.

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

Шаг 3: сделайте обработчик идемпотентным

Доставка гарантируется не реже одного раза, а не ровно один раз. Мы повторим попытку при любом ответе кроме 2xx или при таймауте, с растущей паузой в течение следующих суток. Если ваш сервер вернул 200, но его проглотил обратный прокси, или обработчик упал, успев записать заказ, но не успев ответить, мы пришлём то же событие снова. Так и задумано: вы никогда молча не пропустите подтверждённый платёж. Цена в том, что обработчик обязан пережить одно и то же событие дважды.

У каждого события есть стабильный id. Используйте его как ключ идемпотентности.

  1. Откройте транзакцию.
  2. Попробуйте вставить id события в таблицу processed_events с уникальным ограничением.
  3. Если вставка упала на дубликате, событие уже обработано. Ничего не коммитьте, верните 200.
  4. Если вставка прошла, примените изменение состояния в той же транзакции и закоммитьте.

Именно уникальное ограничение делает это безопасным при одновременных повторах. Две копии одного события, пришедшие разом, будут бороться за вставку; победит ровно одна, вторая получит ошибку дубликата и чисто выйдет. Идемпотентность, собранная только из проверки if exists в коде приложения, оставляет щель между проверкой и записью, в которую проходят обе копии. Пусть гарантию даёт база.

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

Шаг 4: быстро верните 2xx

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

Делайте синхронно минимум: проверьте, отсейте дубль, сохраните новое состояние, верните 200. Медленную работу (письмо, выдачу товара, вызовы вниз по цепочке) положите в свою очередь и выполните после ответа. Единственная задача вебхука надёжно зафиксировать, что событие произошло. Действовать по нему ваша задача и ваше время.

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

Что может пойти не так

  • Проверка по разобранному JSON. Классика. Пересобранное тело никогда не совпадёт с подписью. Берите сырые байты.
  • Нет окна по времени. Верная подпись на устаревшем сообщении это всё равно повтор. Всегда проверяйте t.
  • == на подписи. Утечка через тайминг. Сравнивайте за постоянное время.
  • Идемпотентность в коде, а не в базе. Щель между проверкой и записью задваивает зачисление при одновременных повторах. Ставьте уникальное ограничение.
  • Медленный обработчик. Выдача товара внутри обработчика выбивает таймаут и превращает одно событие в десяток повторов. Отвечайте быстро, работайте потом.
  • Доверие сумме, а не состоянию. Решайте по нашему состоянию и id инвойса, а не по числу, которое восстановил ваш код.
  • Сбитые часы. Несинхронное время молча отклоняет все события. Запустите NTP.

Сделайте эти четыре шага правильно и ваш платёжный вебхук станет скучным, а это ровно то, чем должен быть денежный путь. Если доставка падает, а причину не видно, журнал доставок в кабинете мерчанта показывает каждую попытку и ответ, который мы получили, а живой человек читает @swappsy.

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