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

Безопасность вебхуков и проверка подписи

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

Коротко

Адрес вебхука открыт в интернете, а значит, кто угодно может отправить вам запрос «счёт оплачен». Единственное, что от этого защищает, — проверка подписи.

Подпись строится так:

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= не учтён при сравненииПодпись не сходитсяСравнивайте с префиксом либо уберите его с обеих сторон
Секрет скопирован с лишним пробелом в концеПодпись не сходитсяСкопируйте секрет заново
Часы сервера сбитыНастоящие запросы отклоняются как «старые»Синхронизируйте время
Подпись не проверяется вовсеКто угодно пришлёт фальшивое «оплачено»Включите проверку
Адрес закрыт авторизациейВебхук не приходит вообщеОставьте адрес открытым и защитите подписью

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

Дополнительные меры

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

Можно не проверять подпись? Нельзя. На такой адрес кто угодно пришлёт «счёт оплачен» и заберёт товар бесплатно.

Достаточно ли фильтра по IP? Нет. IP-адреса меняются, а подпись подтверждает сами данные.

Когда меняется секрет? Только когда вы сами его меняете. Новый секрет действует с момента создания, поэтому обновляйте сервер одновременно.

Почему в теле нет точного времени оплаты? Kaspi не передаёт время платежа, поэтому мы можем указать только момент, когда узнали о нём сами.

Как проверить свою реализацию? Кнопкой проверки в разделе Интеграции кабинета, а затем симуляцией оплаты в песочнице.

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

Настройка вебхуковКак добавить адрес вебхука в кабинете, выбрать события и сохранить секрет, какие приходят заголовки и тело, как устроены 11 повторов, как читать журнал, протестировать адрес и что с редиректами.Подпись вебхука не сходитсяПодпись считается как HMAC-SHA256(secret, timestamp + "." + rawBody). Самая частая ошибка — разобрать тело в JSON и собрать обратно в строку. Примеры получения raw body для Express, Laravel, Django.Безопасность интеграции: 12 пунктовДвенадцать конкретных требований к безопасной интеграции с Qut Pay: где хранить ключ, как ограничить scope, как проверять подпись вебхука по сырому телу, что нельзя писать в логи и что делать, если ключ утёк.Идемпотентность: защита от дублейКак работает заголовок Idempotency-Key, как правильно составить ключ, какова роль externalOrderId, и как защититься от повторов при обработке вебхуков и при возвратах.

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

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