Коротко
В 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_.
Подготовка
- В кабинете https://qut.kz/app создайте API-ключ (
qp_test_…для песочницы) и добавьте адрес вебхукаhttps://site.kz/api/qutpay/webhookв разделе Интеграции; секрет показывается один раз — настройка вебхуков. - В
.env.local:QUTPAY_API_KEY=qp_test_…иQUTPAY_WEBHOOK_SECRET=…. Любая переменная сNEXT_PUBLIC_попадает в бандл браузера — для ключа это утечка. - 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. - Оба обработчика должны работать в Node.js runtime (значение по умолчанию); Edge runtime для вебхука не подходит из-за
node:crypto.
Код: оплата и вебхук
// 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 — на сервере, не из браузера. Подробно: как автоматически узнать об оплате.
На что обратить внимание
| Момент | Почему важно |
|---|---|
await req.text(), не req.json() | HMAC считается по байтам; разобранный и собранный обратно JSON не совпадёт никогда — подпись не сходится |
timingSafeEqual и проверка длины | Сравнение через === уязвимо к атаке по времени; при разной длине timingSafeEqual бросает исключение |
| Идемпотентность | Доставка повторяется до 11 раз за ~2 часа, если не было 2xx; условие paid_at IS NULL делает повтор безвредным — идемпотентность |
| Middleware | Если middleware.ts требует авторизацию, исключите /api/qutpay/webhook — мы приходим без логина |
Проверка в песочнице
- С
qp_test_…вызовитеPOST /api/pay, откройтеpayUrl. - Имитируйте оплату:
POST https://api.qut.kz/api/v1/invoices/{id}/simulateс телом{"status":"paid"}— вебхук уходит по-настоящему, с подписью — песочница. Локально адрес вебхука может бытьhttpи туннель; в боевом режиме — толькоhttpsна настоящем домене. - Убедитесь, что заказ получил
paid_at, а повторная доставка ничего не меняет; затем переключитесь наqp_live_…— чек-лист перед запуском.
Вопросы и ответы
Подойдёт ли 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}; источник истины — вебхук — вебхук или опрос.