Коротко
Node.js SDK — это один ESM-модуль без внешних зависимостей. Работает на Node.js 18 и новее (использует встроенные fetch и node:crypto). Даёт две вещи: клиент QutPay и функцию verifyWebhook.
Архив: https://api.qut.kz/downloads/qutpay-sdk-node.zip
SDK необязателен — всё то же самое делается обычным fetch. Но проверку подписи лучше взять готовую, чем писать руками.
Установка
Скачайте архив, распакуйте в папку проекта (например vendor/qutpay-sdk) и подключите как локальную зависимость:
npm install ./vendor/qutpay-sdk
Либо просто скопируйте файл index.js к себе и импортируйте по пути. Имя пакета — @qutpay/sdk, формат — ESM ("type": "module"), поэтому в CommonJS-проекте подключайте через await import('@qutpay/sdk').
Инициализация
import { QutPay, verifyWebhook } from '@qutpay/sdk';
const qp = new QutPay({
apiKey: process.env.QUTPAY_API_KEY, // qp_live_… или qp_test_…
baseUrl: 'https://api.qut.kz', // значение по умолчанию, менять не нужно
timeoutMs: 20000, // по умолчанию 20 секунд
});
Без apiKey конструктор сразу бросит исключение. Ключ должен жить только на сервере — не кладите его в браузерный код, в мобильное приложение и в публичный репозиторий.
Создание счёта
const inv = await qp.createInvoice({
amount: 12500,
kind: 'qr', // 'qr' (по умолчанию) или 'phone'
description: 'Заказ №4471',
externalOrderId: '4471',
customer: { name: 'Айгуль', phone: '77011234567', email: 'a@b.kz' },
successUrl: 'https://site.kz/ok',
failUrl: 'https://site.kz/fail',
metadata: { branch: 'almaty-abay' },
idempotencyKey: 'order-4471', // уходит в заголовок Idempotency-Key
});
console.log(inv.id, inv.status, inv.payUrl, inv.expiresAt);
В ответе приходят id, status, payUrl, qrUrl, deepLink, qrImageUrl, expiresAt. Дальше достаточно отправить покупателя на inv.payUrl.
Для kind: 'phone' обязателен customer.phone, а сумма должна быть целой в тенге.
Остальные методы
| Метод | Что делает |
|---|---|
qp.getInvoice(id) | Читает счёт. qp.getInvoice(id, { live: true }) дополнительно опрашивает Kaspi и обновляет статус |
qp.listInvoices({ status, externalOrderId, from, to, limit, offset }) | Список счетов |
qp.cancelInvoice(id) | Отменяет открытый счёт |
qp.refundInvoice(id, amount, reason) | Возврат. Без amount — полный |
qp.simulateInvoice(id, 'paid') | Только песочница: имитация оплаты |
qp.account() | Краткая информация об организации — удобно для проверки ключа |
const fresh = await qp.getInvoice(inv.id, { live: true });
if (fresh.status === 'paid') await markOrderPaid('4471');
await qp.refundInvoice(inv.id, 5000, 'Часть товара возвращена');
В SDK нет методов для подписок, bulk и форм-хуков. Их вызывайте напрямую по HTTP:
const resp = await fetch('https://api.qut.kz/api/v1/invoices/bulk', {
method: 'POST',
headers: { 'X-API-Key': process.env.QUTPAY_API_KEY, 'Content-Type': 'application/json' },
body: JSON.stringify({ invoices: [{ amount: 1000, externalOrderId: 'a-1' }] }),
});
const result = await resp.json(); // HTTP 207, результат по каждому элементу
Проверка подписи вебхука
verifyWebhook({ secret, rawBody, headers, toleranceSec = 300 }) // → true / false
rawBody — нетронутые байты или строка тела. Объект после JSON.parse не подойдёт: подпись считается ровно по тем байтам, что пришли. Функция сама читает заголовки X-Webhook-Signature и X-Webhook-Timestamp, проверяет префикс sha256=, отбрасывает доставки старше 5 минут и сравнивает подписи способом, устойчивым к атаке по времени.
Полный пример на Express
import express from 'express';
import { QutPay, verifyWebhook } from '@qutpay/sdk';
const app = express();
const qp = new QutPay({ apiKey: process.env.QUTPAY_API_KEY });
// 1) Вебхук — объявлен ДО подключения express.json()
app.post('/qutpay-webhook', express.raw({ type: '*/*' }), async (req, res) => {
const ok = verifyWebhook({
secret: process.env.QUTPAY_WEBHOOK_SECRET,
rawBody: req.body, // Buffer
headers: req.headers,
});
if (!ok) return res.sendStatus(401);
const { event, invoice } = JSON.parse(req.body.toString('utf8'));
// Идемпотентность: одно событие может прийти дважды
const seen = await alreadyHandled(invoice.id, invoice.status);
if (!seen) {
if (event === 'invoice.paid') await markOrderPaid(invoice.externalOrderId, invoice);
if (event === 'invoice.refunded') await markOrderRefunded(invoice.externalOrderId);
await rememberHandled(invoice.id, invoice.status);
}
res.sendStatus(200); // без 2xx доставка повторится 11 раз
});
// 2) JSON — для всех остальных маршрутов
app.use(express.json());
app.post('/buy', async (req, res) => {
const order = await createOrder(req.body);
try {
const inv = await qp.createInvoice({
amount: order.total,
description: `Заказ №${order.id}`,
externalOrderId: String(order.id),
successUrl: `https://site.kz/orders/${order.id}`,
idempotencyKey: `order-${order.id}`,
});
await saveInvoiceId(order.id, inv.id);
res.redirect(303, inv.payUrl);
} catch (err) {
console.error('qutpay', err.status, err.code, err.message);
res.status(502).render('pay-error');
}
});
app.listen(3000);
Самая частая ошибка — express.json(), объявленный раньше маршрута вебхука. Тогда req.body превращается в объект, и подпись не сойдётся никогда.
Обработка ошибок
На каждый неуспешный ответ SDK бросает Error с двумя дополнительными полями:
| Поле | Значение |
|---|---|
err.status | HTTP-статус. При сетевой ошибке и таймауте — 0 |
err.code | Машинный код: invalid_amount, invoice_not_found, request_rate_limited… При таймауте timeout, при обрыве сети network_error |
err.message | Текст для человека |
try {
await qp.createInvoice({ amount, externalOrderId });
} catch (err) {
switch (err.code) {
case 'kaspi_session_expired':
notifyAdmin('Привязка кассира оборвалась');
break;
case 'tariff_limit_reached':
case 'tariff_daily_burst':
notifyAdmin('Лимит');
break; // повторять бессмысленно
case 'request_rate_limited':
case 'timeout':
case 'network_error':
await retryLater(); // бэкофф 1, 2, 4, 8 секунд
break;
default:
throw err;
}
}
Логику всегда стройте по err.code: текст message может меняться, код — нет. Полный список: Каталог ошибок.
Вопросы и ответы
Можно поставить из npm? Нет, пакет не опубликован в публичном реестре. Скачайте архив и подключите как локальную зависимость.
Заработает в CommonJS-проекте? Да: const { QutPay } = await import('@qutpay/sdk');.
Есть ли типы для TypeScript? Отдельного .d.ts нет, в модуле есть JSDoc-аннотации — типы придётся описать самому.
Как получить raw body в Fastify или Koa? В Fastify — addContentTypeParser('*', { parseAs: 'buffer' }, …), в Koa вместо koa-bodyparser используйте raw-body. Главное условие одно: получить тело нетронутым.
С чего начать тестирование? Создайте счёт тестовым ключом и вызовите qp.simulateInvoice(id, 'paid') — вебхук при этом уходит по-настоящему: Симуляция оплаты в песочнице.