Qut Pay Сайт Кабинет База знаний Инструкции Документация API ҚАЗРУС
Главная → База знаний → Справочник

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

Обновлено: 2026-10-25 · Версия в Markdown

Коротко

В 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 в разделе Интеграции; секрет показывается один раз — настройка вебхуков.
  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.
  4. Оба обработчика должны работать в 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 — мы приходим без логина

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

  1. С qp_test_… вызовите POST /api/pay, откройте payUrl.
  2. Имитируйте оплату: POST https://api.qut.kz/api/v1/invoices/{id}/simulate с телом {"status":"paid"} — вебхук уходит по-настоящему, с подписью — песочница. Локально адрес вебхука может быть http и туннель; в боевом режиме — только https на настоящем домене.
  3. Убедитесь, что заказ получил 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}; источник истины — вебхук — вебхук или опрос.

Связанные статьи

Node.js SDKУстановка Node.js-клиента Qut Pay, создание счёта, запрос статуса, возврат и проверка подписи вебхука. Полный рабочий пример на Express с raw body и разбор обработки ошибок.Безопасность вебхуков и проверка подписиКак устроена подпись, почему обязателен raw body, как проверять timestamp, примеры кода для Express, Laravel, Django и чистого Node, идемпотентная обработка и разбор частых ошибок.Как автоматически узнать, что Kaspi QR оплаченДва способа подтвердить оплату Kaspi QR без ручной сверки — webhook invoice.paid на ваш сервер и запрос статуса счёта. Что приходит, как проверить подпись, как не отметить заказ оплаченным по ошибке, что делать на странице «спасибо».Симуляция оплаты в песочницеЭндпоинт simulate меняет статус счёта в песочнице: paid, failed, expired. Kaspi не вызывается, вебхук приходит как при настоящей оплате. Цикл проверки и написание автотестов.Идемпотентность: защита от дублейКак работает заголовок Idempotency-Key, как правильно составить ключ, какова роль externalOrderId, и как защититься от повторов при обработке вебхуков и при возвратах.Подпись вебхука не сходитсяПодпись считается как HMAC-SHA256(secret, timestamp + "." + rawBody). Самая частая ошибка — разобрать тело в JSON и собрать обратно в строку. Примеры получения raw body для Express, Laravel, Django.

Остались вопросы? WhatsApp +77788813333 · kazprose@gmail.com
Написать можно и из кабинета: Поддержка.

Qut Pay — независимый сервис, не аффилирован с АО «Kaspi Bank». Kaspi и Kaspi Pay — товарные знаки их правообладателя.