Щоб перевірити підпис вебхука, перерахуйте HMAC саме над тими байтами, які підписав відправник, зі спільним секретом і порівняйте своє значення із заголовком підпису за постійний час. Потім перевірте, що підписана мітка часу свіжа. Для вебхуків Elido це HMAC-SHA256 з ключем у вигляді всього вашого секрету whsec_..., над {X-Webhook-Timestamp}.{raw body}, у hex з префіксом v1=, зіставлений із X-Elido-Signature.
Ось і весь алгоритм. Збої виникають у деталях навколо: парсер тіла відпрацював надто рано, секрет декодували, hex-дайджест порівняли з base64. У цьому дописі розглянуто перевірку HMAC-підпису вебхука загалом, потім робочий код Elido для Node, Python, Go і Code-ноди n8n, а також вікно replay та ротацію секрету, які більшість швидких стартів опускає.
Якщо ви ще не налаштували endpoint, почніть із вебхуків для подій посилань, де перелічено всі типи подій та оболонку payload. Ця сторінка продовжує з моменту, коли підписаний запит потрапляє на ваш сервер.
Як працюють HMAC-підписи вебхуків
Endpoint вебхука - це публічний URL. Будь-хто, хто його знайде, може надіслати POST із JSON-тілом, схожим на справжню подію, тож отримувачу потрібен доказ походження. HMAC дає його дешево: відправник і отримувач мають спільний секрет, відправник обчислює HMAC-SHA256(secret, message) і кладе результат у заголовок, а отримувач робить те саме обчислення й порівнює. Без секрету ніхто не створить відповідного значення, а зміна одного байта повідомлення змінює весь дайджест.
Три деталі відрізняються між провайдерами, і кожна ламає перевірку, якщо помилитися:
- Що входить у повідомлення. GitHub підписує лише сире тіло. Stripe і Elido підписують мітку часу, крапку й тіло. Специфікація Standard Webhooks підписує ID повідомлення, мітку часу й тіло.
- Як закодовано дайджест. Hex або base64, з префіксом схеми на кшталт
v1=чиsha256=. - Що є ключем. Деякі провайдери base64-декодують секрет після префікса. Elido ні: ключ - це повний рядок
whsec_як байти UTF-8.
Мітка часу всередині підписаного повідомлення важлива. Вона не дає зловмиснику поєднати старе, дійсно підписане тіло зі свіжим заголовком мітки часу, а саме це й робить вікно replay придатним до застосування.
Що підписує Elido і які заголовки його несуть
Кожна доставка на endpoint типу event або siem - це POST із Content-Type: application/json і тілом форми {"type", "workspace_id", "data", "timestamp"}. Endpoint-и чат-типу (Discord, Telegram, Sentry) автентифікуються через свій URL і не несуть HMAC-заголовків, тож усе нижче стосується лише перших двох типів.
| Заголовок | Значення | Що з ним робити |
|---|---|---|
X-Elido-Signature | v1= + 64 hex-символи малими літерами | Порівняти з обчисленим вами значенням |
X-Webhook-Signature | Те саме значення | Старіший псевдонім; читайте будь-який, не обидва |
X-Webhook-Timestamp | Unix-секунди, напр. 1789000000 | Частина підписаного повідомлення; перевірте вік |
X-Elido-Signature-Previous | v1= + hex, підписано старим секретом | Лише під час пільгового вікна ротації |
X-Webhook-Event | Назва події, напр. link.created | Маршрутизація події (після перевірки) |
X-Webhook-Delivery | Числовий ID доставки, стабільний між повторами | Ключ дедуплікації для ідемпотентної обробки |
Секрет ви отримуєте під час створення: whsec_ і 64 hex-символи. Його повертає відповідь на створення один раз і більше ніколи, тож він іде просто у ваше сховище секретів.
Ось тестовий вектор, на якому можна запустити свій код: із секретом whsec_test_only_do_not_use, міткою часу 1789000000 і тілом {"type":"link.created","workspace_id":42} правильне значення заголовка таке:
v1=b9369aa411a8b7ce705bcd5bba112dea9d72d2e787aa88959ff62f33942d1a15
Відтворити його можна з оболонки, і це мій перший крок щоразу, коли отримувач не погоджується з відправником:
printf '%s.%s' 1789000000 '{"type":"link.created","workspace_id":42}' \
| openssl dgst -sha256 -hmac 'whsec_test_only_do_not_use' -r
Одна пастка: власне поле timestamp у тілі - це час, коли подія сталася, тоді як підписана мітка часу - та, що в заголовку X-Webhook-Timestamp, її задають під час надсилання запиту. Не плутайте їх.
Перевірка підпису вебхука в Node
В Express виправлення більшості збоїв - один рядок: підключіть express.raw() на маршруті вебхука, щоб req.body був Buffer з точними отриманими байтами. Реєструйте цей маршрут до будь-якого глобального app.use(express.json()), бо коли JSON-парсер спожив потік, сирому парсеру нічого читати.
import express from "express";
import { createHmac, timingSafeEqual } from "node:crypto";
const SECRET = process.env.ELIDO_WEBHOOK_SECRET; // the full whsec_... string
const TOLERANCE_SEC = 300;
function matches(expected, got) {
const a = Buffer.from(expected);
const b = Buffer.from(got ?? "");
return a.length === b.length && timingSafeEqual(a, b);
}
const app = express();
app.post(
"/webhooks/elido",
express.raw({ type: "application/json" }),
(req, res) => {
const ts = req.get("X-Webhook-Timestamp") ?? "";
if (
!/^\d+$/.test(ts) ||
Math.abs(Date.now() / 1000 - Number(ts)) > TOLERANCE_SEC
) {
return res.status(400).send("stale or missing timestamp");
}
const expected =
"v1=" +
createHmac("sha256", SECRET)
.update(`${ts}.`)
.update(req.body)
.digest("hex");
const ok = [
req.get("X-Elido-Signature"),
req.get("X-Elido-Signature-Previous"),
].some((got) => matches(expected, got));
if (!ok) return res.status(401).send("bad signature");
const event = JSON.parse(req.body.toString("utf8"));
// enqueue event, then acknowledge fast
res.sendStatus(200);
},
);
Перевірка довжини - не прикраса. timingSafeEqual кидає виняток на буферах різної довжини замість повернення false. Якщо ви користуєтеся TypeScript SDK зі швидкого старту API та SDK, webhooks.verify() у @elido/sdk робить той самий HMAC і порівняння за постійний час. Проте передавайте { maxSkewSec: 300 } явно: без цієї опції вік мітки часу взагалі не перевіряється.
Перевірка HMAC SHA256 вебхука в Python і Go
Стандартна бібліотека Python усе покриває. У FastAPI await request.body() повертає сирі байти; у Flask викличте request.get_data() до того, як щось торкнеться request.json.
import hashlib
import hmac
import json
import os
import time
from fastapi import FastAPI, HTTPException, Request
SECRET = os.environ["ELIDO_WEBHOOK_SECRET"].strip().encode()
TOLERANCE = 300
app = FastAPI()
def verify(raw: bytes, ts: str, candidates: list) -> bool:
if not ts.isdigit() or abs(time.time() - int(ts)) > TOLERANCE:
return False
digest = hmac.new(SECRET, ts.encode() + b"." + raw, hashlib.sha256).hexdigest()
expected = "v1=" + digest
return any(c and hmac.compare_digest(c, expected) for c in candidates)
@app.post("/webhooks/elido")
async def elido_webhook(request: Request):
raw = await request.body()
h = request.headers
sigs = [h.get("x-elido-signature"), h.get("x-elido-signature-previous")]
if not verify(raw, h.get("x-webhook-timestamp", ""), sigs):
raise HTTPException(status_code=401, detail="bad signature")
event = json.loads(raw)
# enqueue event
return {"ok": True}
У Go прочитайте тіло один раз, обмежте його розмір і використовуйте hmac.Equal з crypto/hmac, що порівнює за постійний час. Повідомлення я будую з рядка заголовка точно як отримано, а не переформатовую розібране ціле число.
package webhook
import (
"crypto/hmac"
"crypto/sha256"
"encoding/hex"
"io"
"net/http"
"strconv"
"time"
)
const toleranceSec = 300
// Verify returns the raw body when the request carries a valid Elido signature.
func Verify(w http.ResponseWriter, r *http.Request, secret []byte) ([]byte, bool) {
body, err := io.ReadAll(http.MaxBytesReader(w, r.Body, 1<<20))
if err != nil {
return nil, false
}
tsHeader := r.Header.Get("X-Webhook-Timestamp")
ts, err := strconv.ParseInt(tsHeader, 10, 64)
if err != nil {
return nil, false
}
if age := time.Now().Unix() - ts; age > toleranceSec || age < -toleranceSec {
return nil, false
}
mac := hmac.New(sha256.New, secret)
mac.Write([]byte(tsHeader + "."))
mac.Write(body)
expected := []byte("v1=" + hex.EncodeToString(mac.Sum(nil)))
for _, name := range []string{"X-Elido-Signature", "X-Elido-Signature-Previous"} {
if got := r.Header.Get(name); got != "" && hmac.Equal([]byte(got), expected) {
return body, true
}
}
return nil, false
}
Усі три версії відповідають 401 на поганий підпис і розбирають JSON лише після успішної перевірки. Elido рахує будь-яку не-2xx відповідь як невдалу спробу, тож якщо ваш верифікатор неправильний, справжні доставки накопичуються як 401 у журналі доставок endpoint-а. Це перше місце, куди дивитися після розгортання.
Хочете побачити це на живому трафіку? Створіть endpoint у робочому просторі зі сторінки функції вебхуків і спрямуйте його на локальний тунель; журнал доставок показує код статусу, який ваш верифікатор повернув на кожну спробу.
Перевірка підпису вебхука всередині Code-ноди n8n
n8n може зробити ту саму перевірку без додаткового сервісу. Увімкніть опцію Raw Body у ноді Webhook, що зберігає неторкнутий запит як бінарні дані, потім додайте Code-ноду одразу після неї. Вбудований модуль crypto дозволений у Code-ноді n8n, тож це працює як є:
const crypto = require("crypto");
const h = $input.first().json.headers;
const raw = await this.helpers.getBinaryDataBuffer(0, "data");
const ts = h["x-webhook-timestamp"] ?? "";
if (!/^\d+$/.test(ts) || Math.abs(Date.now() / 1000 - Number(ts)) > 300) {
throw new Error("stale delivery");
}
const expected = Buffer.from(
"v1=" +
crypto
.createHmac("sha256", $env.ELIDO_WEBHOOK_SECRET)
.update(`${ts}.`)
.update(raw)
.digest("hex"),
);
const ok = ["x-elido-signature", "x-elido-signature-previous"].some((name) => {
const got = Buffer.from(h[name] ?? "");
return (
got.length === expected.length && crypto.timingSafeEqual(got, expected)
);
});
if (!ok) throw new Error("bad signature");
return [{ json: JSON.parse(raw.toString("utf8")) }];
Викинута помилка зупиняє виконання, тож нічого далі не запуститься на підробленій події. Один нюанс self-hosting: якщо ваш екземпляр встановлює N8N_BLOCK_ENV_ACCESS_IN_NODE=true, Code-нода не може читати $env, секрет повертається порожнім і кожна доставка не проходить перевірку. Посібник із self-hosted n8n охоплює бік зворотного проксі, а допис про ноду скорочувача URL для n8n показує, що будувати, коли події почнуть надходити.
Чим Elido відрізняється від Stripe, GitHub і Standard Webhooks
Якщо ви писали верифікатор для іншого провайдера, не копіюйте його сліпо. Звірено з документацією кожного вендора 2026-10-25:
- Stripe кладе
t=і одне чи кілька значеньv1=в один заголовокStripe-Signature, підписуєtimestamp.bodyчерез HMAC-SHA256 у hex, а його бібліотеки за замовчуванням мають допуск п'ять хвилин (документація Stripe). Elido виносить мітку часу в окремий заголовок. - GitHub підписує лише сире тіло, надсилає
sha256=плюс hex вX-Hub-Signature-256і радить використовувати порівняння за постійний час (документація GitHub). Мітки часу для перевірки немає. - Standard Webhooks використовує
webhook-id,webhook-timestampіwebhook-signature, підписуєid.timestamp.body, кодує в base64 з префіксомv1,і залишає значення допуску вам (специфікація).X-Webhook-Deliveryв Elido відіграє роль ID, але не входить у підписане повідомлення.
Власна схема Elido - це signing.Sign в api-core: повідомлення - це Unix-мітка часу, крапка й тіло, ключ - рядок секрету як збережено, а результат - v1= плюс hex малими літерами. В Elido немає події link.clicked, тож не очікуйте доставок на рівні кліків у цьому каналі.
Вікна replay і ротація секрету
Дійсний підпис доводить, хто надіслав запит, але не коли. Хтось, хто перехопив одну підписану доставку, з рядка логу чи неправильно налаштованого проксі, може надіслати її знову за тиждень, і HMAC усе одно збігатиметься. Перевірка мітки часу закриває цю прогалину, і це ваша робота: воркер доставки Elido підписує мітку часу, але не застосовує жодного вікна на вашому боці. Я використовую 300 секунд. Тримайте годинник отримувача синхронізованим через NTP, бо сервер, що розійшовся на кілька хвилин, почне відхиляти чесний трафік.
Повторні спроби вікно не порушують. Кожна спроба отримує свіжий X-Webhook-Timestamp і новий підпис, а X-Webhook-Delivery лишається тим самим. Цей поділ дає обидва захисти: мітка часу обмежує, як довго перехоплений запит лишається придатним, а унікальний індекс за ID доставки не дає законній повторній спробі обробитися двічі. Допис про ліміти запитів та ідемпотентність розглядає той самий шаблон з боку вхідного API.
Ротація працює через POST /v1/workspaces/{workspace_id}/webhooks/{id}/rotate-secret або кнопку Rotate на сторінці endpoint-а. Відповідь містить новий секрет один раз, а також grace_window_days (7) і previous_expires_at. Протягом цих семи днів кожна доставка несе два підписи:
X-Elido-Signature, зроблений новим секретомX-Elido-Signature-Previous, зроблений старим
Тому кожен фрагмент вище звіряє обидва заголовки з єдиним секретом, який у нього є. Отримувач, що ще працює зі старим секретом, збігається з другим заголовком; після розгортання нового - з першим. Між ними нічого не ламається. Проте вважайте заголовок попереднього ключа мостом, а не гарантією, і розгортайте новий секрет на початку тижня.
Чому перевірка підпису вебхука не проходить
Коли я допомагаю з налагодженням, це майже щоразу той самий короткий список. Проходьте його по порядку:
- Повторно серіалізований JSON.
JSON.stringify(req.body)чиjson.dumps(payload)дає інші байти, ніж хешував відправник: порядок ключів, пробіли, екрановані слеші, unicode-екранування. Хешуйте сире тіло. Якщо ваш фреймворк уже його розібрав, виправте порядок middleware, а не намагайтеся відновити рядок. - Не ті байти ключа. Elido використовує весь рядок
whsec_...як ключ HMAC. Відрізання префікса, hex-декодування решти чи base64-декодування (що роблять бібліотеки Standard Webhooks) дає інший ключ. Кінцевий рядок нового рядка відechoу файл секретів робить те саме, тому приклад на Python викликає.strip(). - Невідповідність кодування. Порівнюйте
v1=плюс hex малими літерами із заголовком. Дайджест у base64, hex великими літерами чи відсутній префікс ніколи не збігатимуться. - Не та мітка часу. Використовуйте рядок заголовка
X-Webhook-Timestamp, а не полеtimestampтіла і не число, яке ви розібрали й переформатували.
Дві дрібніші: порівняння через == працює, але витікає за часом, тож використовуйте функцію постійного часу, яку постачає ваша мова; а проксі, що розпаковує чи перекодовує тіла, теж зламає перевірку, хоча для простих JSON POST це рідкість.
Коли обидві сторони все ще не погоджуються, залогуйте мітку часу, довжину тіла та перші кілька hex-символів вашого дайджесту, потім запустіть рядок openssl вище на тих самих входах. Та сторона, що збігається з openssl, правильна. Про те, де підпис стоїть серед інших контролів, які варто перевірити в будь-якого провайдера, дивіться чекліст безпеки скорочувача URL.
Читайте наріжну статтю: вебхуки для подій посилань.
Також у блозі
- Вебхуки для подій посилань - типи подій, оболонка payload і політика повторних спроб.
- Вебхуки проти опитування для відстеження кліків - коли push перемагає pull, а коли ні.
- Self-hosted автоматизація посилань з n8n - зворотний проксі, режим черги та перевірка підписів в одному стеку.
- API скорочувача URL: ліміти запитів, повторні спроби, ідемпотентність - шаблони дедуплікації з іншого боку.
- Чекліст безпеки скорочувача URL - дев'ять контролів для перевірки в будь-якого провайдера.
Поширені запитання
Як перевірити підпис вебхука?
Перерахуйте HMAC саме над тим, що підписав відправник, зі спільним секретом і порівняйте результат із заголовком підпису за постійний час. Для Elido це HMAC-SHA256 над значенням X-Webhook-Timestamp, крапкою та сирим тілом, у hex з префіксом v1=. Відхиліть запит, якщо нічого не збіглося або мітка часу застаріла.
Чому перевірка підпису вебхука постійно не проходить?
Майже завжди тому, що ви хешували інші байти, ніж відправник. Спершу відпрацював парсер JSON-тіла, а ви повторно серіалізували об'єкт, або декодували секрет, або порівняли hex із base64. Хешуйте сирі байти запиту, використовуйте рядок секрету точно як виданий і логуйте обидва значення поруч.
Що таке HMAC у вебхуку?
HMAC - це хеш із ключем: відправник підмішує спільний з вами секрет у SHA-256-хеш повідомлення. Лише власник секрету може створити відповідне значення, тож дійсний підпис доводить, що запит надійшов від відправника і тіло не змінили дорогою.
Як запобігти replay-атакам на вебхуки?
Підписуйте мітку часу разом із тілом і відхиляйте будь-який запит, мітка якого старша за кілька хвилин; п'ять хвилин - поширене вікно. Потім зберігайте ID доставки й пропускайте вже оброблені. Elido підписує мітку часу, але перевірку свіжості залишає вашому отримувачу.
Hex чи base64 для підпису вебхука HMAC-SHA256?
Те, що документує відправник, бо два кодування того самого дайджесту ніколи не збігаються. Elido надсилає hex малими літерами після префікса v1=. Shopify і специфікація Standard Webhooks використовують base64, а GitHub - hex після sha256=. Закодуйте свій дайджест так само, перш ніж порівнювати.
Як змінити секрет вебхука, не втративши події?
Користуйтеся відправником, що певний час підписує обома ключами. Після ротації секрету endpoint-а Elido кожна доставка несе X-Elido-Signature з новим ключем і X-Elido-Signature-Previous зі старим протягом семи днів. Приймайте будь-який із заголовків, розгорніть новий секрет, і старий закінчиться сам.
Спробуйте Elido
Вставте URL - отримайте коротке посилання
Без реєстрації. Посилання живе 30 днів. Зареєструйтесь, щоб зберегти назавжди.
Без реєстрації. Зареєструйтеся безкоштовно і отримайте 50 посилань на місяць.