Урок 08

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 события
  • Быстрый ответ, обработка в очереди
  • Логирование для отладки
  • Туннель для локального тестирования