Пошук уроків, статей та іншого контенту
Проєктуйте приймання та надсилання webhooks із перевіркою підпису, повторними спробами й ідемпотентністю.
Webhook — це HTTP-запит, який один сервіс надсилає іншому після певної події.
Наприклад, платіжний сервіс може надіслати:
{
"id": "evt_123",
"type": "payment.succeeded",
"data": {
"orderId": "order_42",
"amount": 12500
}
}На відміну від звичайного API-запиту, webhook ініціює зовнішній сервіс. Тому отримувач має врахувати:
запит може бути підроблений;
один і той самий webhook може прийти кілька разів;
запит може прийти не в порядку створення подій;
відправник може повторювати запит після помилки або тайм-ауту;
обробка може тривати довше, ніж час очікування відправника.
Надійна webhook-інтеграція зазвичай має такі властивості:
перевіряє криптографічний підпис;
швидко відповідає HTTP-кодом 2xx;
обробляє повторні події ідемпотентно;
використовує повторні спроби під час надсилання;
зберігає ідентифікатор події та результат її обробки.
Найпоширеніший варіант — HMAC-підпис на основі спільного секрету.
Відправник і отримувач заздалегідь знають один секрет:
WEBHOOK_SECRET=very-secret-valueВідправник створює підпис для:
timestamp.rawBodyде:
timestamp — Unix-час у секундах;
rawBody — точні байти HTTP-тіла;
між ними використовується крапка.
Підпис обчислюється так:
HMAC-SHA256(secret, timestamp + "." + rawBody)Результат можна передати в заголовках:
X-Webhook-Id: evt_123
X-Webhook-Timestamp: 1710000000
X-Webhook-Signature: sha256=...Підпис обчислюється не над JavaScript-об'єктом, а над конкретним текстом запиту.
Ці два JSON-документи мають однакові дані, але різні байти:
{"id":"evt_123","active":true}{
"id": "evt_123",
"active": true
}Якщо спочатку виконати JSON.parse(), а потім JSON.stringify(), початкове представлення може змінитися. Підпис перестане збігатися.
Тому порядок дій має бути таким:
прочитати тіло як Buffer;
перевірити підпис;
перевірити час створення запиту;
лише після цього розібрати JSON.
Підпис сам по собі не забороняє зловмиснику повторно надіслати старий, але справжній запит.
Тому разом із підписом перевіряють timestamp:
Math.abs(currentTime - requestTime) <= allowedSkewНаприклад, можна приймати запити, створені не більше ніж 5 хвилин тому. Допустимий інтервал залежить від вимог системи та синхронізації часу.
Для порівняння підписів потрібно використовувати timingSafeEqual, а не звичайне ===. Це зменшує ризик атак за часом виконання порівняння.
Webhook-відправник не знає напевно, чи отримувач обробив запит. Наприклад, сервер може виконати бізнес-операцію, але відповідь загубиться через мережеву помилку.
Тоді відправник повторить той самий webhook.
Тому обробка має бути ідемпотентною: повторна обробка тієї самої події не повинна повторно створити платіж, замовлення або іншу побічну дію.
Для цього використовується унікальний ідентифікатор події:
event_id = evt_123Типовий алгоритм:
отримати event_id;
атомарно перевірити, чи вже існує така подія;
якщо існує — не виконувати дію повторно;
якщо не існує — зберегти подію та виконати обробку.
У production-системі ідемпотентність потрібно зберігати в постійному сховищі:
унікальний індекс у PostgreSQL;
запис у Redis із контрольованим часом життя;
окрема таблиця webhook-подій.
Map у пам'яті підходить лише для демонстрації: після перезапуску процесу вона спорожніє, і подія може бути оброблена повторно.
Відправник зазвичай вважає webhook успішно прийнятим, якщо отримує код із діапазону 2xx.
Практична схема:
перевірити заголовки та підпис;
перевірити ідемпотентність;
покласти подію в чергу або базу даних;
одразу повернути 202 Accepted;
обробити подію у фоновому worker-процесі.
Якщо webhook уже обробляється або був оброблений раніше, також можна повернути 200 чи 202. Повторний webhook не повинен спричиняти помилку лише через те, що його event_id вже відомий.
Не слід повертати 2xx, якщо запит:
має неправильний підпис;
не містить обов'язкового ідентифікатора;
має некоректний JSON;
перевищує дозволений розмір.
Для таких випадків використовують 4xx. Зазвичай відправник не повинен безкінечно повторювати незмінний некоректний запит.
Нижче наведено приклад без зовнішніх пакетів. Він:
читає сире тіло запиту;
перевіряє HMAC-SHA256;
перевіряє timestamp;
захищає від повторної обробки в межах процесу;
повертає 202 Accepted;
містить функцію надсилання webhook із повторними спробами.
Збережіть код у файлі webhook-demo.mjs і запустіть:
WEBHOOK_SECRET=very-secret-value node webhook-demo.mjsimport http from "node:http";
import crypto from "node:crypto";
const PORT = Number(process.env.PORT || 3000);
const WEBHOOK_SECRET = process.env.WEBHOOK_SECRET || "very-secret-value";
const MAX_BODY_SIZE = 1024 * 1024;
const MAX_TIMESTAMP_SKEW_SECONDS = 5 * 60;
// У production це має бути PostgreSQL, Redis або інше постійне сховище.
const receivedEvents = new Map();
function readRawBody(request, limit) {
return new Promise((resolve, reject) => {
const chunks = [];
let totalSize = 0;
let settled = false;
request.on("data", (chunk) => {
totalSize += chunk.length;
if (totalSize > limit) {
if (!settled) {
settled = true;
reject(new Error("BODY_TOO_LARGE"));
}
// Продовжуємо читати потік, щоб коректно завершити HTTP-запит.
request.resume();
return;
}
chunks.push(chunk);
});
request.on("end", () => {
if (!settled) {
settled = true;
resolve(Buffer.concat(chunks));
}
});
request.on("error", (error) => {
if (!settled) {
settled = true;
reject(error);
}
});
});
}
function isValidSignature(rawBody, timestampHeader, signatureHeader) {
const timestamp = Number(timestampHeader);
if (!Number.isInteger(timestamp)) {
return false;
}
const currentTimestamp = Math.floor(Date.now() / 1000);
if (
Math.abs(currentTimestamp - timestamp) >
MAX_TIMESTAMP_SKEW_SECONDS
) {
return false;
}
const signedPayload = `${timestamp}.${rawBody.toString("utf8")}`;
const expectedSignature = crypto
.createHmac("sha256", WEBHOOK_SECRET)
.update(signedPayload)
.digest("hex");
const receivedSignature = String(signatureHeader || "").replace(
/^sha256=/,
""
);
const expectedBuffer = Buffer.from(expectedSignature, "utf8");
const receivedBuffer = Buffer.from(receivedSignature, "utf8");
if (expectedBuffer.length !== receivedBuffer.length) {
return false;
}
return crypto.timingSafeEqual(expectedBuffer, receivedBuffer);
}
async function processEvent(event) {
// Тут може бути запис у базу даних, оновлення замовлення тощо.
console.log("Обробка події:", event.id, event.type);
await new Promise((resolve) => setTimeout(resolve, 100));
console.log("Подію оброблено:", event.id);
}
const server = http.createServer(async (request, response) => {
if (request.method !== "POST" || request.url !== "/webhooks/payment") {
response.writeHead(404, { "Content-Type": "application/json" });
response.end(JSON.stringify({ error: "Not found" }));
return;
}
let rawBody;
try {
rawBody = await readRawBody(request, MAX_BODY_SIZE);
} catch (error) {
if (error.message === "BODY_TOO_LARGE") {
response.writeHead(413, { "Content-Type": "application/json" });
response.end(JSON.stringify({ error: "Request body is too large" }));
return;
}
response.writeHead(400, { "Content-Type": "application/json" });
response.end(JSON.stringify({ error: "Cannot read request body" }));
return;
}
const timestampHeader = request.headers["x-webhook-timestamp"];
const signatureHeader = request.headers["x-webhook-signature"];
const eventId = request.headers["x-webhook-id"];
if (!eventId || !timestampHeader || !signatureHeader) {
response.writeHead(400, { "Content-Type": "application/json" });
response.end(JSON.stringify({ error: "Missing webhook headers" }));
return;
}
if (
!isValidSignature(rawBody, timestampHeader, signatureHeader)
) {
response.writeHead(401, { "Content-Type": "application/json" });
response.end(JSON.stringify({ error: "Invalid webhook signature" }));
return;
}
let event;
try {
event = JSON.parse(rawBody.toString("utf8"));
} catch {
response.writeHead(400, { "Content-Type": "application/json" });
response.end(JSON.stringify({ error: "Invalid JSON" }));
return;
}
if (event.id !== eventId) {
response.writeHead(400, { "Content-Type": "application/json" });
response.end(JSON.stringify({ error: "Event ID mismatch" }));
return;
}
if (receivedEvents.has(eventId)) {
response.writeHead(200, { "Content-Type": "application/json" });
response.end(JSON.stringify({ received: true, duplicate: true }));
return;
}
// У production ця перевірка та вставка мають бути атомарною.
receivedEvents.set(eventId, {
status: "accepted",
receivedAt: new Date().toISOString()
});
// У production тут краще додати подію до надійної черги.
setImmediate(async () => {
try {
await processEvent(event);
const savedEvent = receivedEvents.get(eventId);
if (savedEvent) {
savedEvent.status = "processed";
}
} catch (error) {
console.error("Помилка обробки події:", eventId, error);
const savedEvent = receivedEvents.get(eventId);
if (savedEvent) {
savedEvent.status = "failed";
}
// Після 202 зовнішній сервіс уже може не повторити запит.
// Тому production-черга повинна мати власні повторні спроби.
}
});
response.writeHead(202, { "Content-Type": "application/json" });
response.end(JSON.stringify({ received: true }));
});
server.listen(PORT, () => {
console.log(`Webhook-сервер слухає порт ${PORT}`);
});
function sleep(milliseconds) {
return new Promise((resolve) => setTimeout(resolve, milliseconds));
}
function shouldRetryStatus(statusCode) {
return (
statusCode === 408 ||
statusCode === 425 ||
statusCode === 429 ||
statusCode >= 500
);
}
function getRetryAfterMilliseconds(value) {
if (!value) {
return null;
}
const seconds = Number(value);
if (Number.isFinite(seconds) && seconds >= 0) {
return seconds * 1000;
}
const date = Date.parse(value);
if (!Number.isNaN(date)) {
return Math.max(0, date - Date.now());
}
return null;
}
export async function sendWebhook({
url,
event,
secret = WEBHOOK_SECRET,
maxAttempts = 5
}) {
const eventId = event.id || crypto.randomUUID();
const body = JSON.stringify({ ...event, id: eventId });
for (let attempt = 1; attempt <= maxAttempts; attempt += 1) {
const timestamp = Math.floor(Date.now() / 1000);
const signature = crypto
.createHmac("sha256", secret)
.update(`${timestamp}.${body}`)
.digest("hex");
try {
const result = await fetch(url, {
method: "POST",
headers: {
"content-type": "application/json",
"x-webhook-id": eventId,
"x-webhook-timestamp": String(timestamp),
"x-webhook-signature": `sha256=${signature}`
},
body,
signal: AbortSignal.timeout(5000)
});
if (result.ok) {
return {
eventId,
status: result.status,
attempts: attempt
};
}
if (!shouldRetryStatus(result.status) || attempt === maxAttempts) {
throw new Error(`Webhook failed with HTTP ${result.status}`);
}
const retryAfter = getRetryAfterMilliseconds(
result.headers.get("retry-after")
);
const exponentialDelay = 500 * 2 ** (attempt - 1);
const jitter = Math.floor(Math.random() * 250);
const delay = retryAfter ?? exponentialDelay + jitter;
console.warn(
`Webhook отримав ${result.status}; повтор через ${delay} мс`
);
await sleep(delay);
} catch (error) {
if (attempt === maxAttempts) {
throw error;
}
const exponentialDelay = 500 * 2 ** (attempt - 1);
const jitter = Math.floor(Math.random() * 250);
console.warn(
`Помилка надсилання webhook: ${error.message}; ` +
`повтор через ${exponentialDelay + jitter} мс`
);
await sleep(exponentialDelay + jitter);
}
}
throw new Error("Webhook was not delivered");
}Функція sendWebhook() використовує той самий eventId для всіх спроб. Це важливо: отримувач може розпізнати повторну доставку як ту саму подію.
Для імпорту функції з іншого файлу:
import { sendWebhook } from "./webhook-demo.mjs";
await sendWebhook({
url: "http://localhost:3000/webhooks/payment",
event: {
id: "evt_1001",
type: "payment.succeeded",
data: {
orderId: "order_42",
amount: 12500
}
}
});Повторювати webhook потрібно не для кожної помилки.
Зазвичай повторні спроби доречні для:
мережевої помилки;
тайм-ауту;
408 Request Timeout;
425 Too Early;
429 Too Many Requests;
кодів 5xx.
Не потрібно автоматично повторювати запит для більшості кодів 4xx, наприклад:
400 Bad Request;
401 Unauthorized;
403 Forbidden;
404 Not Found.
Такі відповіді часто означають, що сам запит неправильний, і повторення без зміни даних не допоможе.
Замість миттєвих повторів використовується експоненційна затримка:
500 мс
1000 мс
2000 мс
4000 мс
8000 мсДо затримки додають випадковий jitter. Це не дає великій кількості відправників повторити запит одночасно після спільного збою.
Якщо отримувач повернув заголовок Retry-After, відправник має врахувати його, особливо для відповіді 429.
Перевірка через has() і подальший set() у Map не є атомарною для розподіленої системи. Якщо одночасно працює кілька екземплярів Node.js, два процеси можуть одночасно не знайти подію та обробити її двічі.
У базі даних потрібна унікальна гарантія. Наприклад, таблиця може містити:
webhook_events
--------------
event_id UNIQUE
event_type
payload
status
received_at
processed_atЛогіка має виглядати так:
виконати вставку з унікальним event_id;
якщо вставка не вдалася через конфлікт унікальності — подія вже прийнята;
якщо вставка успішна — додати подію до обробки;
змінити статус після успішної бізнес-операції.
Важливо продумати межі транзакції. Запис про подію та зміна бізнес-даних мають бути узгоджені. Інакше процес може:
зберегти event_id;
впасти до зміни замовлення;
отримати повторну подію;
помилково пропустити її через уже збережений event_id.
Для складніших сценаріїв застосовують транзакцію бази даних, таблицю подій і worker із повторними спробами.
202Після відправлення 202 Accepted зовнішній сервіс вважає webhook прийнятим. Якщо фоновий worker завершиться з помилкою, зовнішній сервіс може вже не надіслати подію повторно.
Тому при асинхронній схемі потрібні:
надійне збереження payload;
черга або таблиця завдань;
статус обробки;
внутрішні повторні спроби;
обмеження кількості спроб;
журнал помилок або dead-letter queue.
Відповідь 202 означає: «подію прийнято для подальшої обробки», а не «бізнес-операція гарантовано завершена».
Webhook endpoint слід захищати так само уважно, як і інші публічні API:
використовувати HTTPS;
перевіряти HMAC-підпис;
перевіряти timestamp;
обмежувати розмір тіла;
не довіряти event.type, event.id та іншим полям без перевірки формату;
не записувати секрети в логи;
не повертати зайві деталі внутрішніх помилок;
за можливості обмежити джерела запитів на рівні мережі;
підтримувати ротацію секретів.
Підпис підтверджує цілісність запиту та знання секрету, але не замінює валідацію JSON і бізнес-правил.
Неправильно:
const event = JSON.parse(requestBody);
const body = JSON.stringify(event);
verifySignature(body);Після парсингу й повторної серіалізації байти можуть змінитися.
Правильно — перевіряти підпис на оригінальному Buffer, а потім викликати JSON.parse().
===Звичайне порівняння рядків не призначене для криптографічних підписів. Використовуйте crypto.timingSafeEqual() після перевірки однакової довжини буферів.
Навіть якщо відправник обіцяє не повторювати події, мережеві збої та тайм-аути все одно можуть спричинити повторну доставку.
Кожна побічна дія webhook має бути захищена унікальним ідентифікатором події.
Якщо під час кожної спроби створювати новий event_id, отримувач сприйматиме повторну доставку як нову подію. Ідентифікатор повинен залишатися незмінним у межах однієї події.
Без обмеження кількості спроб один недоступний endpoint може створити нескінченний потік запитів. Використовуйте максимальну кількість спроб, backoff і журналювання невдалих подій.
Якщо endpoint чекає завершення складної бізнес-операції перед відповіддю, відправник може отримати тайм-аут і повторити webhook. Краще швидко зберегти подію та передати її worker-процесу.
Map або звичайний об'єкт не зберігають ідемпотентність після перезапуску та не синхронізуються між екземплярами застосунку. Для production потрібне спільне постійне сховище.
Webhook — це зовнішній HTTP-запит про подію.
Підпис потрібно перевіряти на оригінальному сирому тілі запиту.
HMAC-підпис слід порівнювати через timingSafeEqual.
Timestamp захищає від повторного відтворення старого запиту.
event_id використовується для ідемпотентності.
Повторні спроби мають застосовуватися для мережевих помилок, 429 і 5xx.
Exponential backoff і jitter зменшують навантаження під час збоїв.
Для асинхронної обробки потрібна надійна черга або база даних.
Ідемпотентність у production повинна бути атомарною та спільною для всіх екземплярів застосунку.
Відповідь 2xx означає приймання webhook, а не обов'язково завершення бізнес-операції.