Проверка на потписите на webhooks

Проверувајте го заглавието X-Bilify-Signature за вашата адреса да прифаќа само вистински и свежи испораки. Готов код за PHP, Node.js и Python.

5 мин. читање Ажурирано 04.10.2026 Потребно: REST API и webhooks

Секој што ја знае вашата webhook адреса може да ѝ прати барање. Секоја вистинска испорака од Bilify е потпишана со тајниот клуч на адресата, па вашиот примач може да докаже дека барањето доаѓа од Bilify, дека не е изменето по пат и дека не е старо барање пратено повторно. Направете ја оваа проверка на секоја испорака пред да постапите по неа.

Заглавието со потпис

Секоја испорака носи едно заглавие:

X-Bilify-Signature: t=1791100800,v1=b5600c284432b9dfcb76fb21f58d9d0e044986b635576e2826d8ab096a0aacfa
Дел Значење
t Кога Bilify ја потпишал испораката, во Unix секунди
v1 HMAC-SHA256 од потпишаната содржина, како хексадецимален запис со мали букви

Потпишаната содржина е временскиот печат, точка и суровата содржина на барањето, точно онаква каква што пристигнала:

<t>.<сурова содржина>

Клучот е тајниот клуч за потпишување на адресата, целата вредност заедно со префиксот whsec_. Го видовте еднаш кога ја создадовте адресата; ако веќе го немате, користете Генерирај нов клуч во менито на адресата (видете Webhooks).

Како се проверува

  1. Прочитајте ги суровите бајти од содржината пред какво било парсирање на JSON. Ако парсираниот JSON повторно го претворите во текст, се менуваат празните места и знаците и потписот не се совпаѓа.
  2. Поделете го заглавието по ,, а секој дел по првото =. Земете ги t (само цифри) и вредноста или вредностите v1.
  3. Одбијте го барањето ако t е повеќе од 300 секунди (5 минути) оддалечено од часовникот на вашиот сервер, во која било насока. Часовникот на серверот нека биде синхронизиран (NTP).
  4. Пресметајте HMAC-SHA256(secret, t + "." + rawBody) како хексадецимален запис со мали букви.
  5. Споредете го со v1 со споредба во константно време. Никогаш не користете обично == за ова.
  6. Ако се совпаѓа, брзо одговорете со 2xx и обработете го настанот. Ако не, одговорете со 400 и игнорирајте ја содржината.

Пример за проверка

Со тајниот клуч whsec_example, t=1791100800 и содржината {"event":"client.created"} (без празни места и без нов ред), очекуваниот v1 е b5600c284432b9dfcb76fb21f58d9d0e044986b635576e2826d8ab096a0aacfa. Со него проверете го вашиот код, со исклучена проверка на времето.

PHP

<?php

function bilify_verify(string $payload, string $header, string $secret, int $tolerance = 300): bool
{
    $timestamp = null;
    $signatures = [];

    foreach (explode(',', $header) as $part) {
        [$key, $value] = array_pad(explode('=', trim($part), 2), 2, null);

        if ($key === 't' && $value !== null && ctype_digit($value)) {
            $timestamp = (int) $value;
        } elseif ($key === 'v1' && $value !== null && $value !== '') {
            $signatures[] = $value;
        }
    }

    if ($timestamp === null || $signatures === []) {
        return false;
    }

    if (abs(time() - $timestamp) > $tolerance) {
        return false;
    }

    $expected = hash_hmac('sha256', $timestamp.'.'.$payload, $secret);

    foreach ($signatures as $signature) {
        if (hash_equals($expected, $signature)) {
            return true;
        }
    }

    return false;
}

// Обична PHP адреса
$payload = file_get_contents('php://input');
$header = $_SERVER['HTTP_X_BILIFY_SIGNATURE'] ?? '';

if (! bilify_verify($payload, $header, getenv('BILIFY_WEBHOOK_SECRET'))) {
    http_response_code(400);
    exit;
}

