Перейти к содержимому
Разделы документации

Документация / Уведомления

Проверка подписи

X-Signature и X-Signature-V2: спецификация и готовый код

Спецификация

В каждом вебхуке приходят три заголовка подписи. Рекомендуемая схема - v2:

  • X-Timestamp - unix-время отправки в секундах. Считается на каждую попытку доставки (повторы идут до суток), тело при этом не меняется;
  • X-Signature-V2 - HMAC-SHA256 от строки {X-Timestamp}.{сырое тело} (метка, точка, байты тела), hex строчными буквами;
  • X-Signature - прежняя схема, HMAC-SHA256 только от сырого тела. Оставлена для совместимости с существующими интеграциями - работает и дальше.

Ключ обеих схем - «Секрет подписи» вашего магазина с вкладки «Webhook» в кабинете. Проверка v2 дополнительно требует, чтобы X-Timestamp был свежим (рекомендуемое окно - 5 минут в обе стороны): устаревшая метка означает повторное проигрывание перехваченного уведомления - отвечайте 401 и не обрабатывайте.

Проверяйте подпись всегда: только она гарантирует, что уведомление отправил TabPay, а не злоумышленник, узнавший адрес вашего приёмника. Уведомление с неверной подписью игнорируйте (мы получим не-2xx или 401 и повторим доставку - лишний повтор безвреден).

Главные грабли

  • проверяйте по сырому телу: если распарсить JSON и сериализовать обратно, порядок ключей и пробелы могут измениться - подпись не сойдётся. Берите байты запроса до парсинга;
  • сравнивайте подписи функцией постоянного времени (timingSafeEqual, hash_equals, compare_digest), а не оператором сравнения строк;
  • для v2 подписывается именно строка «метка.тело» - метка берётся из заголовка X-Timestamp как есть, без преобразований;
  • часы сервера должны идти точно (NTP): при рассинхроне свежие вебхуки не пройдут окно допуска;
  • окно допуска не заменяет идемпотентность: легитимные повторы доставки приходят и через часы - дубликаты отсекайте по паре (id, status), как описано в вебхуках;
  • секрет подписи - не API-ключ: это разные значения, оба лежат в кабинете.

Готовый код

Примеры проверены против боевого алгоритма подписи TabPay - копируйте как есть:

import { createHmac, timingSafeEqual } from 'node:crypto'

// Окно допуска метки времени: старше 5 минут - считаем повтором.
const TOLERANCE_SECONDS = 300

function safeEqual(expected, actual) {
  const a = Buffer.from(expected)
  const b = Buffer.from(String(actual || ''))
  return a.length === b.length && timingSafeEqual(a, b)
}

// rawBody - СЫРОЕ тело запроса (Buffer или строка).
// Не парсите и не пересобирайте JSON перед проверкой:
// подпись посчитана от исходных байтов.
// Рекомендуемая схема - v2: подпись покрывает и метку времени.
function isValidWebhook(rawBody, headers, secret) {
  const ts = Number(headers['x-timestamp'])
  if (!Number.isFinite(ts)) return false
  if (Math.abs(Date.now() / 1000 - ts) > TOLERANCE_SECONDS) return false
  const expected = createHmac('sha256', secret)
    .update(`${headers['x-timestamp']}.${rawBody}`)
    .digest('hex')
  return safeEqual(expected, headers['x-signature-v2'])
}

// Пример для Express: нужен доступ к сырому телу.
// app.post('/tabpay-webhook',
//   express.raw({ type: 'application/json' }),
//   (req, res) => {
//     const ok = isValidWebhook(
//       req.body,                       // Buffer
//       req.headers,
//       process.env.TABPAY_WEBHOOK_SECRET,
//     )
//     if (!ok) return res.status(401).end()
//     const payment = JSON.parse(req.body)
//     // ... обработайте платёж и ответьте 200
//     res.status(200).end()
//   })