Как xpayment подписывает каждый вебхук алгоритмом HMAC-SHA256, как проверять заголовок X-xPayment-Signature на Node, Go и Python, и как выбрать, хранить и менять секрет.
Ваш эндпоинт вебхука — это публичный URL. Любой, кто его узнает, может отправить на него POST-запрос, который выглядит как уведомление о платеже. Если ваш сервер доверяет каждому входящему запросу, злоумышленник может подделать событие payment.completed и заставить вашу систему отгрузить заказ, который никто не оплачивал.
От чего нужно защищаться:
xpayment защищает от подделки, подписывая каждую доставку. С повторами вы боретесь с помощью идемпотентности (см. руководство по вебхукам).
При создании подписки вебхука вы можете задать секрет. После этого каждая доставка содержит заголовок X-xPayment-Signature с HMAC-SHA256 от тела запроса, вычисленным на ключе — вашем секрете.
Поскольку секрет знаете только вы и xpayment, корректная подпись доказывает, что запрос действительно пришёл от xpayment и что тело не было изменено по пути. В следующих разделах разбираем точную схему, проверку в коде и безопасное управление секретом.
Каждая доставка подписывается алгоритмом HMAC-SHA256:
| Компонент | Значение |
|---|---|
| Алгоритм | HMAC-SHA256 |
| Ключ | Секрет подписки (secret) |
| Сообщение | Точные «сырые» байты тела запроса |
| Кодировка | Hex в нижнем регистре |
| Заголовок | X-xPayment-Signature |
Заголовок выглядит так:
X-xPayment-Signature: aeafc0dcd86de4d4d376f525be2b1175520c2818c93938ec8d069957c63a373b
1. Это «чистый» hex — без префикса sha256=.
Некоторые провайдеры вебхуков добавляют префикс (например, sha256=abc...). xpayment этого не делает. Значение заголовка — это hex-дайджест и ничего больше. Если ваш валидатор срезает префикс, совпадения не будет никогда.
2. Подписывайте байты ровно в том виде, в каком они пришли.
Подпись вычисляется по «сырому» телу. Если вы распарсите JSON и заново его сериализуете перед хешированием, малейшее отличие — изменённый порядок ключей, другой формат числа, лишние пробелы — даст совершенно другой хеш, и проверка провалится. Всегда сохраняйте и хешируйте исходные байты тела запроса.
Чтобы проверить секрет, код не нужен — проверщик секрета вебхука вычисляет ожидаемую подпись для любого payload и секрета.

При фиксированных теле и секрете qwerty123 подпись детерминирована. Это тело:
{"event":"payment.refunded","event_version":"1","delivery_id":"c09a3647-7822-4899-8401-3857cbd5bf68","attempt_number":1,"source":"xpayment","payment_id":"65cd0d84-5f66-4763-94d2-1b8e1ba68347","merchant_order_id":"qweasdxzc","created_at":"2026-06-27T10:24:04.104055991+05:00"}
с секретом qwerty123 даёт:
aeafc0dcd86de4d4d376f525be2b1175520c2818c93938ec8d069957c63a373b
Если ваш код воспроизводит это значение — проверка реализована верно.
Пересчитайте HMAC по «сырому» телу и сравните с заголовком, используя сравнение за постоянное время (обычное == утекает информацию о времени, которая помогает подобрать подпись побайтно).
const crypto = require("crypto")
function verify(rawBody, signature, secret) {
const expected = crypto.createHmac("sha256", secret).update(rawBody).digest("hex")
const a = Buffer.from(signature || "", "hex")
const b = Buffer.from(expected, "hex")
return a.length === b.length && crypto.timingSafeEqual(a, b)
}
// Сохраняем «сырое» тело, чтобы хешировать именно полученные байты:
app.use(express.json({ verify: (req, _res, buf) => { req.rawBody = buf } }))
app.post("/webhook", (req, res) => {
const sig = req.get("X-xPayment-Signature")
if (!verify(req.rawBody, sig, process.env.WEBHOOK_SECRET)) {
return res.status(401).end()
}
// подпись верна — обрабатываем req.body
res.status(200).json({ ok: true })
})
func verify(rawBody []byte, signature, secret string) bool {
mac := hmac.New(sha256.New, []byte(secret))
mac.Write(rawBody)
expected := hex.EncodeToString(mac.Sum(nil))
return hmac.Equal([]byte(signature), []byte(expected))
}
func handler(w http.ResponseWriter, r *http.Request) {
body, err := io.ReadAll(r.Body)
if err != nil {
w.WriteHeader(http.StatusBadRequest)
return
}
if !verify(body, r.Header.Get("X-xPayment-Signature"), os.Getenv("WEBHOOK_SECRET")) {
w.WriteHeader(http.StatusUnauthorized)
return
}
// подпись верна — разбираем тело и обрабатываем
w.WriteHeader(http.StatusOK)
}
import hmac, hashlib
from flask import request
def verify(raw_body: bytes, signature: str, secret: str) -> bool:
expected = hmac.new(secret.encode(), raw_body, hashlib.sha256).hexdigest()
return hmac.compare_digest(signature, expected)
@app.post("/webhook")
def webhook():
# request.get_data() возвращает «сырые» байты — НЕ используйте здесь request.json
if not verify(request.get_data(), request.headers.get("X-xPayment-Signature", ""), WEBHOOK_SECRET):
return "", 401
# подпись верна — теперь request.get_json() можно использовать безопасно
return {"ok": True}, 200
Чтобы просто узнать ожидаемую подпись для заданного payload и секрета, используйте проверщик секрета вебхука — шаг 5 показывает итоговый хеш, с которым должен совпасть ваш код:

timingSafeEqual, hmac.Equal, hmac.compare_digest).401, если подпись отсутствует или неверна.delivery_id для защиты от повторов.Секрет — единственное, что придаёт подписям смысл. Относитесь к нему как к паролю.
Используйте длинное случайное значение — не менее 32 случайных символов. Сгенерируйте его, а не придумывайте:
openssl rand -hex 32
Используйте отдельный секрет для каждой подписки, чтобы отзыв одного эндпоинта не затрагивал другие.
Пустой секрет не даёт реальной защиты — тело всё равно «подписывается», но на известном пустом ключе, поэтому подпись может воспроизвести кто угодно. Всегда задавайте надёжный секрет.
Меняйте секрет при подозрении на утечку или периодически — для гигиены. Обновите секрет в подписке, затем обновите значение, которое использует ваш эндпоинт.
Во время ротации есть короткое окно, когда уже отправленные доставки могут быть подписаны старым секретом. Для ротации без простоя временно принимайте либо старую, либо новую подпись, а старую отключите после того, как убедитесь, что весь трафик использует новый секрет.
Проверка подписи — ваш основной механизм. Поверх него добавьте:
delivery_id и игнорируйте повторы (защита от replay и случайной двойной обработки).