Qut Pay Сайт Кабинет База знаний Инструкции Документация API ҚАЗРУС
ГлавнаяБаза знаний → Решение проблем

Подпись вебхука не сходится

Обновлено: 2026-09-14 · Версия в Markdown

Коротко

Подпись считается так: 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-SignatureHex-подпись с префиксом sha256=
X-Webhook-DeliveryИдентификатор доставки, удобно писать в журнал

Причина по симптому

СимптомПричинаРешение
Не сходится никогдаТело разобрано в JSON и собрано обратноБерите raw body
Иногда сходится, иногда нетВ теле кириллица или эмодзи, пересборка меняет байтыБерите raw body
Везде сходится, кроме продаСекреты у сред разные, .env не обновлёнПоставьте секрет именно этой среды
Лишний префикс sha256=Префикс не учтён при сравненииДобавьте sha256= и к ожидаемому значению
Timestamp считается устаревшимЧасы сервера ушли или событие долго лежало в очередиВключите синхронизацию по NTP
Подпись посчитана по API-ключуПерепутаны секрет и API-ключИспользуйте секрет вебхука

Главная ошибка: пересборка тела

Многие фреймворки автоматически разбирают входящий JSON в объект. Если потом собрать этот объект обратно в строку, результат не совпадёт с оригиналом побайтово:

// НЕВЕРНО — подпись не сойдётся никогда
const raw = JSON.stringify(req.body);

Почему:

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-Keyqp_live_… / qp_test_…
Секрет вебхукаТолько при проверке подписи входящего вебхукаОтдельное значение, не начинается на qp_

Секрет вебхука показывается в кабинете один раз — в момент добавления адреса. Если он потерян, посмотреть его снова нельзя: создаёте новый и обновляете код.

Если проблема с заголовком X-API-Key, это совсем другая история: API отвечает 401.

Порядок проверки

  1. Запишите в журнал точные байты пришедшего запроса (в hex или base64)
  2. Посчитайте подпись от этих байтов вручную и сравните с заголовком
  3. Сошлось — проблема в том, как ваш код получает raw body
  4. Не сошлось — другой секрет или timestamp подставлен неправильно
  5. Проверьте порядок: timestamp + "." + rawBody, точка как разделитель
  6. Если всё выглядит верно: Проблема у вас или у Kaspi

Вопросы и ответы

Что отвечать, если подпись не сошлась? 400. Тогда событие будет повторено — и это правильно: успеете починить код, событие не потеряется.

Можно ли не проверять подпись? Нельзя. Адрес вебхука открыт без авторизации, то есть запрос на него может отправить кто угодно. Подпись — единственное доказательство, что запрос пришёл от нас.

Что будет со старыми вебхуками, если сменить секрет? С момента смены используется новый секрет. Обновляйте код одновременно.

Может ли у одного адреса быть несколько секретов? У каждого адреса вебхука свой секрет. Если адресов несколько, храните их по отдельности.

Работает ли это за прокси? Да, но убедитесь, что прокси не меняет тело. Некоторые конфигурации переформатируют JSON — тогда подпись не сойдётся никогда.

Связанные статьи

Поздняя оплата — счёт закрыт, а деньги пришлиНа отменённый или просроченный счёт деньги могут прийти с опозданием. В этом случае событие invoice.paid приходит с меткой late: true. Что делать и как заранее подготовить к этому код.Проблема у вас или у Kaspi — диагностика за две минутыТри вопроса показывают, на чьей стороне сбой: в вашей интеграции, в привязке Kaspi или в самом сервисе. К каждому ответу — конкретное действие и список того, что собрать для поддержки.API отвечает 401 — ключ не принимается401 unauthorized означает, что в запросе нет действующего API-ключа. Причины, порядок проверки и рабочий пример curl. Чаще всего виноват заголовок или префикс Bearer.

Остались вопросы? WhatsApp +77788813333 · kazprose@gmail.com
Написать можно и из кабинета: Поддержка.

Qut Pay — независимый сервис, не аффилирован с АО «Kaspi Bank». Kaspi и Kaspi Pay — товарные знаки их правообладателя.