Как проверить вебхук Qvard

Короткая инструкция по заголовку X-Qvard-Signature: что это и как проверить его несколькими строками кода.

Что такое подпись

Когда статус платежа меняется, Qvard отправляет уведомление (вебхук) на адрес из настроек вашего магазина. Отправить запрос на этот адрес может кто угодно, поэтому нужен способ отличить настоящее уведомление от подделки.

Подпись и есть такое доказательство: короткий код, который Qvard считает от сообщения и вашего секретного ключа. Ключ знаете только вы и Qvard, поэтому правильный код получится только у Qvard.

Что вы получаете

С каждым вебхуком приходит один дополнительный заголовок и небольшое JSON-тело:

X-Qvard-Signature: t=1760950000,v1=3f2a…c91e {"id":"order_1024","status":"paid"}

t — время отправки (Unix, секунды). v1 — сам код. В течение 24 часов после перевыпуска секретного ключа в заголовке два значения v1: для нового ключа и для старого.

Поля id и status остались как были. В то же тело, которое подписывается, добавлены четыре денежных поля:

  • amount и currency: сумма и её валюта, как вы передали их в запросе.
  • amountUsd: учётная сумма в USD.
  • rate: единиц валюты за 1 USD на момент создания платежа.
  • Старые интеграции не ломаются: они читают id и status и не используют новые поля. Подпись проверяется по тому же телу, как и раньше.

Как проверить

  1. 1
    Возьмите тело запроса ровно в том виде, как оно пришло: сырой текст, до разбора JSON.
  2. 2
    Прочитайте t и все v1 из заголовка X-Qvard-Signature.
  3. 3
    Посчитайте HMAC-SHA256 от текста t + "." + тело с вашим секретным ключом (qvard_sk_…) и запишите результат строчным hex.
  4. 4
    Если ваш результат совпал с любым из v1 (сравнивайте за постоянное время), сообщение настоящее.
  5. 5
    Проверьте, что t не старше 5 минут: так старое сообщение нельзя отправить повторно.
  6. 6
    Только после этого используйте данные и сверьте сумму оплаты этого заказа в своей базе.

Готовый код

const crypto = require('crypto');

function verifyQvardSignature(rawBody, header, secret, toleranceSec = 300) {
  const parts = header.split(',').map((p) => p.trim());
  const t = Number((parts.find((p) => p.startsWith('t=')) || '').slice(2));
  const sigs = parts.filter((p) => p.startsWith('v1=')).map((p) => p.slice(3));
  if (!t || sigs.length === 0) return false;
  if (Math.abs(Date.now() / 1000 - t) > toleranceSec) return false;
  const expected = crypto.createHmac('sha256', secret).update(`${t}.${rawBody}`).digest();
  return sigs.some((s) => {
    const got = Buffer.from(s, 'hex');
    return got.length === expected.length && crypto.timingSafeEqual(got, expected);
  });
}

// Use the RAW body: express.raw, not express.json
app.post('/qvard-webhook', express.raw({ type: 'application/json' }), (req, res) => {
  const raw = req.body.toString('utf8');
  if (!verifyQvardSignature(raw, req.get('X-Qvard-Signature') || '', process.env.QVARD_SECRET_KEY)) {
    return res.sendStatus(400);
  }
  const { id, status } = JSON.parse(raw);
  // Confirm the paid amount for order `id` in your own database before fulfilling it.
  res.sendStatus(200);
});

В течение 24 часов после перевыпуска секретного ключа в заголовке два значения v1: принимайте вебхук, если совпало любое из них.

Частые ошибки

  • Если разобрать JSON и собрать его заново, текст изменится. Всегда берите сырое тело.
  • Использован публичный ключ (qvard_pk_…) вместо секретного.
  • Если часы на сервере уходят, проверка 5 минут не пройдёт. Держите время синхронизированным.
  • Сравнение через == вместо сравнения за постоянное время.

Где взять секретный ключ

В кабинете: ваш магазин, Настройки, API ключи. Храните его только на сервере. Если ключ утёк, перевыпустите его там: старый ключ ещё 24 часа работает для платежей (не для выплат).

Открыть кабинет

Справочник API

Старые ключи

Если у вашего магазина ещё ключ старого формата (без префикса qvard_sk_), в вебхуках по-прежнему приходит заголовок x-api-key с этим ключом. После перевыпуска он исчезнет. Переходите на проверку подписи.

Хотите попробовать окно оплаты?

Открыть демо