Skip to content

Вебхуки и безопасность

Все кассы принимаются одним динамическим роутом:

POST /api/v1/payments/{код кассы}

Роут по коду кассы достаёт её настройки из базы (расшифровав ключи), отдаёт колбэк модулю провайдера на верификацию, ставит задачу обработки в очередь и сразу отвечает 200. Новая касса не добавляет ни нового роута, ни новой логики — меняется только модуль провайдера. Единственное исключение — Telegram Stars: у него HTTP-вебхука нет, подтверждение приходит внутрь бота событием successful_payment и дальше идёт тем же пайплайном.

Механизмы верификации

У каждого провайдера своя схема — база покрывает их все, и всякий колбэк проверяется до какой-либо обработки:

МеханизмКто использует
HMAC-подпись тела (SHA-256/SHA-512)CryptoBot, Lava, CloudPayments, SeverPay, AuraPay, RioPay; RollyPay дополнительно подписывает timestamp — защита от replay
MD5-подпись полейRobokassa, FreeKassa, KassaAI, PayPalych; Cryptomus и Heleket — md5(base64(тело) + api_key)
SHA-1-подпись полейЮMoney, MulenPay
RSA-подписьAntilopay (RSA-SHA256, проверка публичным ключом), WATA (RSA-SHA512, публичный ключ берётся из их API)
Повторный запрос к APIYooKassa — её вебхук не подписан вовсе, поэтому база доверяет только собственному запросу статуса платежа по shop_id/secret_key
Сравнение кредов в заголовкахPlatega — колбэк несёт X-MerchantId/X-Secret, они сверяются с вашими ключами
Общий секрет«Вручную / баланс» — публичный роут подтверждения требует заголовок X-Admin-Secret и работает fail-closed: без настроенного секрета отвергает всё

Все сравнения подписей и секретов — constant-time (hmac.compare_digest), тайминг-атаки на подбор не работают. В базе также есть готовый IP-allowlist хелпер (список CIDR + учёт trusted-proxy Cloudflare) для провайдеров, которые аутентифицируются адресом источника.

Прокси переписывают тело — и это учтено

Известная грабля: Cloudflare и другие прокси пересериализуют JSON-тело, и подпись «HMAC от сырого тела» перестаёт сходиться, хотя платёж честный. Поэтому верификация делается с fallback-вариантами:

  • CryptoBot: сначала сырое тело, затем компактные пересериализации JSON (оба варианта ensure_ascii);
  • CloudPayments: принимаются оба заголовка — Content-HMAC (сырое тело) и X-Content-HMAC (URL-декодированное);
  • Lava: сырое тело, затем пересериализация с сортировкой ключей (легаси-магазины подписывают именно её).

Коды ответов

КодКогда
200Колбэк принят: {"accepted": true}. Провайдерам с обязательным plain-text ответом отдаётся ровно он: Robokassa — OK{InvId}, FreeKassa и KassaAI — YES. Верифицированный, но нерелевантный апдейт (например, invoice_expired от CryptoBot) тоже получает 200, чтобы провайдер перестал ретраить
403Подпись/секрет не сошлись — или вебхук кассы X пытается закрыть транзакцию кассы Y (gateway mismatch, защита в глубину: внутренний payment_id светится в redirect-URL)
404Неизвестный код кассы, касса не настроена или выключена, платёж не найден

Не-200 ответ — не сбой, а механизм: провайдер повторит вебхук позже, и платёж не потеряется.

Идемпотентность

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

  • статус меняется атомарным CAS-переходом (PENDING → COMPLETED только из PENDING) — дубль находит транзакцию уже завершённой и становится no-op;
  • UNIQUE(external_id, gateway_type) — один платёж провайдера не породит две транзакции;
  • строка транзакции блокируется SELECT ... FOR UPDATE — параллельные вебхуки сериализуются;
  • сумма из вебхука сверяется со счётом: меньше 90 % — транзакция уходит в FAILED, а не в выдачу (в quickpay-формах юзер может править сумму);
  • гонка «опоздавший вебхук против реконсилятора» безвредна — побеждает первый, второй ничего не делает.

Reverse-proxy

В комплектном docker-стеке стоит Caddy: домен проксируется на веб-сервис, TLS выпускается сам — настраивать ничего не нужно. Если ставите свой nginx/CDN перед ботом:

  • путь /api/v1/payments/* должен быть доступен публично по HTTPS — без Basic Auth, без IP-фильтров;
  • тело запроса передавайте байт-в-байт: не переформатируйте JSON и не режьте заголовки подписей (Content-HMAC, X-Signature, X-Apay-Callback и т. п.);
  • специальные таймауты не нужны — роут не делает тяжёлой работы и отвечает мгновенно.

Заголовки клиентского IP подделываемы

CF-Connecting-IP / X-Real-IP / X-Forwarded-For база учитывает только когда касса явно настроена как «за доверенным прокси» (trust_proxy). Иначе берётся адрес сокета — безопасное поведение по умолчанию.

MIT License · сделано для тех, кто продаёт VPN, а не настраивает ботов