Webhooks и интеграции
Webhook — HTTP-запрос от внешнего сервиса при событии.
Базовый webhook
// app/api/webhooks/stripe/route.ts
import Stripe from 'stripe';
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!);
export async function POST(request: Request) {
const body = await request.text();
const signature = request.headers.get('stripe-signature')!;
let event: Stripe.Event;
try {
event = stripe.webhooks.constructEvent(
body,
signature,
process.env.STRIPE_WEBHOOK_SECRET!
);
} catch (error) {
return Response.json({ error: 'Invalid signature' }, { status: 401 });
}
switch (event.type) {
case 'payment_intent.succeeded':
await handlePaymentSuccess(event.data.object);
break;
case 'payment_intent.failed':
await handlePaymentFailure(event.data.object);
break;
}
return Response.json({ received: true });
}
Проверка подписи вручную
import crypto from 'crypto';
export async function POST(request: Request) {
const body = await request.text();
const signature = request.headers.get('x-signature');
const expected = crypto
.createHmac('sha256', process.env.WEBHOOK_SECRET!)
.update(body)
.digest('hex');
if (signature !== expected) {
return Response.json({ error: 'Invalid signature' }, { status: 401 });
}
const event = JSON.parse(body);
await handleEvent(event);
return Response.json({ received: true });
}
Постоянное сравнение уязвимо к timing-атакам. Используй crypto.timingSafeEqual:
const expected = crypto
.createHmac('sha256', process.env.WEBHOOK_SECRET!)
.update(body)
.digest('hex');
const isValid =
signature &&
signature.length === expected.length &&
crypto.timingSafeEqual(Buffer.from(signature), Buffer.from(expected));
if (!isValid) {
return Response.json({ error: 'Invalid signature' }, { status: 401 });
}
Идемпотентность
Webhook может прийти дважды. Храни ID обработанных событий:
export async function POST(request: Request) {
const body = await request.text();
const signature = request.headers.get('x-signature');
if (!verifySignature(body, signature)) {
return Response.json({ error: 'Invalid signature' }, { status: 401 });
}
const event = JSON.parse(body);
const exists = await db.webhookEvent.findUnique({
where: { eventId: event.id },
});
if (exists) {
return Response.json({ received: true, duplicate: true });
}
await db.webhookEvent.create({
data: { eventId: event.id, type: event.type, payload: event },
});
await handleEvent(event);
return Response.json({ received: true });
}
Быстрый ответ
Webhook-провайдеры ждут ответ быстро. Долгую обработку — в очередь:
export async function POST(request: Request) {
const body = await request.text();
if (!verifySignature(body, request.headers.get('x-signature'))) {
return Response.json({ error: 'Invalid signature' }, { status: 401 });
}
const event = JSON.parse(body);
await queue.enqueue('process-webhook', event);
return Response.json({ received: true });
}
Обработчик очереди:
async function processWebhook(event: WebhookEvent) {
switch (event.type) {
case 'user.created':
await createUser(event.data);
break;
// ...
}
}
Retry со стороны провайдера
Провайдеры повторяют webhook при ошибках (5xx). Поэтому:
- Возвращай 200 быстро
- Не делай тяжёлое в обработчике
- Используй идемпотентность
Логирование
await db.webhookLog.create({
data: {
provider: 'stripe',
eventId: event.id,
type: event.type,
payload: event,
receivedAt: new Date(),
},
});
Интеграции
Telegram
export async function POST(request: Request) {
const update = await request.json();
if (update.message) {
const chatId = update.message.chat.id;
const text = update.message.text;
await sendTelegramMessage(chatId, `Вы сказали: ${text}`);
}
return Response.json({ ok: true });
}
GitHub
import crypto from 'crypto';
export async function POST(request: Request) {
const body = await request.text();
const signature = request.headers.get('x-hub-signature-256');
const expected = 'sha256=' + crypto
.createHmac('sha256', process.env.GITHUB_WEBHOOK_SECRET!)
.update(body)
.digest('hex');
if (signature !== expected) {
return Response.json({ error: 'Invalid signature' }, { status: 401 });
}
const event = JSON.parse(body);
const eventType = request.headers.get('x-github-event');
if (eventType === 'push') {
await deployLatest();
}
return Response.json({ ok: true });
}
Тестирование
Локально — туннель:
npx ngrok http 3000
Или через stripe listen:
stripe listen --forward-to localhost:3000/api/webhooks/stripe
Итоги
- Webhook — HTTP от внешнего сервиса
- Всегда проверяй подпись
- Идемпотентность через ID события
- Быстрый ответ, обработка в очереди
- Логирование для отладки
- Туннель для локального тестирования