Пошук уроків, статей та іншого контенту
Реалізуєте приймання webhook-подій, перевірку підписів і безпечну повторну обробку.
Webhook — це HTTP-запит, який зовнішній сервіс надсилає вашому застосунку після певної події. Наприклад:
створено платіж;
змінено статус замовлення;
зареєстровано нового користувача;
завершено фонову операцію.
На відміну від звичайного API-запиту, webhook ініціює не ваш застосунок, а зовнішній сервіс.
Типовий життєвий цикл webhook-запиту:
Зовнішній сервіс формує JSON-подію.
Додає до запиту підпис.
Надсилає POST на ваш endpoint.
Ваш застосунок перевіряє підпис.
Перевіряє, чи не оброблялася ця подія раніше.
Запускає бізнес-логіку.
Повертає успішну HTTP-відповідь.
Webhook endpoint доступний з Інтернету. Будь-хто може спробувати надіслати на нього HTTP-запит, тому не можна довіряти лише JSON у тілі запиту.
Основні загрози:
зловмисник надсилає підроблену подію;
одна й та сама подія обробляється кілька разів;
перехоплений запит повторно відправляється через тривалий час;
великий або некоректний payload створює зайве навантаження.
HMAC-підпис тіла запиту;
timestamp для обмеження часу дії підпису;
ідентифікатор події для ідемпотентності;
HTTPS;
обмеження розміру тіла запиту.
Підпис обчислюється на основі точних байтів HTTP body. Навіть незначна зміна JSON може змінити підпис:
{"id":"evt_1","amount":100}і
{ "id": "evt_1", "amount": 100 }містять однакові дані з точки зору JSON, але мають різне текстове представлення.
Тому для перевірки підпису потрібно використовувати rawBody, а не повторно серіалізований об’єкт JavaScript.
У NestJS з Express можна увімкнути збереження сирого тіла через опцію rawBody.
У цьому прикладі використаємо такий формат заголовка:
t=1710000000,v1=hex_hmac_signatureПідпис формується так:
HMAC_SHA256(secret, `${timestamp}.${rawBody}`)Наприклад, якщо:
timestamp — 1710000000;
сире тіло — {"id":"evt_1"};
секрет — webhook-secret;
то сервіс обчислює HMAC-SHA256 для рядка:
1710000000.{"id":"evt_1"}Конкретні webhook-провайдери можуть використовувати інший формат заголовка або підписувати інший набір даних. Формат потрібно реалізовувати відповідно до документації конкретного сервісу.
rawBody у NestJS// src/main.ts
import { NestFactory } from '@nestjs/core';
import { AppModule } from './app.module';
async function bootstrap() {
const app = await NestFactory.create(AppModule, {
rawBody: true,
});
await app.listen(3000);
}
bootstrap();Після цього в Express-запиті доступне поле rawBody. Воно містить тіло запиту у вигляді Buffer, а @Body() як і раніше містить розібраний JSON.
Створимо окремий сервіс для перевірки підпису.
// src/webhooks/webhook-signature.service.ts
import {
Injectable,
UnauthorizedException,
} from '@nestjs/common';
import { createHmac, timingSafeEqual } from 'node:crypto';
@Injectable()
export class WebhookSignatureService {
private readonly secret = process.env.WEBHOOK_SECRET ?? 'webhook-secret';
verify(rawBody: Buffer | undefined, header: string | undefined): void {
if (!rawBody || !header) {
throw new UnauthorizedException('Відсутнє тіло або підпис webhook');
}
const values = this.parseHeader(header);
const timestamp = Number(values.t);
if (!Number.isInteger(timestamp)) {
throw new UnauthorizedException('Некоректний timestamp підпису');
}
const toleranceInSeconds = 5 * 60;
const currentTimestamp = Math.floor(Date.now() / 1000);
if (
Math.abs(currentTimestamp - timestamp) > toleranceInSeconds
) {
throw new UnauthorizedException('Підпис webhook застарів');
}
const signedPayload = Buffer.concat([
Buffer.from(`${timestamp}.`, 'utf8'),
rawBody,
]);
const expectedSignature = createHmac('sha256', this.secret)
.update(signedPayload)
.digest();
let receivedSignature: Buffer;
try {
receivedSignature = Buffer.from(values.v1, 'hex');
} catch {
throw new UnauthorizedException('Некоректний формат підпису');
}
if (
receivedSignature.length !== expectedSignature.length ||
!timingSafeEqual(receivedSignature, expectedSignature)
) {
throw new UnauthorizedException('Недійсний підпис webhook');
}
}
private parseHeader(header: string): Record<string, string> {
const result: Record<string, string> = {};
for (const item of header.split(',')) {
const separatorIndex = item.indexOf('=');
if (separatorIndex === -1) {
continue;
}
const key = item.slice(0, separatorIndex).trim();
const value = item.slice(separatorIndex + 1).trim();
if (key && value) {
result[key] = value;
}
}
if (!result.t || !result.v1) {
throw new UnauthorizedException('Неповний заголовок підпису');
}
return result;
}
}timingSafeEqualЗвичайне порівняння рядків може завершуватися на першому відмінному символі. Теоретично за часом відповіді можна отримувати інформацію про правильність частини секрету.
timingSafeEqual призначений для порівняння криптографічних значень із більш передбачуваним часом виконання.
Перед викликом timingSafeEqual потрібно перевірити довжину буферів. Якщо довжини різні, Node.js викине помилку.
Подія повинна мати стабільний ідентифікатор. Саме він буде використовуватися для захисту від повторної обробки.
// src/webhooks/webhook-event.ts
export interface WebhookEvent {
id: string;
type: string;
data: unknown;
}Не слід використовувати для ідемпотентності випадково згенерований ідентифікатор під час обробки запиту. Для кожної повторної доставки він буде іншим. Ідентифікатор повинен приходити від webhook-провайдера.
Зовнішній сервіс може повторити webhook, якщо:
ваш endpoint не відповів вчасно;
з’єднання було перервано;
сервіс отримав HTTP-відповідь із помилкою;
сталася тимчасова помилка мережі.
Тому endpoint повинен коректно обробляти ту саму подію кілька разів.
Для демонстрації використаємо Map:
// src/webhooks/webhook-processing.service.ts
import { Injectable } from '@nestjs/common';
import { WebhookEvent } from './webhook-event';
type EventState = 'processing' | 'done';
@Injectable()
export class WebhookProcessingService {
private readonly events = new Map<string, EventState>();
async process(event: WebhookEvent): Promise<{ duplicate: boolean }> {
const currentState = this.events.get(event.id);
if (currentState === 'processing' || currentState === 'done') {
return { duplicate: true };
}
// Резервуємо ідентифікатор до запуску асинхронної бізнес-логіки.
this.events.set(event.id, 'processing');
try {
await this.handleBusinessEvent(event);
this.events.set(event.id, 'done');
return { duplicate: false };
} catch (error) {
// Дозволяємо повторну спробу після помилки обробки.
this.events.delete(event.id);
throw error;
}
}
private async handleBusinessEvent(event: WebhookEvent): Promise<void> {
switch (event.type) {
case 'order.paid':
console.log(`Замовлення оплачено: ${event.id}`);
break;
case 'customer.created':
console.log(`Створено клієнта: ${event.id}`);
break;
default:
console.log(`Невідомий тип події: ${event.type}`);
}
}
}У цьому прикладі повторний запит, який прийшов паралельно з першим, побачить стан processing і не запустить бізнес-логіку вдруге.
Map підходить лише для демонстрації або одного процесу застосунку. Після перезапуску процесу всі дані буде втрачено. У production стан оброблених подій потрібно зберігати в постійному сховищі, наприклад у базі даних із унікальним індексом на event_id.
// src/webhooks/webhooks.controller.ts
import {
BadRequestException,
Body,
Controller,
Headers,
HttpCode,
Post,
Req,
} from '@nestjs/common';
import { Request } from 'express';
import { WebhookEvent } from './webhook-event';
import { WebhookProcessingService } from './webhook-processing.service';
import { WebhookSignatureService } from './webhook-signature.service';
type RequestWithRawBody = Request & {
rawBody?: Buffer;
};
@Controller('webhooks')
export class WebhooksController {
constructor(
private readonly signatureService: WebhookSignatureService,
private readonly processingService: WebhookProcessingService,
) {}
@Post('provider')
@HttpCode(200)
async receive(
@Req() request: RequestWithRawBody,
@Headers('x-webhook-signature') signature: string | undefined,
@Body() body: unknown,
): Promise<{ received: true; duplicate: boolean }> {
this.signatureService.verify(request.rawBody, signature);
const event = this.parseEvent(body);
const result = await this.processingService.process(event);
return {
received: true,
duplicate: result.duplicate,
};
}
private parseEvent(body: unknown): WebhookEvent {
if (!body || typeof body !== 'object') {
throw new BadRequestException('Тіло webhook повинно бути об’єктом');
}
const value = body as Record<string, unknown>;
if (
typeof value.id !== 'string' ||
typeof value.type !== 'string' ||
!value.id ||
!value.type
) {
throw new BadRequestException(
'Webhook повинен містити id і type',
);
}
return {
id: value.id,
type: value.type,
data: value.data,
};
}
}Порядок операцій важливий:
спочатку перевіряється підпис сирого тіла;
потім перевіряється структура події;
лише після цього запускається бізнес-логіка.
Не потрібно використовувати event.id або event.type до перевірки підпису, оскільки до цього моменту тіло запиту ще не можна вважати довіреним.
// src/webhooks/webhooks.module.ts
import { Module } from '@nestjs/common';
import { WebhooksController } from './webhooks.controller';
import { WebhookProcessingService } from './webhook-processing.service';
import { WebhookSignatureService } from './webhook-signature.service';
@Module({
controllers: [WebhooksController],
providers: [
WebhookSignatureService,
WebhookProcessingService,
],
})
export class WebhooksModule {}// src/app.module.ts
import { Module } from '@nestjs/common';
import { WebhooksModule } from './webhooks/webhooks.module';
@Module({
imports: [WebhooksModule],
})
export class AppModule {}Тепер застосунок приймає webhook за адресою:
POST /webhooks/providerДля тестування можна використати Node.js-скрипт, який обчислює підпис так само, як тестовий webhook-відправник.
// scripts/send-webhook.js
const crypto = require('node:crypto');
const secret = 'webhook-secret';
const timestamp = Math.floor(Date.now() / 1000);
const rawBody = JSON.stringify({
id: 'evt_1001',
type: 'order.paid',
data: {
orderId: 'order_42',
amount: 1999,
},
});
const signature = crypto
.createHmac('sha256', secret)
.update(`${timestamp}.${rawBody}`)
.digest('hex');
console.log(JSON.stringify({
timestamp,
rawBody,
signature,
}));
// Приклад значення заголовка:
// t=1710000000,v1=012345...Для реального HTTP-запиту заголовок повинен мати формат:
x-webhook-signature: t=<timestamp>,v1=<hex-підпис>А тіло потрібно передати без змін після обчислення підпису.
Webhook-провайдери зазвичай вважають будь-яку відповідь із класу 2xx успішною. Відповіді 4xx або 5xx можуть спричинити повторну доставку.
Корисна базова поведінка:
недійсний підпис — 401 Unauthorized;
некоректне тіло — 400 Bad Request;
тимчасова помилка обробки — 5xx, щоб провайдер міг повторити запит;
вже оброблена подія — 200 OK.
У production не варто виконувати довгу бізнес-операцію безпосередньо перед відповіддю. Спочатку можна перевірити підпис, зареєструвати подію в сховищі та передати її внутрішньому обробнику. Однак запис події та перевірка унікальності повинні бути атомарними, інакше паралельні webhook-запити можуть створити дублікати.
Секрет не повинен бути захардкоджений у репозиторії:
const secret = process.env.WEBHOOK_SECRET;Для різних середовищ використовуйте різні секрети.
Сам HMAC доводить, що запит створено тим, хто знає секрет, але не захищає від повторної відправки старого валідного запиту. Перевірка timestamp обмежує час, протягом якого такий запит приймається.
Допустиме вікно залежить від конкретного провайдера. Поширений приклад — кілька хвилин.
Не логують:
секрет підпису;
повний заголовок із чутливими даними;
payload, якщо він містить персональні або платіжні дані.
Для діагностики достатньо логувати ідентифікатор події, її тип і результат обробки.
Великий body може витратити пам’ять і процесор ще до перевірки підпису. Для webhook endpoint варто встановити розумне обмеження розміру запиту відповідно до вимог провайдера.
Підпис захищає цілісність і походження повідомлення, але HTTPS потрібен для захисту даних під час передавання та для запобігання перехопленню секретних заголовків.
Помилково:
const rawBody = JSON.stringify(body);Це не обов’язково відновить початкові байти запиту. Потрібно використовувати request.rawBody.
===Для криптографічних підписів використовуйте timingSafeEqual, попередньо перевіривши довжину буферів.
Якщо подія order.paid прийде двічі, без захисту можна:
двічі зарахувати кошти;
двічі відправити лист;
двічі змінити стан замовлення;
двічі створити пов’язаний ресурс.
Map очищається після перезапуску та не синхронізується між кількома екземплярами застосунку. Для production потрібне спільне постійне сховище.
event.type як ключаДві різні події можуть мати однаковий тип. Ключем має бути унікальний ідентифікатор події, наприклад event.id.
200 OK після помилкиЯкщо бізнес-логіка не виконалася, а endpoint повернув 200, провайдер вважатиме подію успішно обробленою і може більше її не повторити.
Для безпечного webhook endpoint у NestJS потрібно:
Увімкнути rawBody.
Перевірити HMAC-підпис саме сирих байтів тіла.
Перевірити timestamp і відхилити застарілі запити.
Валідовати структуру події після перевірки підпису.
Використовувати стабільний event.id для ідемпотентності.
Не позначати подію обробленою, якщо бізнес-логіка завершилася помилкою.
У production зберігати стани подій у спільному постійному сховищі.
Повертати HTTP-статус, який відповідає результату обробки.