> Источник: https://apipay.kz/guides/kaspi-oplata-na-sayte · Обновлено: 2026-10-25 · apipay.kz
> ApiPay — независимый сервис приёма платежей поверх вашего Kaspi Pay.

# Как принять оплату Kaspi на сайте: виджет или свой код?

**TL;DR.** Сайт принимает оплату Kaspi через ApiPay — [API поверх Kaspi Pay](/kaspi-api) вашего бизнеса, деньги идут напрямую на ваш Kaspi-счёт. Путей два: **готовый виджет** `widget.js` (≤10 КБ, кнопка «Оплатить через Kaspi» и QR, от вас — два небольших серверных эндпоинта: один создаёт QR-счёт, другой отдаёт виджету его статус) или **свой код** — форма с номером телефона, `POST /invoices`, покупатель платит по push в Kaspi в течение 24 часов. Об оплате ваш сервер узнаёт опросом `GET /invoices/{id}` — для старта этого хватит; вебхук и его секрет подключаются по желанию. API-ключ живёт только на сервере, поэтому даже сайту на Tilda нужна серверная часть — мини-бэкенд или сценарий n8n.

## Что выбрать: виджет или свой код?

| | Виджет `widget.js` | Свой код: счёт по номеру |
|---|---|---|
| Когда платит покупатель | Сейчас, на странице заказа | В течение 24 часов, по push в Kaspi |
| Что видит покупатель | Кнопку, QR-код и «Открыть в Kaspi» | Push в приложении Kaspi на свой номер |
| Запрос к ApiPay | `POST /invoices/qr` | `POST /invoices` |
| Что пишете вы | Два серверных эндпоинта: создать счёт и отдать статус | Форму, эндпоинт создания счёта и проверку статуса |
| Стоимость | Входит в подписку ApiPay | Входит в подписку ApiPay |

Пути можно совмещать. Что ещё нужно магазину, кроме кода, — Kaspi Pay вашего бизнеса (ИП, ТОО и другие формы), отдельный номер под роль «Кассир» и тариф — разобрано в статье «[Как принимать Kaspi на сайте без Kaspi-магазина](/guides/apipay-dlya-internet-magazina)».

## Как подключить готовый виджет widget.js?

### Что делает

Виджет рисует кнопку «Оплатить через Kaspi», по клику открывает модалку с QR-кодом, кнопкой «Открыть в Kaspi» и копированием ссылки на оплату, сам опрашивает статус (раз в 5 секунд, до 10 минут) и после оплаты показывает «Оплачено». Вес — до 10 КБ gzip, без зависимостей. API-ключ виджет **не хранит и не запрашивает**.

### Подключение на странице

```html
<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 }   // статус счёта из GET /invoices/{id} как есть
```

Внутри merchant-эндпоинта вы создаёте QR-счёт (`POST /invoices/qr` с `X-API-Key`). ApiPay отдаёт идентификатор счёта в поле **`id`** — верните его виджету как **`invoice_id`**, иначе опрос статуса не запустится и модалка так и останется в «Ожидаем оплату…». Поля `qr_image_url`, `qr_token_url`, `qr_expires_at` называются одинаково — их отдавайте как есть. **Критично:** `data-amount` приходит из HTML и легко подменяется в DevTools — сервер обязан брать реальную сумму из своей корзины/заказа, а не доверять присланной.

Check-эндпоинт спрашивает у ApiPay `GET /invoices/{id}` и отдаёт виджету статус. Промежуточные `processing` и `cancelling` — не отказ: виджет продолжит ждать, новый счёт не создавайте. Модалка перестаёт ждать через 10 минут или на `expired`/`cancelled`, но заказ на вашей стороне закрывайте не по ней: закрытый счёт ещё может смениться на `paid`, поэтому сервер сверяет его ещё 24 часа — схема в следующем разделе. Если модалка показала «QR истёк», а покупатель уже подтвердил оплату в Kaspi, не выставляйте новый счёт по тому же заказу сразу: сначала сверьте статус прежнего — оплата могла завершиться чуть позже.

Окно на скан QR короткое, точный момент берите из поля `qr_expires_at` и не зашивайте длительность константой — сколько живёт QR и что с ним происходит дальше: «[QR-счёт: TTL и лимиты](/guides/qr-schet-ttl-i-limity)».

## Как принять оплату своим кодом: форма, счёт по номеру, статус?

Подходит, когда покупатель не обязан платить «здесь и сейчас»: [счёт по номеру](/invoice-by-phone) живёт **24 часа**, покупатель получает push в приложении Kaspi. Для старта хватит опроса: сервер сам спрашивает `GET /invoices/{id}`, вебхук не нужен. Полный минимальный сервер (Node 20.6+/Express, один файл):