$event = json_decode($payload, true);
http_response_code(200);
// Тука ставете ги $event['event'] / $event['data'] на ред за обработка.

Во Laravel, како содржина проследете $request->getContent(), а како заглавие $request->header('X-Bilify-Signature', ''), и исклучете ја рутата од CSRF заштитата.

Node.js (Express)

const crypto = require('crypto');
const express = require('express');

function verifyBilify(rawBody, header, secret, toleranceSeconds = 300) {
  if (!header) return false;

  let timestamp = null;
  const signatures = [];

  for (const part of header.split(',')) {
    const index = part.indexOf('=');
    if (index === -1) continue;
    const key = part.slice(0, index).trim();
    const value = part.slice(index + 1).trim();

    if (key === 't' && /^\d+$/.test(value)) timestamp = parseInt(value, 10);
    else if (key === 'v1' && value) signatures.push(value);
  }

  if (timestamp === null || signatures.length === 0) return false;
  if (Math.abs(Math.floor(Date.now() / 1000) - timestamp) > toleranceSeconds) return false;

  const expected = Buffer.from(
    crypto.createHmac('sha256', secret).update(`${timestamp}.`).update(rawBody).digest('hex'),
    'utf8'
  );

  return signatures.some((signature) => {
    const given = Buffer.from(signature, 'utf8');
    return given.length === expected.length && crypto.timingSafeEqual(given, expected);
  });
}

const app = express();

// express.raw ја чува содржината како Buffer: не користете express.json() на оваа рута.
app.post('/webhooks/bilify', express.raw({ type: 'application/json' }), (req, res) => {
  if (!verifyBilify(req.body, req.get('X-Bilify-Signature'), process.env.BILIFY_WEBHOOK_SECRET)) {
    return res.sendStatus(400);
  }

  const event = JSON.parse(req.body.toString('utf8'));
  res.sendStatus(200);
  // Тука обработете ги event.event / event.data.
});

app.listen(3000);

Python (Flask)

import hashlib
import hmac
import os
import time

from flask import Flask, abort, request


def verify_bilify(raw_body: bytes, header: str, secret: str, tolerance: int = 300) -> bool:
    if not header:
        return False
    timestamp = None
    signatures = []
    for part in header.split(","):
        key, sep, value = part.strip().partition("=")
        if not sep:
            continue
        if key == "t" and value.isdigit():
            timestamp = int(value)
        elif key == "v1" and value:
            signatures.append(value)
    if timestamp is None or not signatures:
        return False
    if abs(int(time.time()) - timestamp) > tolerance:
        return False
    signed = str(timestamp).encode() + b"." + raw_body
    expected = hmac.new(secret.encode(), signed, hashlib.sha256).hexdigest()
    return any(hmac.compare_digest(expected, s) for s in signatures)


app = Flask(__name__)


@app.post("/webhooks/bilify")
def bilify_webhook():
    raw = request.get_data()  # сурови бајти, прочитани пред парсирање на JSON
    if not verify_bilify(raw, request.headers.get("X-Bilify-Signature", ""), os.environ["BILIFY_WEBHOOK_SECRET"]):
        abort(400)
    event = request.get_json()
    # Тука обработете ги event["event"] / event["data"].
    return "", 200

Чести грешки

Симптом Причина
Ниеден потпис не поминува Содржината е парсирана и повторно серијализирана пред пресметката, или рамката додала нов ред. Пресметувајте врз суровите бајти.
Ниеден потпис не поминува откако генериравте нов клуч Примачот сè уште го има стариот клуч. Новиот клуч важи веднаш.
Потписот не поминува само понекогаш Часовникот на серверот отстапува повеќе од 5 минути, или прокси сервер ја менува содржината (на пример ги кодира знаците поинаку).
Не поминуваат само повторно испорачаните настани Повторните испораки се потпишуваат одново, со нов t и со тековниот клуч, па проверете го часовникот и клучот, а не оригиналниот временски печат.
Клучот изгледа точен, но HMAC се разликува Користете го целиот клуч заедно со whsec_, како текст, без base64 декодирање.