Разделы документации
Документация / Уведомления
Проверка подписи
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()
// })