Как защитить webhook криптоплатежей
Что защищает точку приёма webhook платежей: проверка подписи X-Qvard-Signature, идемпотентность, точные коды ответа, повторы и логирование. По тому, как webhook Qvard работают на самом деле.

Webhook это момент, когда ваш магазин решает, что заказ оплачен. Любой, кто может вызвать эту точку с нужным телом, попробует получить товар бесплатно, так что она заслуживает больше внимания, чем большинство маршрутов. В материале описано, что защищает webhook платежей, по тому, как webhook Qvard работают сегодня, включая заголовок X-Qvard-Signature, по которому можно проверить, что уведомление пришло от Qvard.
Что такое webhook Qvard, а что нет
Когда платёж завершён, Qvard отправляет POST на callback URL, сохранённый в вашем проекте. Тело небольшое: id, который вы передали при создании платежа, и status платежа. Webhook отправляется, когда платёж завершён, и это сигнал пометить заказ оплаченным.
Запрос несёт заголовок X-Qvard-Signature в виде t=<unix>,v1=<hex>: HMAC-SHA256 от метки времени и сырого тела, посчитанный вашим секретным ключом. За 24 часа после ротации ключа приходят два значения v1, и действительно любое из них. Мерчанты со старым ключом (без префикса qvard_sk_) получают в webhook ещё и заголовок x-api-key; подпись всё равно проверяйте.
Из этого следуют два свойства: ваша точка это публичный адрес, а единственный секрет, который её защищает, это ваш секретный ключ, которым проверяется подпись.
Угрозы, которые действительно важны
Короткий список полезнее длинного:
- Поддельный запрос. Кто-то находит вашу точку и отправляет
{ "id": "1234", "status": "completed" }, чтобы заказ пометился оплаченным. Остановит это проверка подписи. - Утёкший ключ. Один и тот же секретный ключ подписывает webhook и аутентифицирует ваши вызовы API. Если он попал в репозиторий, лог или фронтенд-код, под угрозой оба.
- Повторная доставка. Это не атака, но самая частая причина реального ущерба: то же событие приходит дважды, и заказ выполняется дважды.
- Медленная или падающая точка. Пропущенные доставки оставляют оплаченные заказы невыполненными.
- Небрежные логи. Если логировать весь запрос, ключ окажется в файлах логов.
Каждый следующий раздел закрывает одну из этих угроз.
Проверяйте ключ и сравнивайте его безопасно
Читайте заголовок X-Qvard-Signature в каждом запросе и проверяйте его по сырому телу до любых других действий. Две детали:
- Сравнение за постоянное время. Обычное
===может выдавать информацию о том, сколько первых символов совпало.constructEventв SDK сравнивает за постоянное время; при ручной проверке используйте функцию вашего языка с постоянным временем (crypto.timingSafeEqualв Node.js,hash_equalsв PHP,hmac.compare_digestв Python). Если функция требует равных размеров, сначала сравните длины. - Отказывайте молча. Отвечайте
400без пояснений. Не сообщайте, подпись отсутствовала или была неверной.
Пример ниже показывает весь обработчик: проверка подписи, небольшой лимит тела, проверка полей, обновление, которое меняет только ещё не оплаченный заказ, и ответ ровно 200. Храните ключ в переменной окружения или хранилище секретов, но не в репозитории и не во фронтенде.
import express from 'express';
import { Qvard } from '@qvard/node';
const secret = process.env.QVARD_SECRET_KEY;
const qvard = new Qvard(secret);
const app = express();
app.post('/webhooks/qvard', express.raw({ type: 'application/json', limit: '10kb' }), async (req, res) => {
let event;
try {
event = qvard.webhooks.constructEvent(req.body, req.get('X-Qvard-Signature'), secret);
} catch {
return res.sendStatus(400);
}
const { id, status } = event;
if (typeof id !== 'string' || status !== 'completed') return res.sendStatus(200);
// Idempotent: the update only matches an order that is not paid yet.
const changed = await db.orders.markPaidIfNot(id);
if (changed) queue.push({ type: 'fulfil', orderId: id }); // slow work happens after the answer
return res.sendStatus(200); // exactly 200
});Сделайте обработчик идемпотентным
Qvard считает доставку успешной, только когда ваша точка отвечает ровно HTTP 200. Любой другой статус, таймаут или редирект считаются ошибкой и повторяются: пять попыток примерно за шесть минут. Поэтому ваш обработчик иногда будет видеть одно событие несколько раз, например когда сервер сделал работу, а ответ потерялся.
Проектируйте с учётом этого:
- Делайте смену состояния условной: «пометить заказ оплаченным, если он ещё не оплачен», лучше одним запросом к базе, чтобы две параллельные доставки не победили обе.
- Запускайте выполнение (письма, ключи, доступ) только если обновление действительно изменило строку.
- Игнорируйте события по неизвестным заказам, но всё равно отвечайте 200, иначе Qvard будет повторять то, что вы никогда не обработаете.
- Действуйте только по
completed. Любой другой статус не должен помечать заказ оплаченным.
Это же защита от повторно отправленного запроса: повторное валидное событие во второй раз ничего не меняет.
Отвечайте быстро, работайте потом
Ваша точка должна ответить раньше, чем Qvard сдастся, поэтому делайте её короткой: проверить, обновить, поставить в очередь, ответить. Медленное, такое как письма, вызовы других систем и генерация файлов, выполняйте из очереди после ответа.
Подумайте и о том, чего точка не знает. В теле только id и status, поэтому ожидаемую сумму вы берёте из своего заказа, а не из webhook. Никогда не берите сумму, валюту или товар из тела запроса.
Отдавайте точку только по HTTPS и следите, чтобы перед ней не стояли прокси или редиректы: редиректы не обрабатываются, поэтому 301 с http на https это неудачная доставка.
Логи, мониторинг и восстановление
Относитесь к webhook как к движению денег и ведите журнал.
- Логируйте решение, а не секрет. Сохраняйте
id,status, время и ваше решение (применено, дубликат, неизвестный заказ, ключ отклонён). Никогда не логируйте секретный ключ. - Настройте оповещения об отказах. Внезапная серия ответов
400значит, что кто-то прощупывает точку или вы сменили ключ не везде. - Сверяйтесь. Раз в сутки сравнивайте заказы, которые считаете оплаченными, с балансом и платежами в кабинете Qvard.
- Восстанавливайтесь. Если сервер лежал дольше окна повторов, отправьте уведомление по платежу из кабинета ещё раз, а идемпотентный обработчик с этим справится.
Если подозреваете утечку ключа, замените его и проверьте, какие ваши системы им пользовались.
Чек-лист перед запуском
- Точка работает по HTTPS, публична и доступна снаружи вашей сети.
- Она проверяет
X-Qvard-Signatureпо сырому телу и иначе отвечает400. - Она ограничивает размер тела и проверяет
idиstatus. - Она помечает заказ оплаченным только по
completedи только если он ещё не оплачен. - Она быстро отвечает ровно 200, а медленную работу делает потом.
- Она никогда не доверяет суммам и товарам из тела.
- Она никогда не логирует ключ.
- Вы проверили неудачный ответ, повторную доставку и неизвестный заказ.
Весь процесс вокруг webhook описан в материале как принимать криптоплатежи на сайте. Форматы запросов и ответов в документации для разработчиков.
FAQ
Подписывает ли Qvard webhook через HMAC?
Да. Каждый webhook несёт заголовок X-Qvard-Signature (t=<unix>,v1=<hex>): HMAC-SHA256 от метки времени и сырого тела, посчитанный вашим секретным ключом. Проверяйте его по сырому телу до разбора JSON; SDK делает это через constructEvent.
Какой код ответа должна вернуть моя точка?
Ровно 200. Любой другой статус, таймаут или редирект считаются неудачной доставкой и повторяются: пять попыток примерно за шесть минут.
Что если мой сервер лежал, когда платёж завершился?
Повторы покрывают около шести минут. Потом отправьте уведомление по платежу из кабинета ещё раз. Идемпотентный обработчик делает это безопасным.
Можно ли доверять сумме из webhook?
В webhook только id платежа и его статус. Берите сумму из своего заказа и помечайте его оплаченным только по статусу completed.
