Перейти к содержанию

Webhooks: подпись

Каждый webhook содержит заголовок:

X-Truenum-Signature: t=<unix_timestamp>,v1=<hex_hmac_sha256>

где:

  • t — Unix timestamp в секундах, когда подпись была вычислена;
  • v1 — HMAC-SHA256 в шестнадцатеричной форме от <timestamp>.<raw_body>, с ключом — вашим webhook secret.

Формат полностью совместим со схемой Stripe (различается только имя заголовка). Если у вас уже есть код для Stripe — адаптация минимальна.

Откуда взять секрет

Секрет — отдельная сущность от API-ключа

Webhook secret привязан к компании, а не к API-ключу. Все webhook'и одной компании подписываются одним и тем же секретом, независимо от того, каким API-ключом была создана верификация.

Дашборд → WebhooksRotate secret:

  1. Нажмите Rotate. Откроется модальное окно с новым секретом.
  2. Сохраните секрет сразу — повторно увидеть его нельзя.
  3. Старый секрет инвалидируется немедленно в момент ротации.

Простой webhook-приёма при ротации

Между ротацией и обновлением секрета в продакшене все приходящие webhook'и не пройдут вашу верификацию. Планируйте ротацию на окно низкой нагрузки или сделайте безпростойный deploy: сначала залить новый секрет в env (приёмник принимает оба), потом ротировать, потом удалить старый.

Храните секрет как любой prod-секрет: env-переменная, секрет-менеджер.

Формат секрета:

whsec_<≥32 alphanumeric>

Префикс whsec_ — для отличия от API-ключа tn_live_*. Не путайте.

Алгоритм проверки

  1. Извлеките значение заголовка X-Truenum-Signature.
  2. Распарсите пары t=...,v1=....
  3. Проверьте, что |now - t| <= max_age (рекомендуется 300 секунд) — защита от replay-атак.
  4. Вычислите ожидаемый HMAC-SHA256 от строки <t>.<raw_body> (с точкой между timestamp и телом!) с ключом-секретом.
  5. Сравните constant-time с v1 из заголовка.
  6. Если совпало и 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-приёмника:

  1. Опубликуйте свой localhost:PORT через ngrok / cloudflared:
    ngrok http 8080
    
  2. Используйте полученный https://<random>.ngrok.io/webhook как webhook_url.
  3. Создайте верификацию и совершите звонок — webhook прилетит на ваш localhost через туннель.

Тестовая верификация подписи без реального звонка: возьмите тело webhook'а из дашборда → WebhooksDeliveries → конкретная доставка → Copy raw body + signature. Прогоните через свой verify-код локально.

Что делать при неудачной верификации

Если подпись не сошлась:

  • Не доверяйте телу.
  • Ответьте 403 Forbidden.
  • В логах зафиксируйте: время прихода, заголовок, hash тела, возможно — verification_id (если смогли его извлечь без парсинга и без доверия).
  • Если подобные запросы повторяются — возможна попытка форджа или у вас рассинхрон секрета после ротации.

Если уверены, что секрет верный, но подпись не сходится — частые причины:

  1. Тело прошло через мидлвар, который изменил байты (например, re-serialize JSON, normalize whitespace). Используйте raw body.
  2. Прокси перед вашим приложением переписывает Content-Type или меняет encoding.
  3. Вы вычисляете HMAC от тела без префикса <t>. — это самая частая ошибка. Проверьте.