Путь 1. Готовый виджет widget.js
Что делает
Виджет рисует кнопку «Оплатить через Kaspi», по клику открывает модалку с QR-кодом Kaspi, показывает таймер и сам опрашивает статус (раз в 5 секунд, до 10 минут), после оплаты показывает «Оплачено». Вес — до 10 КБ gzip, никаких зависимостей и конфликтов со стилями сайта. API-ключ виджет не хранит и не запрашивает — все секреты остаются на вашем сервере.
Подключение на странице
<script src="https://apipay.kz/widget.js" defer></script>
<div
data-apipay
data-amount="5000"
data-description="Заказ №42"
data-merchant-endpoint="/api/create-invoice"
data-check-endpoint="/api/check-invoice"
data-label="Оплатить через Kaspi"
></div>
Опциональная настройка внешнего вида: data-color (цвет кнопки), data-shape (rounded|pill|sharp|soft), data-size (sm|md|lg), data-full-width="true". Есть и программный API: window.ApiPayWidget.open(opts), .mount(el), .refresh().
Контракт вашего серверного эндпоинта
Виджет ходит не в ApiPay, а в ваш бэкенд (поэтому ключ не светится):
POST {data-merchant-endpoint} body: { amount, description }
→ { invoice_id, qr_image_url, qr_token_url, qr_expires_at }
GET {data-check-endpoint}?id={invoice_id}
→ { status: "pending" | "paid" | "cancelled" | "expired" }
Внутри merchant-эндпоинта вы создаёте QR-счёт (POST /invoices/qr с X-API-Key) и возвращаете его поля как есть. Критично: data-amount приходит из HTML и легко подменяется в DevTools — сервер обязан брать реальную сумму из своей корзины/заказа, а не доверять присланной. Помните про природу QR: он живёт ~5 минут — покупатель должен платить сразу (детали: «QR-счёт: TTL и лимиты»).
Путь 2. Свой бэкенд: форма → счёт по номеру → вебхук
Подходит, когда покупатель не обязан платить «здесь и сейчас»: счёт по номеру живёт 24 часа, покупатель получает push в приложении Kaspi. Полный минимальный сервер (Node 18+/Express, один файл):
// npm i express | запуск: APIPAY_API_KEY=... APIPAY_WEBHOOK_SECRET=... node server.js
const express = require("express");
const crypto = require("crypto");
const app = express();
const orders = new Map(); // demo-хранилище: external_order_id → {status, chatData}; в проде — БД
// 1) Форма отправляет сюда номер телефона покупателя
app.post("/api/pay", express.json(), async (req, res) => {
const phone = String(req.body.phone || "").replace(/\D/g, "").slice(-10);
if (phone.length !== 10) return res.status(422).json({ error: "Формат: 87001234567" });
const orderId = `site-${Date.now()}`;
const amount = 5000; // ВСЕГДА со своей стороны (корзина/заказ), НЕ из запроса браузера!
const r = await fetch("https://api.apipay.kz/api/v1/invoices", {
method: "POST",
headers: { "X-API-Key": process.env.APIPAY_API_KEY, "Content-Type": "application/json" },
body: JSON.stringify({
phone_number: "8" + phone, amount, description: "Оплата заказа на сайте",
external_order_id: orderId, external_order_id_idempotency: orderId,
}),
});
if (r.status !== 201) return res.status(502).json({ error: "Счёт не создан, попробуйте позже" });
orders.set(orderId, { status: "processing" });
res.json({ order_id: orderId }); // фронт покажет «Откройте Kaspi — там счёт»
});
// 2) Вебхук ApiPay: единственный источник правды об оплате
app.post("/webhooks/apipay", express.raw({ type: "application/json" }), (req, res) => {
const expected = "sha256=" + crypto.createHmac("sha256", process.env.APIPAY_WEBHOOK_SECRET)
.update(req.body).digest("hex"); // подпись — по СЫРОМУ телу
const got = Buffer.from(req.get("X-Webhook-Signature") || "");
const exp = Buffer.from(expected);
if (got.length !== exp.length || !crypto.timingSafeEqual(exp, got)) return res.status(401).end();
res.status(200).end(); // ответ быстрее 5 секунд, обработка после
const { event, invoice } = JSON.parse(req.body);
if (event === "invoice.status_changed" && orders.has(invoice.external_order_id)) {
orders.get(invoice.external_order_id).status = invoice.status; // paid / expired / cancelled / error
}
});
// 3) Страница «ждём оплату» опрашивает СВОЙ бэкенд (не ApiPay!)
app.get("/api/order-status", (req, res) => {
res.json(orders.get(req.query.id) || { status: "unknown" });
});
app.use(express.static("public")); // форма и страница статуса
app.listen(3000);
Фронтенд предельно простой: форма шлёт номер на /api/pay, затем страница раз в 3–5 секунд спрашивает /api/order-status?id=… и показывает «Оплачено», когда вебхук перевёл заказ в paid. Опрос собственного бэкенда — нормально; нельзя опрашивать из браузера сам ApiPay (для этого пришлось бы светить ключ). Учтите легитимные гонки статусов (expired → paid): «Как создать счёт по номеру».
Безопасность: три правила, которые нельзя нарушать
- API-ключ — только на сервере. Ключ в HTML/JS виден каждому посетителю: с ним можно выставлять счета от вашего имени. Виджет спроектирован так, что ключ ему не нужен, — не «упрощайте» схему.
- Сумму определяет сервер. Всё, что пришло из браузера (
data-amount, поля формы), — недоверенное: сверяйте с корзиной на бэкенде. - Оплату подтверждает только вебхук с проверенной подписью. Не редирект «спасибо за оплату», не ответ виджета — только
invoice.status_changed: paid, чья подписьX-Webhook-Signatureсошлась по raw body. Настройка и примеры проверки: «Как настроить вебхуки ApiPay».
А если сайт на Tilda или другом конструкторе?
Честный ответ: фронт-часть встраивается, серверная — нет. Конструкторы не дают запускать серверный код, а для оплаты нужны две серверные вещи: место, где живёт API-ключ, и URL для вебхука. Рабочие варианты:
- Мини-бэкенд (любой VPS/PaaS, код выше — 60 строк) + на Tilda вставка HTML-блока с виджетом или формой, указывающей на ваш
/api/pay. - n8n вместо кода: сценарий «форма → создать счёт → принять вебхук → письмо/уведомление» собирается мышкой — «Интеграция ApiPay с n8n».
- Lovable/AI-конструкторы с серверными функциями: серверную часть генерирует ИИ — дайте агенту apipay.kz/llms.txt и «плейбук для ИИ».
Готового плагина «для Tilda/WordPress в один клик» пока нет — не верьте страницам, которые обещают обратное.
Частые ошибки
- API-ключ в клиентском JS. Самая опасная ошибка. Ключ утёк — перегенерируйте немедленно («API-ключ и вебхук-секрет»).
- Доверять сумме из браузера. Подмена
data-amount/поля формы = оплата 10 ₸ вместо 10 000 ₸. Сумма — из вашей БД. - Засчитывать оплату по возврату покупателя на «страницу спасибо». Только вебхук с проверенной подписью.
- Виджет + покупатель «оплатит потом». Виджет показывает QR (~5 минут). Для «потом» — путь 2 (счёт по номеру, 24 часа).
- Вебхук на localhost/за Basic auth. Нужен открытый публичный HTTPS-URL; локально — ngrok («Локальное тестирование»).
- Тест в песочнице, ожидание реального push. В тестовом режиме счета в Kaspi не уходят; оплату имитируйте в кабинете, в прод — через «Рабочий режим».
Вопросы и ответы
Что выбрать: виджет или свой бэкенд?
Покупатель платит в момент заказа на странице — виджет (быстрее внедрить). Нужны «оплатит в течение дня», свои экраны и логика заказов — путь 2. Их можно совмещать.
Можно ли обойтись совсем без сервера?
Нет: API-ключ и вебхук требуют серверной точки. Минимум без кода — n8n-сценарий.
Виджет платный?
Виджет — часть сервиса, отдельно не тарифицируется; действует ваша подписка ApiPay. Подключение — по шагам этой статьи; если что-то не выходит, напишите в поддержку.
Как виджет узнаёт про оплату — вебхук не нужен?
Виджет опрашивает ваш check-эндпоинт (раз в 5 секунд, до 10 минут) — этого хватает для экрана покупателя. Но заказ в вашей системе всё равно подтверждайте вебхуком: это единственный надёжный канал.
Что покупатель видит при оплате по номеру?
Push в приложении Kaspi: счёт с описанием и кнопкой «Оплатить» — одно касание. Деньги сразу на вашем Kaspi-счету.
Поддерживается ли WooCommerce/WordPress?
Через REST API (путь 2) — да; готового плагина нет.