```js
// npm i express   |   ключ — в файле .env (APIPAY_API_KEY=…), не в команде; .env — в .gitignore
// запуск: node --env-file=.env server.js
const express = require("express");

const API = "https://api.apipay.kz/api/v1";
const HEADERS = { "X-API-Key": process.env.APIPAY_API_KEY, "Content-Type": "application/json" };
const CLOSED = ["expired", "cancelled", "error"];
const DAY = 24 * 60 * 60 * 1000;

const app = express();
const orders = new Map(); // demo-хранилище: orderId → {invoiceId, status, closedAt}; в проде — БД

// 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(`${API}/invoices`, {
    method: "POST",
    headers: HEADERS,
    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: "Счёт не создан, попробуйте позже" });

  const invoice = await r.json(); // status: "processing" — Kaspi ещё не вызван
  orders.set(orderId, { invoiceId: invoice.id, status: invoice.status });
  res.json({ order_id: orderId }); // фронт покажет «Откройте Kaspi — там счёт»
});

// 2) Статус спрашивает только сервер: GET /invoices/{id}
async function refresh(order) {
  const r = await fetch(`${API}/invoices/${order.invoiceId}`, { headers: HEADERS });
  if (!r.ok || order.status === "paid") return; // оплаченный заказ не понижаем
  const { status } = await r.json(); // processing / cancelling — ждём, счёт не пересоздаём
  order.status = status;
  order.closedAt = CLOSED.includes(status) ? order.closedAt || Date.now() : undefined;
}

// 3) Страница «ждём оплату» опрашивает СВОЙ бэкенд (не ApiPay!)
app.get("/api/order-status", async (req, res) => {
  const order = orders.get(req.query.id);
  if (!order) return res.json({ status: "unknown" });
  await refresh(order).catch(() => {});
  res.json({ status: order.status });
});

// 4) Фоновая сверка раз в минуту: всё без paid, закрытые счета — ещё 24 часа
setInterval(() => {
  for (const order of orders.values()) {
    if (order.status === "paid" || Date.now() - (order.closedAt || Date.now()) > DAY) continue;
    refresh(order).catch(() => {});
  }
}, 60_000);

app.use(express.static("public")); // форма и страница статуса
app.listen(3000);
```

Фронтенд простой: форма шлёт номер на `/api/pay`, затем страница раз в 3–5 секунд спрашивает `/api/order-status?id=…` и показывает «Оплачено», когда счёт перешёл в `paid`. Опрос собственного бэкенда — нормально; нельзя опрашивать из браузера сам ApiPay (для этого пришлось бы светить ключ). У `GET /invoices/{id}` отдельный лимит — до 1000 запросов в минуту на ключ. Что делать магазину на каждом статусе счёта — таблица в статье «[Как принимать Kaspi на сайте без Kaspi-магазина](/guides/apipay-dlya-internet-magazina)».

### Когда подключать вебхук?

Когда важен мгновенный отклик или счетов много: ApiPay сам пришлёт POST на ваш публичный HTTPS-адрес при смене статуса. Адрес сохраняется в кабинете, после этого выпускается секрет подписи — добавьте его в тот же `.env` строкой `APIPAY_WEBHOOK_SECRET=…` и перезапустите сервер. Опрос при этом не убирайте — он остаётся страховкой на случай, если уведомление не дошло. Обработчик к серверу выше:

```js
const crypto = require("crypto");

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);
  const order = orders.get(invoice.external_order_id);
  if (event === "invoice.status_changed" && order && order.status !== "paid") {
    order.status = invoice.status; // paid / expired / cancelled / error
  }
});
```

Порядок доставки вебхуков не гарантирован: корректирующий `paid` и «опоздавший» `expired`/`cancelled` могут прийти в обратном порядке, поэтому уже оплаченный заказ не понижается. Про гонки статусов — «[Как создать счёт по номеру](/guides/kak-sozdat-schet-kaspi-po-nomeru)», про повторы доставки и проверку подписи — «[Как настроить вебхуки ApiPay](/guides/nastroyka-webhookov-apipay)».

## Какие правила безопасности нельзя нарушать?

1. **API-ключ — только на сервере.** Ключ в HTML/JS виден каждому посетителю: с ним можно выставлять счета от вашего имени. Виджет спроектирован так, что ключ ему не нужен. Если ключ утёк — перегенерируйте его немедленно («[API-ключ и вебхук-секрет](/guides/api-klyuch-i-webhook-secret)»).
2. **Сумму определяет сервер.** Всё, что пришло из браузера (`data-amount`, поля формы), — недоверенное: сверяйте с корзиной на бэкенде.
3. **Оплату подтверждает только ваш сервер по данным ApiPay.** Не редирект «спасибо за оплату» и не ответ виджета, а статус `paid` из `GET /invoices/{id}` или из вебхука `invoice.status_changed`, чья подпись `X-Webhook-Signature` сошлась по raw body.

