Коротко
Подпись считается так: HMAC-SHA256(secret, timestamp + "." + rawBody), результат в hex, с префиксом sha256=, приходит в заголовке X-Webhook-Signature. У несовпадения почти всегда одна причина: вы разбираете тело в JSON, а потом собираете обратно в строку. При этом меняются байты — пробелы, порядок полей, экранирование Unicode, — и подпись не сойдётся никогда.
Решение: брать тело в неизменном байтовом виде и проверять именно его. А разбор в JSON делать после проверки.
Как считать правильно
import crypto from 'node:crypto';
function verify(secret, timestamp, rawBody, signature) {
const expected = 'sha256=' + crypto
.createHmac('sha256', secret)
.update(timestamp + '.' + rawBody) // rawBody — Buffer или нетронутая строка
.digest('hex');
const a = Buffer.from(expected);
const b = Buffer.from(signature || '');
return a.length === b.length && crypto.timingSafeEqual(a, b);
}
Сравнивайте не через ===, а через timingSafeEqual.
Заголовки:
| Заголовок | Что приходит |
|---|---|
X-Webhook-Event | Имя события, например invoice.paid |
X-Webhook-Timestamp | Метка времени, участвующая в подписи |
X-Webhook-Signature | Hex-подпись с префиксом sha256= |
X-Webhook-Delivery | Идентификатор доставки, удобно писать в журнал |
Причина по симптому
| Симптом | Причина | Решение |
|---|---|---|
| Не сходится никогда | Тело разобрано в JSON и собрано обратно | Берите raw body |
| Иногда сходится, иногда нет | В теле кириллица или эмодзи, пересборка меняет байты | Берите raw body |
| Везде сходится, кроме прода | Секреты у сред разные, .env не обновлён | Поставьте секрет именно этой среды |
Лишний префикс sha256= | Префикс не учтён при сравнении | Добавьте sha256= и к ожидаемому значению |
| Timestamp считается устаревшим | Часы сервера ушли или событие долго лежало в очереди | Включите синхронизацию по NTP |
| Подпись посчитана по API-ключу | Перепутаны секрет и API-ключ | Используйте секрет вебхука |
Главная ошибка: пересборка тела
Многие фреймворки автоматически разбирают входящий JSON в объект. Если потом собрать этот объект обратно в строку, результат не совпадёт с оригиналом побайтово:
// НЕВЕРНО — подпись не сойдётся никогда
const raw = JSON.stringify(req.body);
Почему:
- Пробелы и переводы строк теряются или добавляются
- Порядок полей может измениться
- Числа пишутся иначе:
1000.0→1000 - Кириллица и эмодзи экранируются по-другому
- Пустые объекты и
nullвыводятся иначе
HMAC — функция от байтов. Изменился один байт — подпись становится совершенно другой.
Как получить raw body
Express. Для маршрута с проверкой подписи вместо express.json() используйте express.raw():
app.post('/webhooks/qutpay',
express.raw({ type: 'application/json' }),
(req, res) => {
const raw = req.body; // Buffer, нетронутый
const ts = req.headers['x-webhook-timestamp'];
const sig = req.headers['x-webhook-signature'];
if (!verify(process.env.WEBHOOK_SECRET, ts, raw, sig)) {
return res.sendStatus(400);
}
const event = JSON.parse(raw.toString('utf8')); // ПОСЛЕ проверки
res.sendStatus(200);
});
Важно: если express.json() подключён на всё приложение через app.use(express.json()), он успеет отработать раньше. Регистрируйте маршрут вебхука до общего express.json().
Laravel.
$raw = $request->getContent(); // нетронутое тело
$expected = 'sha256=' . hash_hmac(
'sha256',
$request->header('X-Webhook-Timestamp') . '.' . $raw,
config('services.qutpay.webhook_secret')
);
if (!hash_equals($expected, $request->header('X-Webhook-Signature', ''))) {
abort(400);
}
$event = json_decode($raw, true);
Нужен именно $request->getContent(), а не $request->all().
Django.
import hmac, hashlib
raw = request.body # bytes, нетронутые
ts = request.headers.get('X-Webhook-Timestamp', '')
sig = request.headers.get('X-Webhook-Signature', '')
expected = 'sha256=' + hmac.new(
secret.encode(),
ts.encode() + b'.' + raw,
hashlib.sha256,
).hexdigest()
if not hmac.compare_digest(expected, sig):
return HttpResponseBadRequest()
Нужен request.body, а не request.POST и не request.data. В DRF обращайтесь к request.body до того, как будет прочитан request.data.
Чистый Node.
const chunks = [];
req.on('data', (c) => chunks.push(c));
req.on('end', () => {
const raw = Buffer.concat(chunks);
// проверка здесь
});
Проверка timestamp
Даже с верной подписью старый запрос принимать не стоит. Значение X-Webhook-Timestamp не должно быть старше 5 минут:
const age = Math.abs(Date.now() / 1000 - Number(ts));
if (age > 300) return res.sendStatus(400);
Это защита от повторной отправки перехваченного запроса. Если проверка срабатывает ложно, проверьте часы сервера: синхронизация по NTP должна быть включена.
Не путайте секреты
Есть два разных секрета, и они совершенно не похожи:
| Что | Где применяется | Формат |
|---|---|---|
| API-ключ | При отправке запросов, заголовок X-API-Key | qp_live_… / qp_test_… |
| Секрет вебхука | Только при проверке подписи входящего вебхука | Отдельное значение, не начинается на qp_ |
Секрет вебхука показывается в кабинете один раз — в момент добавления адреса. Если он потерян, посмотреть его снова нельзя: создаёте новый и обновляете код.
Если проблема с заголовком X-API-Key, это совсем другая история: API отвечает 401.
Порядок проверки
- Запишите в журнал точные байты пришедшего запроса (в hex или base64)
- Посчитайте подпись от этих байтов вручную и сравните с заголовком
- Сошлось — проблема в том, как ваш код получает raw body
- Не сошлось — другой секрет или timestamp подставлен неправильно
- Проверьте порядок:
timestamp + "." + rawBody, точка как разделитель - Если всё выглядит верно: Проблема у вас или у Kaspi
Вопросы и ответы
Что отвечать, если подпись не сошлась? 400. Тогда событие будет повторено — и это правильно: успеете починить код, событие не потеряется.
Можно ли не проверять подпись? Нельзя. Адрес вебхука открыт без авторизации, то есть запрос на него может отправить кто угодно. Подпись — единственное доказательство, что запрос пришёл от нас.
Что будет со старыми вебхуками, если сменить секрет? С момента смены используется новый секрет. Обновляйте код одновременно.
Может ли у одного адреса быть несколько секретов? У каждого адреса вебхука свой секрет. Если адресов несколько, храните их по отдельности.
Работает ли это за прокси? Да, но убедитесь, что прокси не меняет тело. Некоторые конфигурации переформатируют JSON — тогда подпись не сойдётся никогда.