Вебхуки и безопасность
Все кассы принимаются одним динамическим роутом:
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) |
| Повторный запрос к API | YooKassa — её вебхук не подписан вовсе, поэтому база доверяет только собственному запросу статуса платежа по 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). Иначе берётся адрес сокета — безопасное поведение по умолчанию.
