Коротко
Адрес вебхука открыт в интернете, а значит, кто угодно может отправить вам запрос «счёт оплачен». Единственное, что от этого защищает, — проверка подписи.
Подпись строится так:
signature = "sha256=" + hex( HMAC-SHA256( secret, timestamp + "." + rawBody ) )
Здесь timestamp — значение заголовка X-Webhook-Timestamp, а rawBody — тело запроса в неизменённом байтовом виде. Результат сравнивается со значением заголовка X-Webhook-Signature.
Обязательных проверок три: подпись сходится, timestamp не старше 5 минут, обработка идемпотентна.
Почему обязателен raw body
Это самая частая ошибка. Большинство фреймворков автоматически разбирают тело в JSON, а когда вы собираете строку обратно, байты меняются: пробелы, порядок полей, представление Unicode-символов.
HMAC считается по байтам. Изменился один байт — подпись будет совсем другой.
| Неверно | Верно |
|---|---|
JSON.stringify(req.body) | req.body как Buffer |
json.dumps(request.json) | request.body или request.get_data() |
json_encode($data) | file_get_contents('php://input') |
Правило: проверяйте подпись до разбора тела в JSON, и только потом парсите.
Проверка timestamp
X-Webhook-Timestamp — время отправки в Unix-секундах. Если оно старше 5 минут, запрос принимать не нужно.
Это защита от повторной отправки: если кто-то перехватит когда-то валидный запрос и отправит его позже, подпись сойдётся, а время — нет.
Убедитесь, что часы вашего сервера идут верно: при расхождении в несколько минут вы начнёте отклонять настоящие запросы.
Express (Node.js)
Главное — express.json() не должен применяться к этому маршруту.
import crypto from 'node:crypto';
import express from 'express';
const app = express();
function verify(req) {
const ts = req.get('X-Webhook-Timestamp') || '';
const got = req.get('X-Webhook-Signature') || '';
if (Math.abs(Date.now() / 1000 - Number(ts)) > 300) return false;
const mac = crypto
.createHmac('sha256', process.env.QUTPAY_WEBHOOK_SECRET)
.update(ts + '.')
.update(req.body) // Buffer, без изменений
.digest('hex');
const expected = 'sha256=' + mac;
const a = Buffer.from(expected);
const b = Buffer.from(got);
return a.length === b.length && crypto.timingSafeEqual(a, b);
}
app.post('/qutpay-webhook', express.raw({ type: '*/*' }), (req, res) => {
if (!verify(req)) return res.sendStatus(401);
res.sendStatus(200);
const { event, invoice } = JSON.parse(req.body.toString('utf8'));
handle(event, invoice);
});
Для сравнения используйте timingSafeEqual, а не обычное равенство: побайтовое сравнение с ранним выходом позволяет подобрать подпись.
Laravel (PHP)
Laravel тоже разбирает тело, но $request->getContent() отдаёт исходные байты. Этот маршрут нужно исключить из CSRF-проверки.
public function handle(Request $request)
{
$raw = $request->getContent();
$ts = $request->header('X-Webhook-Timestamp', '');
$got = $request->header('X-Webhook-Signature', '');
if (abs(time() - (int) $ts) > 300) {
return response('stale', 401);
}
$mac = hash_hmac('sha256', $ts . '.' . $raw, config('services.qutpay.webhook_secret'));
if (!hash_equals('sha256=' . $mac, $got)) {
return response('bad signature', 401);
}
$payload = json_decode($raw, true);
dispatch(new HandleQutPayEvent($payload)); // работу — в очередь
return response('', 200);
}
В чистом PHP тело берётся через file_get_contents('php://input'), заголовки — через getallheaders().
Django (Python)
import hmac, hashlib, time, json
from django.conf import settings
from django.http import HttpResponse
from django.views.decorators.csrf import csrf_exempt
@csrf_exempt
def qutpay_webhook(request):
raw = request.body # bytes, без изменений
ts = request.headers.get("X-Webhook-Timestamp", "")
got = request.headers.get("X-Webhook-Signature", "")
if abs(time.time() - int(ts or 0)) > 300:
return HttpResponse("stale", status=401)
mac = hmac.new(
settings.QUTPAY_WEBHOOK_SECRET.encode(),
ts.encode() + b"." + raw,
hashlib.sha256,
).hexdigest()
if not hmac.compare_digest("sha256=" + mac, got):
return HttpResponse("bad signature", status=401)
payload = json.loads(raw)
handle(payload)
return HttpResponse(status=200)
Во Flask тело берётся через request.get_data(), остальное совпадает.
Node без фреймворка
import http from 'node:http';
http.createServer((req, res) => {
const chunks = [];
req.on('data', (c) => chunks.push(c));
req.on('end', () => {
const raw = Buffer.concat(chunks); // неизменённые байты
if (!verifyRaw(raw, req.headers)) {
res.writeHead(401).end();
return;
}
res.writeHead(200).end();
handle(JSON.parse(raw.toString('utf8')));
});
}).listen(3000);
Идемпотентная обработка
Даже при верной подписи одно событие может прийти несколько раз: оборвалась сеть, вы не успели ответить 200, мы повторили. Всего повторов до 11.
Поэтому выполняйте обработку ровно один раз по паре (invoice.id, status). Самый простой способ — записывать эту пару в свою базу с уникальным ключом:
CREATE TABLE qutpay_events (
invoice_id TEXT NOT NULL,
status TEXT NOT NULL,
handled_at TIMESTAMPTZ DEFAULT now(),
PRIMARY KEY (invoice_id, status)
);
const ins = await db.query(
'INSERT INTO qutpay_events (invoice_id, status) VALUES ($1, $2) ON CONFLICT DO NOTHING',
[invoice.id, invoice.status],
);
if (ins.rowCount === 0) return; // уже обработано
await fulfil(invoice);
Подробнее: Идемпотентность.
Частые ошибки
| Что сделано | Результат | Решение |
|---|---|---|
| Тело разобрали в JSON и собрали строку обратно | Подпись не сойдётся никогда | Используйте raw body |
timestamp не добавлен в подпись | Подпись не сходится | Сохраните порядок timestamp + "." + rawBody |
Префикс sha256= не учтён при сравнении | Подпись не сходится | Сравнивайте с префиксом либо уберите его с обеих сторон |
| Секрет скопирован с лишним пробелом в конце | Подпись не сходится | Скопируйте секрет заново |
| Часы сервера сбиты | Настоящие запросы отклоняются как «старые» | Синхронизируйте время |
| Подпись не проверяется вовсе | Кто угодно пришлёт фальшивое «оплачено» | Включите проверку |
| Адрес закрыт авторизацией | Вебхук не приходит вообще | Оставьте адрес открытым и защитите подписью |
Если подпись не сходится: Подпись вебхука не сходится.
Дополнительные меры
- Сделайте адрес неугадываемым: например
/qutpay-webhook-8f3a…. Подпись это не заменяет, но убирает лишний шум. - HTTPS обязателен, в боевом режиме другого и не принимается.
- Не держите секрет в коде, храните его в секретных переменных окружения.
- Логируйте запросы с неверной подписью — так видно и атаку, и собственную ошибку.
- Полный список: Безопасность интеграции: чек-лист.
Вопросы и ответы
Можно не проверять подпись? Нельзя. На такой адрес кто угодно пришлёт «счёт оплачен» и заберёт товар бесплатно.
Достаточно ли фильтра по IP? Нет. IP-адреса меняются, а подпись подтверждает сами данные.
Когда меняется секрет? Только когда вы сами его меняете. Новый секрет действует с момента создания, поэтому обновляйте сервер одновременно.
Почему в теле нет точного времени оплаты? Kaspi не передаёт время платежа, поэтому мы можем указать только момент, когда узнали о нём сами.
Как проверить свою реализацию? Кнопкой проверки в разделе Интеграции кабинета, а затем симуляцией оплаты в песочнице.