# Kaspi Pay интеграция с Next.js

> Route Handler POST /api/pay создаёт счёт Qut Pay на сервере (ключ не попадает в браузер) и отдаёт payUrl; POST /api/qutpay/webhook читает сырое тело через req.text() и проверяет HMAC через timingSafeEqual; страница «спасибо» перепроверяет статус на сервере. Переменные QUTPAY_API_KEY и QUTPAY_WEBHOOK_SECRET.

## Коротко

В Next.js (app router) интеграция Kaspi QR через Qut Pay — это два Route Handler. `POST /api/pay` на сервере вызывает `POST https://api.qut.kz/api/v1/invoices` с заголовком `X-API-Key` и возвращает `payUrl`, куда вы отправляете покупателя. `POST /api/qutpay/webhook` читает тело как есть через `await req.text()`, проверяет `X-Webhook-Signature` (`sha256=` + HMAC-SHA256 от `timestamp + "." + rawBody`) через `crypto.timingSafeEqual`, отмечает заказ оплаченным один раз и отвечает `200`. Ключ и секрет — только в серверных переменных `QUTPAY_API_KEY` и `QUTPAY_WEBHOOK_SECRET`, без префикса `NEXT_PUBLIC_`.

## Подготовка

1. В кабинете https://qut.kz/app создайте API-ключ (`qp_test_…` для песочницы) и добавьте адрес вебхука `https://site.kz/api/qutpay/webhook` в разделе Интеграции; секрет показывается один раз — [настройка вебхуков](/kb/ru/webhook-setup).
2. В `.env.local`: `QUTPAY_API_KEY=qp_test_…` и `QUTPAY_WEBHOOK_SECRET=…`. Любая переменная с `NEXT_PUBLIC_` попадает в бандл браузера — для ключа это утечка.
3. Node.js SDK (https://api.qut.kz/downloads/qutpay-sdk-node.zip) необязателен: ниже пример на встроенном `fetch` и `node:crypto`. Если подключите SDK, `verifyWebhook({ secret, rawBody, headers: req.headers })` принимает `Headers` из Route Handler напрямую — [Node.js SDK](/kb/ru/sdk-node).
4. Оба обработчика должны работать в Node.js runtime (значение по умолчанию); Edge runtime для вебхука не подходит из-за `node:crypto`.

## Код: оплата и вебхук

```ts
// app/api/pay/route.ts
import { NextResponse } from 'next/server';

export async function POST(req: Request) {
  const { orderId } = await req.json();
  const order = await getOrder(orderId);                        // сумму берём из базы, не из запроса
  const r = await fetch('https://api.qut.kz/api/v1/invoices', {
    method: 'POST',
    headers: { 'X-API-Key': process.env.QUTPAY_API_KEY!, 'Content-Type': 'application/json',
               'Idempotency-Key': `order-${order.id}` },
    body: JSON.stringify({ amount: order.total, externalOrderId: String(order.id),
      description: `Заказ №${order.id}`, successUrl: `https://site.kz/thanks?order=${order.id}`,
      failUrl: `https://site.kz/orders/${order.id}`, metadata: { orderId: order.id } }),
  });
  if (!r.ok) return NextResponse.json({ error: (await r.json()).error }, { status: 502 });
  const inv = await r.json();                                   // id, status, payUrl, qrUrl, deepLink, expiresAt
  await saveInvoiceId(order.id, inv.id);
  return NextResponse.json({ payUrl: inv.payUrl });             // на клиенте: window.location.href = payUrl
}

// app/api/qutpay/webhook/route.ts
import crypto from 'node:crypto';

export async function POST(req: Request) {
  const raw = await req.text();                                 // сырое тело — до JSON.parse
  const ts = req.headers.get('x-webhook-timestamp') ?? '';
  const sig = req.headers.get('x-webhook-signature') ?? '';
  const expected = 'sha256=' + crypto.createHmac('sha256', process.env.QUTPAY_WEBHOOK_SECRET!)
    .update(`${ts}.${raw}`).digest('hex');
  const fresh = Math.abs(Date.now() / 1000 - Number(ts)) <= 300;
  const a = Buffer.from(sig), b = Buffer.from(expected);
  if (!fresh || a.length !== b.length || !crypto.timingSafeEqual(a, b)) return new Response(null, { status: 401 });

  const { event, invoice } = JSON.parse(raw);
  if (event === 'invoice.paid') {
    // идемпотентно: UPDATE orders SET paid_at = now() WHERE id = $1 AND paid_at IS NULL AND total = $2
    await markPaidOnce(invoice.externalOrderId, invoice.amount, invoice.id);
  }
  return new Response(null, { status: 200 });                   // в течение 8 секунд, иначе повтор
}
```

## Страница «спасибо»

Покупатель возвращается на `successUrl`, но этот переход можно открыть вручную — он ничего не доказывает. В `app/thanks/page.tsx` (серверный компонент) прочитайте заказ из базы и показывайте «оплачено» только при заполненном `paid_at`; если вебхук ещё в пути, запросите статус напрямую: `GET https://api.qut.kz/api/v1/invoices/{id}?live=1` с `X-API-Key` — на сервере, не из браузера. Подробно: [как автоматически узнать об оплате](/kb/ru/auto-check-payment).

## На что обратить внимание

| Момент | Почему важно |
|---|---|
| `await req.text()`, не `req.json()` | HMAC считается по байтам; разобранный и собранный обратно JSON не совпадёт никогда — [подпись не сходится](/kb/ru/webhook-signature-mismatch) |
| `timingSafeEqual` и проверка длины | Сравнение через `===` уязвимо к атаке по времени; при разной длине `timingSafeEqual` бросает исключение |
| Идемпотентность | Доставка повторяется до 11 раз за ~2 часа, если не было `2xx`; условие `paid_at IS NULL` делает повтор безвредным — [идемпотентность](/kb/ru/idempotency) |
| Middleware | Если `middleware.ts` требует авторизацию, исключите `/api/qutpay/webhook` — мы приходим без логина |

## Проверка в песочнице

1. С `qp_test_…` вызовите `POST /api/pay`, откройте `payUrl`.
2. Имитируйте оплату: `POST https://api.qut.kz/api/v1/invoices/{id}/simulate` с телом `{"status":"paid"}` — вебхук уходит по-настоящему, с подписью — [песочница](/kb/ru/sandbox-simulate). Локально адрес вебхука может быть `http` и туннель; в боевом режиме — только `https` на настоящем домене.
3. Убедитесь, что заказ получил `paid_at`, а повторная доставка ничего не меняет; затем переключитесь на `qp_live_…` — [чек-лист перед запуском](/kb/ru/going-live-checklist).

## Вопросы и ответы

**Подойдёт ли pages router?** Да, но там нужно выключить парсер: `export const config = { api: { bodyParser: false } }` и собрать тело из потока вручную; в app router `req.text()` даёт сырое тело без настроек.

**Можно вызывать API Qut Pay из клиентского компонента?** Нет: ключ окажется в браузере. Только Route Handler или Server Action на сервере.

**Нужен ли polling на странице ожидания?** Можно раз в 5–10 секунд спрашивать свой `GET /api/orders/{id}`; источник истины — вебхук — [вебхук или опрос](/kb/ru/polling-vs-webhook).
