Қысқаша
Next.js-те (app router) Qut Pay арқылы Kaspi QR интеграциясы — екі Route Handler. POST /api/pay серверде X-API-Key тақырыбымен POST https://api.qut.kz/api/v1/invoices шақырып, сатып алушыны жіберетін payUrl қайтарады. POST /api/qutpay/webhook денені await req.text() арқылы сол күйінде оқиды, X-Webhook-Signature-ді (sha256= + timestamp + "." + rawBody-дің HMAC-SHA256-сы) crypto.timingSafeEqual арқылы тексереді, тапсырысты бір рет төленді деп белгілеп, 200 қайтарады. Кілт пен құпия — тек серверлік QUTPAY_API_KEY және QUTPAY_WEBHOOK_SECRET айнымалыларында, NEXT_PUBLIC_ префиксінсіз.
Дайындық
- https://qut.kz/app кабинетінде API кілтін жасаңыз (sandbox үшін
qp_test_…) және Интеграциялар бөліміндеhttps://site.kz/api/qutpay/webhookwebhook адресін қосыңыз; құпия бір рет көрсетіледі — 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 })Route Handler-діңHeadersобъектісін тікелей қабылдайды — Node.js SDK. - Екі өңдеуші де Node.js runtime-да жұмыс істеуі керек (әдепкі мән);
node:cryptoсебебінен webhook үшін Edge runtime жарамайды.
Код: төлем және webhook
// 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 толтырылғанда көрсетіңіз; webhook әлі жолда болса, күйді тікелей сұраңыз: X-API-Key-мен GET https://api.qut.kz/api/v1/invoices/{id}?live=1 — серверде, браузерден емес. Толығырақ: төлемді автоматты қалай білуге болады.
Неге назар аудару керек
| Жағдай | Неге маңызды |
|---|---|
req.json() емес, await req.text() | HMAC байттар бойынша есептеледі; талданып, қайта жиналған JSON ешқашан сәйкес келмейді — қолтаңба сәйкес келмейді |
timingSafeEqual және ұзындық тексеруі | === арқылы салыстыру уақыт бойынша шабуылға осал; ұзындық әртүрлі болса timingSafeEqual ерекшелік тастайды |
| Идемпотенттілік | 2xx болмаса, жеткізу ~2 сағат ішінде 11 ретке дейін қайталанады; paid_at IS NULL шарты қайталауды зиянсыз етеді — идемпотенттілік |
| Middleware | middleware.ts авторизация талап етсе, /api/qutpay/webhook-ты ерекшелікке қосыңыз — біз логинсіз келеміз |
Sandbox-та тексеру
qp_test_…кілтіменPOST /api/payшақырып,payUrl-ді ашыңыз.- Төлемді имитациялаңыз:
{"status":"paid"}денесіменPOST https://api.qut.kz/api/v1/invoices/{id}/simulate— webhook қолтаңбасымен нағыз күйінде кетеді — sandbox. Локалда webhook адресіhttpжәне туннель бола алады; нақты режимде — тек нағыз домендегіhttps. - Тапсырыс
paid_atалғанына, ал қайталанған жеткізу ештеңе өзгертпейтініне көз жеткізіңіз; содан соңqp_live_…кілтіне ауысыңыз — іске қосу алдындағы тексеру тізімі.
Сұрақ-жауап
Pages router жарай ма? Иә, бірақ онда парсерді өшіру керек: export const config = { api: { bodyParser: false } } және денені ағыннан қолмен жинау; app router-де req.text() шикі денені баптаусыз береді.
Qut Pay API-ін клиенттік компоненттен шақыруға бола ма? Жоқ: кілт браузерге түседі. Тек серверде — Route Handler немесе Server Action.
Күту бетінде polling керек пе? 5–10 секунд сайын өз GET /api/orders/{id}-іңізден сұрауға болады; ақиқат көзі — webhook — webhook пе, сұрау ма.