## А если сайт на Tilda или другом конструкторе?

**Фронт-часть встраивается, серверная — нет.** Конструкторы не дают запускать серверный код, а API-ключ должен жить на сервере: там же создаётся счёт и проверяется статус. Рабочие варианты:

- **Мини-бэкенд** (любой VPS/PaaS, код выше) + на Tilda вставка HTML-блока с виджетом или формой, указывающей на ваш `/api/pay`.
- **n8n вместо кода**: сценарий «форма → создать счёт → проверить статус → письмо/уведомление» собирается мышкой — «[Интеграция ApiPay с n8n](/n8n-integration)».
- **Lovable/AI-конструкторы с серверными функциями**: серверную часть генерирует ИИ — дайте агенту [плейбук для ИИ](/for-ai) и [документацию API](/docs).

Готового плагина «для Tilda/WordPress в один клик» пока нет — WooCommerce и подобные подключаются через REST API (свой код).

## Где чаще всего ошибаются?

- **Новый счёт на `processing` или `cancelling`.** Это промежуточные статусы, а не отказ: дождитесь итогового и не создавайте второй счёт на тот же заказ.
- **Заказ закрыт навсегда на `expired` или `cancelled`.** Эти статусы ещё могут смениться на `paid` — сверяйте такие счета ещё 24 часа.
- **Вебхук на localhost, за Basic auth или на туннельном адресе.** Если подключаете вебхук, нужен открытый публичный HTTPS-URL. Туннель (ngrok, «[Локальное тестирование](/local-testing)») годится только для разработки: в рабочем режиме кабинет ждёт постоянный адрес на вашем домене.
- **Тест в песочнице, ожидание реального push.** В тестовом режиме счета в Kaspi не уходят; оплату имитируйте в кабинете, в прод — через «Рабочий режим».

## Вопросы и ответы

**Как принять оплату Kaspi на сайте?**
Через ApiPay — API поверх Kaspi Pay вашего бизнеса. Два пути: виджет `widget.js` с кнопкой и QR-кодом (ваш сервер создаёт QR-счёт) или свой код — форма с номером телефона, `POST /invoices`, покупатель платит по push в Kaspi в течение 24 часов. Об оплате сервер узнаёт опросом `GET /invoices/{id}` или вебхуком.

**Что выбрать: виджет или свой бэкенд?**
Покупатель платит в момент заказа на странице — виджет, его быстрее внедрить. Нужно «оплатит в течение дня», свои экраны и логика заказов — свой код со счётом по номеру. Их можно совмещать, отдельной платы за виджет нет: действует ваша подписка ApiPay.

**Обязателен ли вебхук?**
Нет. Для старта хватит опроса `GET /invoices/{id}` с вашего сервера; закрытые `expired`, `cancelled` и `error` сверяйте ещё 24 часа — они могут смениться на `paid`. Вебхук подключают, когда нужен мгновенный отклик или счетов много, а опрос остаётся страховкой.

**Можно ли подключить оплату Kaspi без сервера?**
Нет: API-ключ должен храниться на сервере, в коде страницы его увидит каждый посетитель. Минимальный вариант без кода — сценарий в n8n; для конструкторов (Tilda) фронт встраивается, а серверную точку даёт мини-бэкенд или n8n.

**Есть ли готовый плагин ApiPay для Tilda или WordPress?**
Готового плагина «в один клик» нет. Рабочие варианты: HTML-вставка виджета и мини-бэкенд, сценарий в n8n без кода, либо серверные функции AI-конструкторов (Lovable) по плейбуку apipay.kz/for-ai.

Смотрите также: [Как принимать Kaspi на сайте без Kaspi-магазина](/guides/apipay-dlya-internet-magazina) · [Вебхуки ApiPay](/guides/nastroyka-webhookov-apipay) · [QR-счёт: TTL и лимиты](/guides/qr-schet-ttl-i-limity) · [Как создать счёт по номеру](/guides/kak-sozdat-schet-kaspi-po-nomeru) · [Kaspi-оплата в Telegram-боте](/guides/kaspi-oplata-v-telegram-bote) · [Документация API](/docs) · пиллар «[Как принимать оплату Kaspi через API](/kaspi-api)».

---

ApiPay — независимый сервис и не аффилирован с АО «Kaspi Bank». Kaspi и Kaspi Pay — товарные знаки их правообладателя.
База знаний: https://apipay.kz/guides
