Перевірка підписів
Кожну доставку підписано секретом ендпоінта. Перевіряйте підпис, перш ніж обробляти запит, щоб ніхто, хто дізнався URL вашого ендпоінта, не міг надсилати вам події.
Дві схеми підпису
Section titled “Дві схеми підпису”Доставка може мати два незалежні підписи того самого тіла. signatureScheme ендпоінта визначає, які саме:
signatureScheme |
Які заголовки надсилаються |
|---|---|
BOTH (типово) |
X-Signature і заголовки Standard Webhooks. |
LEGACY |
Лише X-Signature. |
STANDARD |
Лише webhook-id, webhook-timestamp, webhook-signature. |
Перевіряйте будь-який із двох. Залишайте ендпоінту одну схему лише тоді, коли другу вже ніхто не читає.
Заголовки кожної доставки
Section titled “Заголовки кожної доставки”| Заголовок | Значення |
|---|---|
X-Signature |
t=<unix-ms>,v1=<hex>. Під час ротації секрету може містити другий v1. |
X-Timestamp |
Те саме значення, що й t: коли підписано цю спробу, у мілісекундах. |
X-Event-Id |
Ідентифікатор події. Кожен підписаний на неї ендпоінт отримує той самий ідентифікатор. |
X-Delivery-Id |
Ідентифікатор цієї доставки: однієї події на один ендпоінт. Однаковий в усіх спробах. |
X-Sequence-Number |
Позиція цієї доставки, якщо ввімкнено впорядковану доставку, інакше 0. |
Idempotency-Key |
Однаковий в усіх спробах, тож за ним можна відкинути те, що ви вже обробили. |
webhook-id |
Ідентифікатор доставки. Входить до даних, які охоплює підпис Standard Webhooks. |
webhook-timestamp |
Коли підписано цю спробу, у секундах. |
webhook-signature |
v1,<base64>; якщо значень кілька, вони йдуть через пробіл. |
Перевірка X-Signature
Section titled “Перевірка X-Signature”v1 є HMAC-SHA256 від рядка <t>.<необроблене тіло> байт у байт із секретом ендпоінта як ключем, записаним у hex малими літерами. t задано в мілісекундах. SDK Railhook відхиляють мітку часу, яка відрізняється від поточного часу більш ніж на 300 секунд.
За допомогою SDK
Section titled “За допомогою SDK”import express from 'express';import { constructEvent } from '@railhook/node';
const app = express();
// express.raw, not express.json: the signature covers the bytes that arrived.app.post('/webhooks', express.raw({ type: 'application/json' }), (req, res) => { try { const event = constructEvent(req.body.toString('utf8'), req.headers, process.env.WEBHOOK_SECRET); // event.data is the parsed body; event.deliveryId is safe to deduplicate on. res.sendStatus(200); } catch { res.sendStatus(400); }});import os
from flask import Flask, requestfrom railhook import RailhookError, construct_event
app = Flask(__name__)
@app.route("/webhooks", methods=["POST"])def handle_webhook(): payload = request.get_data(as_text=True) try: event = construct_event(payload, dict(request.headers), os.environ["WEBHOOK_SECRET"]) except RailhookError: return "Invalid signature", 400 # event.data is the parsed body; event.delivery_id is safe to deduplicate on. return "OK", 200<?phpuse Railhook\Webhook;use Railhook\Exception\RailhookException;
$payload = file_get_contents('php://input');
try { $event = Webhook::constructEvent($payload, getallheaders(), getenv('WEBHOOK_SECRET')); // $event['data'] is the decoded body; $event['deliveryId'] is safe to deduplicate on. http_response_code(200);} catch (RailhookException $e) { http_response_code(400);}Без SDK
Section titled “Без SDK”Читайте всі v1 у заголовку, а не лише перший.
import crypto from 'node:crypto';
export function verify(rawBody, header, secret) { let t; const candidates = []; for (const part of header.split(',')) { const [key, value] = part.split('=', 2); if (key === 't') t = value; if (key === 'v1') candidates.push(value); } if (!t || candidates.length === 0) return false; if (Math.abs(Date.now() - Number(t)) > 5 * 60 * 1000) return false;
const expected = Buffer.from( crypto.createHmac('sha256', secret).update(`${t}.${rawBody}`).digest('hex'), ); let ok = false; for (const candidate of candidates) { const given = Buffer.from(candidate); if (given.length === expected.length && crypto.timingSafeEqual(given, expected)) ok = true; } return ok;}import hashlibimport hmacimport time
def verify(raw_body: bytes, header: str, secret: str) -> bool: t, candidates = None, [] for part in header.split(","): key, _, value = part.partition("=") if key == "t": t = value elif key == "v1": candidates.append(value) if t is None or not candidates: return False if abs(time.time() * 1000 - int(t)) > 5 * 60 * 1000: return False
expected = hmac.new(secret.encode(), f"{t}.".encode() + raw_body, hashlib.sha256).hexdigest() ok = False for candidate in candidates: if hmac.compare_digest(expected, candidate): ok = True return ok<?phpfunction verify(string $rawBody, string $header, string $secret): bool{ $t = null; $candidates = []; foreach (explode(',', $header) as $part) { [$key, $value] = array_pad(explode('=', $part, 2), 2, ''); if ($key === 't') { $t = $value; } elseif ($key === 'v1') { $candidates[] = $value; } } if ($t === null || $candidates === []) { return false; } if (abs((int) (microtime(true) * 1000) - (int) $t) > 300000) { return false; }
$expected = hash_hmac('sha256', $t . '.' . $rawBody, $secret); $ok = false; foreach ($candidates as $candidate) { if (hash_equals($expected, $candidate)) { $ok = true; } } return $ok;}Перевірка заголовків Standard Webhooks
Section titled “Перевірка заголовків Standard Webhooks”Вони відповідають відкритій специфікації Standard Webhooks, тож перевірити їх може будь-яка бібліотека для неї. Три відмінності від X-Signature:
- Підписують рядок
<webhook-id>.<webhook-timestamp>.<необроблене тіло>, тобто ідентифікатор теж входить до підпису. webhook-timestampзадано в секундах, а не в мілісекундах.- Хеш записано в base64, а не в hex, а ключем є
standardWebhooksSecretендпоінта у форматіwhsec_<base64>.
API повертає standardWebhooksSecret разом із secret під час створення ендпоінта й ротації секрету. SDK приймають і секрет без префікса й використовують його як є, тож не той секрет дасть помилку неправильного підпису, а не помилку формату.
import express from 'express';import { verifyStandardWebhook } from '@railhook/node';
app.post('/webhooks', express.raw({ type: 'application/json' }), (req, res) => { try { verifyStandardWebhook(req.body.toString('utf8'), req.headers, process.env.RAILHOOK_WHSEC); } catch { return res.sendStatus(400); } const data = JSON.parse(req.body.toString('utf8')); return res.sendStatus(204);});import os
from fastapi import HTTPException, Request, Responsefrom railhook import RailhookError, verify_standard_webhook
@app.post("/webhooks")async def receive(request: Request) -> Response: body = (await request.body()).decode() try: verify_standard_webhook(body, dict(request.headers), os.environ["RAILHOOK_WHSEC"]) except RailhookError: raise HTTPException(status_code=400) return Response(status_code=204)<?phpuse Railhook\Webhook;use Railhook\Exception\RailhookException;
$payload = file_get_contents('php://input');
try { Webhook::verifyStandardWebhook($payload, getallheaders(), getenv('RAILHOOK_WHSEC')); http_response_code(204);} catch (RailhookException $e) { http_response_code(400);}Ротація секрету без збоїв в отримувача
Section titled “Ротація секрету без збоїв в отримувача”-
Викличте
POST /api/v1/projects/{projectId}/endpoints/{id}/rotate-secret. Відповідь містить новийsecretіstandardWebhooksSecret. -
Протягом перехідного періоду ендпоінта (типово 24 години) кожну доставку підписують і новим, і старим секретом.
X-Signatureмістить по одномуv1для кожного секрету, аwebhook-signatureпо одному значенню через пробіл:X-Signature: t=1735689600000,v1=8f2a41c7…new,v1=3b90de55…retired -
Розгорніть новий секрет в отримувача протягом цього періоду. Після нього підписує лише новий секрет.