Пошук уроків, статей та іншого контенту
Створіть endpoint для webhook-подій, перевіряйте підпис запиту та враховуйте повторну доставку.
Webhook — це HTTP-запит, який зовнішній сервіс надсилає до вашого застосунку після певної події.
Наприклад:
платіжний сервіс повідомляє про успішну оплату;
система доставки надсилає оновлення статусу замовлення;
сервіс CI повідомляє про завершення збірки.
На відміну від звичайного API-запиту, webhook ініціює не ваш застосунок. Тому endpoint має бути готовим до:
запитів із непередбачуваним тілом;
повторної доставки тієї самої події;
підроблених запитів;
тимчасової недоступності вашого сервера;
великих або некоректних payload.
У Next.js endpoint для webhook можна створити за допомогою Route Handler у директорії app.
Наприклад:
app/
└── api/
└── webhooks/
└── orders/
└── route.tsТакий endpoint буде доступний за адресою:
POST /api/webhooks/ordersRoute Handler експортує функцію з назвою HTTP-методу:
export async function POST(request: Request) {
return Response.json({ received: true });
}Для webhook важливо отримати сире тіло запиту до того, як воно буде перетворене на JSON:
const rawBody = await request.text();Не слід одразу викликати:
const body = await request.json();Підпис зазвичай обчислюється на основі точних байтів оригінального тіла. Якщо спочатку розпарсити JSON, а потім серіалізувати його знову, форматування може змінитися:
порядок властивостей;
пробіли;
escape-послідовності;
символи переносу рядка.
Через це обчислений підпис не збігатиметься з підписом відправника.
Поширений підхід до автентифікації webhook — HMAC із секретним ключем.
Відправник:
бере секретний ключ;
обчислює HMAC для payload;
передає результат у заголовку запиту.
Сервер повторює це обчислення та порівнює значення.
У різних сервісів формат заголовків відрізняється. У цьому прикладі використаємо умовний формат:
x-webhook-signature: sha256=<hex-підпис>
x-webhook-timestamp: 1710000000
x-webhook-id: evt_123Підпис обчислюється для рядка:
<timestamp>.<raw body>У реальному проєкті алгоритм, назви заголовків і формат повідомлення повинні відповідати документації конкретного постачальника webhook.
Таке порівняння небажане:
if (expectedSignature === receivedSignature) {
// ...
}Для порівняння секретних значень краще використовувати timingSafeEqual. Він зменшує ризик атак, що використовують різницю в часі порівняння.
Важливо: timingSafeEqual працює лише з буферами однакової довжини. Тому довжину потрібно перевірити до виклику функції.
Нижче наведено приклад Route Handler для Next.js із:
читанням сирого тіла;
перевіркою HMAC-підпису;
перевіркою часу створення запиту;
перевіркою ідентифікатора події;
захистом від повторної обробки в межах одного процесу;
обробкою помилок.
// app/api/webhooks/orders/route.ts
import { createHmac, timingSafeEqual } from "node:crypto";
export const runtime = "nodejs";
const MAX_TIMESTAMP_AGE_SECONDS = 5 * 60;
// Демонстраційне сховище.
// У production його потрібно замінити на базу даних.
const processedEvents = new Set<string>();
const processingEvents = new Set<string>();
type OrderWebhookEvent = {
id: string;
type: "order.paid" | "order.cancelled";
data: {
orderId: string;
customerId: string;
};
};
function getHeader(request: Request, name: string): string | null {
return request.headers.get(name);
}
function isValidSignature(
rawBody: string,
timestamp: string,
receivedSignature: string,
secret: string,
): boolean {
const prefix = "sha256=";
if (!receivedSignature.startsWith(prefix)) {
return false;
}
const receivedHex = receivedSignature.slice(prefix.length);
if (!/^[0-9a-f]+$/i.test(receivedHex)) {
return false;
}
const signedPayload = `${timestamp}.${rawBody}`;
const expectedSignature = createHmac("sha256", secret)
.update(signedPayload, "utf8")
.digest("hex");
const expectedBuffer = Buffer.from(expectedSignature, "utf8");
const receivedBuffer = Buffer.from(receivedHex, "utf8");
if (expectedBuffer.length !== receivedBuffer.length) {
return false;
}
return timingSafeEqual(expectedBuffer, receivedBuffer);
}
function isRecentTimestamp(timestamp: string): boolean {
const timestampInSeconds = Number(timestamp);
if (!Number.isInteger(timestampInSeconds)) {
return false;
}
const currentTimeInSeconds = Math.floor(Date.now() / 1000);
const age = Math.abs(currentTimeInSeconds - timestampInSeconds);
return age <= MAX_TIMESTAMP_AGE_SECONDS;
}
async function handleOrderEvent(event: OrderWebhookEvent): Promise<void> {
switch (event.type) {
case "order.paid":
console.log(`Замовлення ${event.data.orderId} оплачено`);
// Тут може бути оновлення замовлення в базі даних.
return;
case "order.cancelled":
console.log(`Замовлення ${event.data.orderId} скасовано`);
// Тут може бути скасування замовлення в базі даних.
return;
default:
throw new Error(`Непідтримуваний тип події: ${event.type}`);
}
}
export async function POST(request: Request): Promise<Response> {
const secret = process.env.WEBHOOK_SECRET;
if (!secret) {
console.error("WEBHOOK_SECRET не налаштовано");
return Response.json(
{ error: "Webhook endpoint is not configured" },
{ status: 500 },
);
}
const rawBody = await request.text();
const signature = getHeader(request, "x-webhook-signature");
const timestamp = getHeader(request, "x-webhook-timestamp");
const eventId = getHeader(request, "x-webhook-id");
if (!signature || !timestamp || !eventId) {
return Response.json(
{ error: "Required webhook headers are missing" },
{ status: 400 },
);
}
if (!isRecentTimestamp(timestamp)) {
return Response.json(
{ error: "Webhook timestamp is expired" },
{ status: 400 },
);
}
const signatureIsValid = isValidSignature(
rawBody,
timestamp,
signature,
secret,
);
if (!signatureIsValid) {
return Response.json(
{ error: "Invalid webhook signature" },
{ status: 401 },
);
}
let event: OrderWebhookEvent;
try {
event = JSON.parse(rawBody) as OrderWebhookEvent;
} catch {
return Response.json(
{ error: "Request body is not valid JSON" },
{ status: 400 },
);
}
if (event.id !== eventId) {
return Response.json(
{ error: "Event ID does not match the request header" },
{ status: 400 },
);
}
if (processedEvents.has(eventId)) {
// Повторна доставка вже успішно обробленої події.
return Response.json({ received: true, duplicate: true });
}
if (processingEvents.has(eventId)) {
// Та сама подія вже обробляється паралельним запитом.
return Response.json(
{ received: true, processing: true },
{ status: 202 },
);
}
processingEvents.add(eventId);
try {
await handleOrderEvent(event);
processedEvents.add(eventId);
return Response.json({ received: true });
} catch (error) {
console.error("Помилка обробки webhook:", error);
// Дозволяємо повторити обробку після тимчасової помилки.
return Response.json(
{ error: "Webhook processing failed" },
{ status: 500 },
);
} finally {
processingEvents.delete(eventId);
}
}Секрет потрібно зберігати в змінній середовища:
WEBHOOK_SECRET=your-secret-valueСекрет не можна:
комітувати в репозиторій;
передавати у frontend-код;
додавати до публічних змінних на кшталт NEXT_PUBLIC_WEBHOOK_SECRET;
виводити в логи.
Навіть якщо підпис правильний, зловмисник може перехопити справжній запит і надіслати його повторно. Така атака називається replay attack.
Сам підпис підтверджує, що payload був створений із правильним секретом, але не гарантує, що запит новий.
Для захисту зазвичай використовують:
timestamp у заголовку;
обмежене вікно допустимого часу;
унікальний ідентифікатор події;
сховище вже оброблених подій.
У прикладі timestamp має бути не старшим за п’ять хвилин:
const age = Math.abs(currentTimeInSeconds - timestampInSeconds);
return age <= MAX_TIMESTAMP_AGE_SECONDS;Допуск у п’ять хвилин — лише приклад. Його потрібно узгодити з документацією сервісу та особливостями інфраструктури.
Перевірка timestamp не замінює перевірку підпису. Timestamp також повинен бути частиною підписаного повідомлення:
const signedPayload = `${timestamp}.${rawBody}`;Інакше зловмисник може змінити timestamp, не порушивши перевірку HMAC.
Зовнішні сервіси часто використовують модель доставки at least once. Це означає, що подія може бути доставлена:
один раз;
кілька разів;
після тимчасової помилки;
через значний проміжок часу.
Тому webhook-обробник повинен бути ідемпотентним.
Ідемпотентна обробка означає, що повторна обробка тієї самої події не змінює результат повторно.
Наприклад, небезпечно щоразу створювати платіж:
await createPayment(event.data.orderId);Якщо одна подія прийде тричі, можуть створитися три платежі.
Краще зберігати ідентифікатор події та перевіряти його перед виконанням операції:
event_id = evt_123
status = processedІдентифікатором може бути:
окремий заголовок відправника;
поле id у JSON;
комбінація, гарантовано унікальна для конкретного постачальника.
Set у прикладі не підходить для productionУ прикладі використовується:
const processedEvents = new Set<string>();Це зручно для демонстрації, але таке сховище має суттєві обмеження:
дані зникають після перезапуску процесу;
різні екземпляри застосунку мають різні Set;
serverless-функції можуть запускатися в різних середовищах;
немає гарантованого захисту від конкурентних запитів між процесами;
кількість подій у пам’яті необмежено зростає.
У production ідентифікатори подій потрібно зберігати в надійному спільному сховищі, наприклад у базі даних.
Для таблиці подій корисно мати:
унікальний індекс на event_id;
статус обробки;
час отримання;
час завершення;
кількість спроб;
повідомлення про помилку.
Концептуально обробка має виглядати так:
перевірити підпис;
спробувати вставити event_id у таблицю вхідних подій;
якщо унікальний індекс повідомив про дубль — повернути успішну відповідь;
обробити подію;
позначити її як успішно оброблену;
у разі помилки залишити можливість повторної спроби.
Критично важливо, щоб перевірка та реєстрація ідентифікатора виконувалися атомарно. Просте поєднання двох окремих операцій:
if (!(await exists(eventId))) {
await insert(eventId);
}може бути некоректним. Два паралельні запити можуть одночасно не знайти запис і обидва почати обробку. Унікальне обмеження бази даних вирішує цю проблему на рівні сховища.
Webhook-постачальник зазвичай вважає доставку успішною, якщо endpoint повернув відповідь із кодом 2xx.
Типова стратегія:
2xx — подію прийнято або вона вже була оброблена;
400 — запит має некоректний формат;
401 або 403 — підпис недійсний;
500 — тимчасова помилка обробки, можна повторити доставку.
Не варто повертати 500 для вже обробленої події. Інакше постачальник продовжить повторювати webhook, хоча обробка вже завершилася.
Також небезпечно повертати 200 до завершення критичної операції:
export async function POST(request: Request) {
void processEvent(request);
return Response.json({ received: true });
}Після відповіді середовище виконання може завершити процес, і подія не буде оброблена. Якщо потрібна асинхронна обробка, спочатку надійно збережіть подію в черзі або базі даних, а вже потім повертайте успішну відповідь.
Перевірка підпису підтверджує походження payload, але не гарантує, що його структура відповідає очікуваній.
Після перевірки підпису потрібно перевірити мінімально необхідні поля:
if (
typeof event.id !== "string" ||
typeof event.type !== "string" ||
typeof event.data?.orderId !== "string"
) {
return Response.json(
{ error: "Invalid event structure" },
{ status: 400 },
);
}Не слід без перевірки довіряти значенням із webhook:
використовувати їх у SQL без параметрів;
передавати їх у shell-команди;
використовувати як URL для внутрішніх запитів;
змінювати довільні поля користувача без перевірки доступу;
припускати, що тип події належить до відомого списку.
Для складних payload можна використовувати runtime-схему валідації, але навіть без бібліотеки потрібно перевіряти критичні поля вручну.
У логах не повинні опинятися:
секрет webhook;
повний підпис;
токени;
персональні дані;
повне тіло запиту, якщо воно містить чутливу інформацію.
Замість повного payload краще логувати:
event_id
event_type
request_id
processing_status
error_codeЦе допомагає діагностувати проблеми, не розкриваючи зайві дані.
const body = await request.json();Після цього оригінальне тіло може бути недоступним у потрібному вигляді. Спочатку потрібно викликати request.text(), перевірити підпис і лише потім виконати JSON.parse.
Постачальник може підписувати:
timestamp.bodyабо:
id.timestamp.bodyабо лише:
bodyНавіть зайвий перенос рядка змінить підпис. Формат потрібно реалізувати точно за документацією сервісу.
===Застосовуйте безпечне порівняння буферів і перевіряйте їхню довжину перед timingSafeEqual.
Не припускайте, що постачальник надішле кожну подію лише один раз. Зберігайте унікальний ідентифікатор події у спільному надійному сховищі.
Якщо спочатку виконати операцію, а потім зберегти event_id, паралельні запити можуть виконати її кілька разів. Реєстрація події повинна мати атомарний захист від дублювання.
Set, глобальна змінна або локальний файл не забезпечують надійну ідемпотентність у production-середовищі.
200 при помилціЯкщо обробка не завершилася, а endpoint повернув 200, зовнішній сервіс може припинити повторні спроби. Повертайте 5xx, якщо подію потрібно доставити ще раз.
Webhook endpoint у Next.js повинен:
читати сире тіло через request.text();
перевіряти HMAC-підпис до парсингу JSON;
порівнювати підписи безпечним способом;
перевіряти timestamp для захисту від replay attack;
перевіряти структуру та тип події;
використовувати унікальний ідентифікатор події;
бути ідемпотентним;
зберігати статус обробки у спільному надійному сховищі;
повертати 2xx для успішно прийнятих і повторних подій;
повертати 5xx, якщо тимчасова помилка має спричинити повторну доставку.