Webhooks: подпись¶
Каждый webhook содержит заголовок:
где:
t— Unix timestamp в секундах, когда подпись была вычислена;v1— HMAC-SHA256 в шестнадцатеричной форме от<timestamp>.<raw_body>, с ключом — вашим webhook secret.
Формат полностью совместим со схемой Stripe (различается только имя заголовка). Если у вас уже есть код для Stripe — адаптация минимальна.
Откуда взять секрет¶
Секрет — отдельная сущность от API-ключа
Webhook secret привязан к компании, а не к API-ключу. Все webhook'и одной компании подписываются одним и тем же секретом, независимо от того, каким API-ключом была создана верификация.
Дашборд → Webhooks → Rotate secret:
- Нажмите Rotate. Откроется модальное окно с новым секретом.
- Сохраните секрет сразу — повторно увидеть его нельзя.
- Старый секрет инвалидируется немедленно в момент ротации.
Простой webhook-приёма при ротации
Между ротацией и обновлением секрета в продакшене все приходящие webhook'и не пройдут вашу верификацию. Планируйте ротацию на окно низкой нагрузки или сделайте безпростойный deploy: сначала залить новый секрет в env (приёмник принимает оба), потом ротировать, потом удалить старый.
Храните секрет как любой prod-секрет: env-переменная, секрет-менеджер.
Формат секрета:
Префикс whsec_ — для отличия от API-ключа tn_live_*. Не путайте.
Алгоритм проверки¶
- Извлеките значение заголовка
X-Truenum-Signature. - Распарсите пары
t=...,v1=.... - Проверьте, что
|now - t| <= max_age(рекомендуется 300 секунд) — защита от replay-атак. - Вычислите ожидаемый HMAC-SHA256 от строки
<t>.<raw_body>(с точкой между timestamp и телом!) с ключом-секретом. - Сравните constant-time с
v1из заголовка. - Если совпало и
max_ageпройден — подпись валидна.
Сравнивайте через compare_digest
Не используйте обычное == для сравнения подписей — это утечка
через timing side-channel. В каждом примере ниже использована
constant-time функция конкретного языка.
Тело — сырое, до парсинга
HMAC считается по байтам тела до JSON-парсинга. Если вы сначала
распарсите JSON и потом сериализуете обратно — пробелы, порядок
ключей и unicode-эскейпинг могут отличаться, подпись не сойдётся.
Большинство веб-фреймворков позволяют получить raw body
(request.get_data() во Flask, request.body в Django, req.rawBody
или middleware в Express).
Примеры¶
import hashlib
import hmac
import os
import time
WEBHOOK_SECRET = os.environ["TRUENUM_WEBHOOK_SECRET"]
def verify(header_value: str, raw_body: bytes, max_age: int = 300) -> bool:
try:
parts = dict(p.split("=", 1) for p in header_value.split(",") if "=" in p)
ts = int(parts["t"])
v1 = parts["v1"]
except (KeyError, ValueError):
return False
if abs(time.time() - ts) > max_age:
return False
payload = f"{ts}.".encode() + raw_body
expected = hmac.new(
WEBHOOK_SECRET.encode(), payload, hashlib.sha256
).hexdigest()
return hmac.compare_digest(expected, v1)
# Django view:
# from django.http import HttpRequest, HttpResponse, HttpResponseForbidden
# def webhook(request: HttpRequest) -> HttpResponse:
# header = request.headers.get("X-Truenum-Signature", "")
# if not verify(header, request.body):
# return HttpResponseForbidden("Bad signature")
# ...
import crypto from "node:crypto";
const WEBHOOK_SECRET = process.env.TRUENUM_WEBHOOK_SECRET;
export function verify(headerValue, rawBody, maxAge = 300) {
const parts = Object.fromEntries(
headerValue.split(",").map((p) => p.split("=", 2)),
);
const ts = parseInt(parts.t, 10);
const v1 = parts.v1;
if (!ts || !v1) return false;
if (Math.abs(Date.now() / 1000 - ts) > maxAge) return false;
const payload = Buffer.concat([Buffer.from(`${ts}.`), rawBody]);
const expected = crypto
.createHmac("sha256", WEBHOOK_SECRET)
.update(payload)
.digest("hex");
const a = Buffer.from(expected, "hex");
const b = Buffer.from(v1, "hex");
if (a.length !== b.length) return false;
return crypto.timingSafeEqual(a, b);
}
// Express:
// app.post("/webhook", express.raw({ type: "application/json" }), (req, res) => {
// const header = req.get("X-Truenum-Signature") || "";
// if (!verify(header, req.body)) return res.sendStatus(403);
// const payload = JSON.parse(req.body.toString());
// ...
// });
package webhook
import (
"crypto/hmac"
"crypto/sha256"
"encoding/hex"
"os"
"strconv"
"strings"
"time"
)
var WebhookSecret = []byte(os.Getenv("TRUENUM_WEBHOOK_SECRET"))
func Verify(header string, rawBody []byte, maxAge time.Duration) bool {
var ts int64
var v1 string
for _, part := range strings.Split(header, ",") {
kv := strings.SplitN(part, "=", 2)
if len(kv) != 2 {
continue
}
switch kv[0] {
case "t":
n, err := strconv.ParseInt(kv[1], 10, 64)
if err != nil {
return false
}
ts = n
case "v1":
v1 = kv[1]
}
}
if ts == 0 || v1 == "" {
return false
}
diff := time.Since(time.Unix(ts, 0))
if diff < -maxAge || diff > maxAge {
return false
}
mac := hmac.New(sha256.New, WebhookSecret)
mac.Write([]byte(strconv.FormatInt(ts, 10) + "."))
mac.Write(rawBody)
expected := hex.EncodeToString(mac.Sum(nil))
return hmac.Equal([]byte(expected), []byte(v1))
}
<?php
function verify_webhook(string $header, string $rawBody, int $maxAge = 300): bool {
$parts = [];
foreach (explode(',', $header) as $kv) {
$pair = explode('=', $kv, 2);
if (count($pair) === 2) {
$parts[$pair[0]] = $pair[1];
}
}
if (!isset($parts['t'], $parts['v1'])) return false;
$ts = (int) $parts['t'];
if (abs(time() - $ts) > $maxAge) return false;
$secret = getenv('TRUENUM_WEBHOOK_SECRET');
$payload = $ts . '.' . $rawBody;
$expected = hash_hmac('sha256', $payload, $secret);
return hash_equals($expected, $parts['v1']);
}
// Использование (raw php):
// $rawBody = file_get_contents('php://input');
// $header = $_SERVER['HTTP_X_TRUENUM_SIGNATURE'] ?? '';
// if (!verify_webhook($header, $rawBody)) {
// http_response_code(403);
// exit;
// }
Тестирование локально¶
Для отладки локального webhook-приёмника:
- Опубликуйте свой
localhost:PORTчерез ngrok / cloudflared: - Используйте полученный
https://<random>.ngrok.io/webhookкакwebhook_url. - Создайте верификацию и совершите звонок — webhook прилетит на
ваш
localhostчерез туннель.
Тестовая верификация подписи без реального звонка: возьмите тело webhook'а из дашборда → Webhooks → Deliveries → конкретная доставка → Copy raw body + signature. Прогоните через свой verify-код локально.
Что делать при неудачной верификации¶
Если подпись не сошлась:
- Не доверяйте телу.
- Ответьте
403 Forbidden. - В логах зафиксируйте: время прихода, заголовок, hash тела,
возможно —
verification_id(если смогли его извлечь без парсинга и без доверия). - Если подобные запросы повторяются — возможна попытка форджа или у вас рассинхрон секрета после ротации.
Если уверены, что секрет верный, но подпись не сходится — частые причины:
- Тело прошло через мидлвар, который изменил байты (например, re-serialize JSON, normalize whitespace). Используйте raw body.
- Прокси перед вашим приложением переписывает Content-Type или меняет encoding.
- Вы вычисляете HMAC от тела без префикса
<t>.— это самая частая ошибка. Проверьте.