Тест в sandbox
Создайте тестовый счёт, проверьте список и отмену. Симулируйте оплату через
POST /v1/sandbox/invoices/{id}/simulate.
Документация · API v1
Эта страница — для разработчиков, которые подключают Arzan Pay к сайту, боту, CRM или внутренней системе. Если вы владелец бизнеса — вернитесь на главную или создайте кабинет — там всё работает без программирования.
База: https://pay.arzan.cloud/api ·
Аутентификация: X-API-Key: <ключ> или
Authorization: Bearer <ключ> ·
Лимит: 120 запросов/мин на ключ (подробнее — в разделе «Лимиты»)
Тело всех POST-запросов — JSON, заголовок Content-Type: application/json обязателен: без него тело
не разбирается и запрос вернёт 400.
Для тестов добавляйте is_sandbox: true в тело запроса — мы не будем обращаться к реальному Kaspi.
Один HTTP-вызов выставляет счёт в Kaspi Pay. Один webhook сообщает, что оплата прошла. Всё остальное — ваша бизнес-логика.
Arzan Pay — не банк и не эквайер. Это сервис автоматизации поверх вашего Kaspi Pay-аккаунта. Деньги клиентов идут напрямую на ваш счёт в Kaspi — мы их не храним, не получаем и не задерживаем.
is_sandbox: true. Никаких реальных счетов в Kaspi.
Минимальный пример — создание счёта по номеру клиента в sandbox-режиме. Замените YOUR_API_KEY на ваш
ключ из кабинета.
curl -X POST https://pay.arzan.cloud/api/v1/invoices \
-H "X-API-Key: YOUR_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"amount": 10000,
"phone_number": "77011234567",
"description": "Заказ № 1042",
"external_order_id": "order_1042",
"is_sandbox": true
}'
const res = await fetch('https://pay.arzan.cloud/api/v1/invoices', {
method: 'POST',
headers: {
'X-API-Key': 'YOUR_API_KEY',
'Content-Type': 'application/json',
},
body: JSON.stringify({
amount: 10000,
phone_number: '77011234567',
description: 'Заказ № 1042',
external_order_id: 'order_1042',
is_sandbox: true,
}),
});
const invoice = await res.json();
console.log(invoice.id, invoice.status);
import requests
res = requests.post(
'https://pay.arzan.cloud/api/v1/invoices',
headers={'X-API-Key': 'YOUR_API_KEY'},
json={
'amount': 10000,
'phone_number': '77011234567',
'description': 'Заказ № 1042',
'external_order_id': 'order_1042',
'is_sandbox': True,
},
)
invoice = res.json()
print(invoice['id'], invoice['status'])
<?php
$ch = curl_init('https://pay.arzan.cloud/api/v1/invoices');
curl_setopt_array($ch, [
CURLOPT_POST => true,
CURLOPT_RETURNTRANSFER => true,
CURLOPT_HTTPHEADER => [
'X-API-Key: YOUR_API_KEY',
'Content-Type: application/json',
],
CURLOPT_POSTFIELDS => json_encode([
'amount' => 10000,
'phone_number' => '77011234567',
'description' => 'Заказ № 1042',
'external_order_id' => 'order_1042',
'is_sandbox' => true,
]),
]);
$invoice = json_decode(curl_exec($ch), true);
echo $invoice['id'] . ' ' . $invoice['status'];
В ответе придёт invoice.id, статус (pending), сумма. В боевом режиме клиент получит
push в Kaspi-приложение. В sandbox push не отправляется и ничего не симулируется автоматически — статус
меняете сами через POST /v1/sandbox/invoices/{id}/simulate (см. «Песочница»).
Готовые рецепты по шагам для типовых задач. Copy-paste и адаптируй под свой стек.
В кабинете → Разработчику → API-ключи → Создать ключ. Полный ключ покажется один раз. Сохраните его в переменные окружения вашего приложения.
Передайте сумму в тенге, номер телефона клиента и (опционально) свой external_order_id —
по нему потом сопоставите счёт с заказом в вашей CRM.
curl -X POST https://pay.arzan.cloud/api/v1/invoices \
-H "X-API-Key: $ARZAN_KEY" \
-H "Idempotency-Key: order_1042" \
-H "Content-Type: application/json" \
-d '{
"amount": 10000,
"phone_number": "77011234567",
"description": "Заказ № 1042",
"external_order_id": "order_1042"
}'
const invoice = await fetch('https://pay.arzan.cloud/api/v1/invoices', {
method: 'POST',
headers: {
'X-API-Key': process.env.ARZAN_KEY,
'Idempotency-Key': `order_${orderId}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({
amount: total,
phone_number: customerPhone,
description: `Заказ № ${orderId}`,
external_order_id: `order_${orderId}`,
}),
}).then((r) => r.json());
// Сохраните invoice.id у себя — он понадобится для возврата
await db.orders.update(orderId, { arzan_invoice_id: invoice.id });
import requests
invoice = requests.post(
'https://pay.arzan.cloud/api/v1/invoices',
headers={
'X-API-Key': os.environ['ARZAN_KEY'],
'Idempotency-Key': f'order_{order_id}',
},
json={
'amount': total,
'phone_number': customer_phone,
'description': f'Заказ № {order_id}',
'external_order_id': f'order_{order_id}',
},
).json()
# Сохраните invoice['id'] у себя — он понадобится для возврата
db.orders.update(order_id, arzan_invoice_id=invoice['id'])
Когда клиент оплатит счёт в Kaspi, мы пришлём POST на ваш webhook URL с событием
invoice.status_changed и status: "paid" — обычно в течение ~5 секунд после
оплаты. Не доверяйте этому событию слепо — проверяйте подпись (см. сценарий «Проверка подписи»).
Счёт на номер отслеживается до финального статуса, но не дольше 24 часов — дальше сверяйтесь через
POST /v1/invoices/status/check.
payment_url будет null — счёт идёт
push'ом в Kaspi-приложение клиента, ссылки нет. Если нужна ссылка/QR — используйте сценарий «QR / ссылка».
Если клиент хочет «оплатить позже из мессенджера» — выставляйте именно счёт на номер: QR и ссылка живут
всего 5 минут (см. «Срок жизни QR и ссылки»).
Без номера клиента. В ответе вернутся payment_url, deeplink,
qr_token — это одна и та же ссылка https://pay.kaspi.kz/pay/XXX. Отдельно —
unified_qr (он же qr_original_token): ссылка вида
https://qr.kaspi.kz/..., которую сканируют и приложения других банков.
Вместо comment можно передать description.
curl -X POST https://pay.arzan.cloud/api/v1/payment-links \
-H "X-API-Key: $ARZAN_KEY" \
-H "Idempotency-Key: cart_a-2041" \
-H "Content-Type: application/json" \
-d '{
"amount": 24500,
"comment": "Корзина A-2041",
"external_order_id": "cart_a-2041"
}'
# Ответ (201):
# {
# "id": "qr_9f2c41d0",
# "payment_id": "qr_9f2c41d0",
# "external_order_id": "cart_a-2041",
# "amount": "24500.00",
# "currency": "KZT",
# "status": "pending",
# "qr_operation_id": "15660074306",
# "payment_url": "https://pay.kaspi.kz/pay/XXXX",
# "deeplink": "https://pay.kaspi.kz/pay/XXXX",
# "qr_token": "https://pay.kaspi.kz/pay/XXXX",
# "unified_qr": "https://qr.kaspi.kz/XXXX",
# "qr_original_token": "https://qr.kaspi.kz/XXXX",
# "expires_at": "2026-05-24T14:15:00.000Z", (создание + 5 минут)
# "is_sandbox": false,
# "created_at": "2026-05-24T14:10:00.000Z"
# }
const link = await fetch('https://pay.arzan.cloud/api/v1/payment-links', {
method: 'POST',
headers: {
'X-API-Key': process.env.ARZAN_KEY,
'Idempotency-Key': `cart_${cartId}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({
amount: cart.total,
comment: `Корзина ${cartId}`,
external_order_id: `cart_${cartId}`,
}),
}).then((r) => r.json());
// link.unified_qr — рисуйте из него QR-картинку: сканируют Kaspi и другие банки
// link.deeplink — для кнопки «Открыть в Kaspi» на мобильном
// (payment_url, deeplink и qr_token — одна и та же ссылка)
return {
qr_value: link.unified_qr || link.payment_url,
open_in_kaspi: link.deeplink,
expires_at: link.expires_at,
};
На десктопе: сгенерируйте QR-картинку из unified_qr любой QR-библиотекой
(qrcode.js, python-qrcode, phpqrcode) — такой QR сканируют
приложения Kaspi и других банков. Если unified_qr пустой — используйте
payment_url.
На мобильном: кнопка «Открыть в Kaspi» со ссылкой deeplink — тап откроет
приложение Kaspi сразу.
QR одноразовый и живёт 5 минут: на каждую попытку оплаты создавайте новый, а саму ссылку не открывайте «для проверки». Подробно — в разделе «Срок жизни QR и ссылки».
Аналогично сценарию по телефону — webhook прилетит с invoice.status_changed и
status: "paid". По полю invoice.external_order_id вы найдёте свой заказ.
Если клиент открыл ссылку и ушёл без оплаты, придёт status: "failed"; если QR так и не
отсканировали — expired.
Либо у себя в БД (вы сохранили invoice.id при создании), либо запросом
GET /v1/invoices?status=paid&external_order_id=order_1042 (точное совпадение).
По умолчанию список отдаёт только счета на номер; чтобы искать и среди QR/ссылок, добавьте
&type=all (или type=qr — только QR и ссылки).
Без указания суммы — полный возврат. С суммой меньше — частичный. Работает и для счетов на номер, и для
QR/ссылок. Частичные возвраты можно повторять, пока их сумма не достигнет суммы оплаты — иначе
422 refund_exceeds_remaining. Возврат синхронный: если ответ 201, деньги
уже возвращены.
# Полный возврат
curl -X POST https://pay.arzan.cloud/api/v1/invoices/inv_e80bbb3b/refund \
-H "X-API-Key: $ARZAN_KEY"
# Частичный возврат
curl -X POST https://pay.arzan.cloud/api/v1/refunds \
-H "X-API-Key: $ARZAN_KEY" \
-H "Content-Type: application/json" \
-d '{
"invoice_id": "inv_e80bbb3b",
"amount": 2000,
"reason": "Возврат части товара"
}'
// Полный возврат
await fetch(`https://pay.arzan.cloud/api/v1/invoices/${invoiceId}/refund`, {
method: 'POST',
headers: { 'X-API-Key': process.env.ARZAN_KEY },
});
// Частичный возврат
await fetch('https://pay.arzan.cloud/api/v1/refunds', {
method: 'POST',
headers: {
'X-API-Key': process.env.ARZAN_KEY,
'Content-Type': 'application/json',
},
body: JSON.stringify({
invoice_id: invoiceId,
amount: 2000,
reason: 'Возврат части товара',
}),
});
Сразу после успешного возврата прилетит событие invoice.refunded с объектом
invoice исходного счёта (отдельного объекта возврата в нём нет). Когда возвращена вся
сумма (одним или несколькими возвратами) — счёт переходит в refunded; пока возвращена часть — остаётся paid.
Список возвратов по счёту — GET /v1/invoices/{id}/refunds.
Когда вы добавляете webhook URL в кабинете (или через POST /v1/webhooks), в ответе придёт
secret (whsec_...) — это HMAC-ключ. Он показывается один раз,
потом его не получить — сохраните в переменные окружения. В POST /v1/webhooks поле
events обязательно; рекомендуем ["invoice.status_changed", "invoice.refunded"].
curl -X POST https://pay.arzan.cloud/api/v1/webhooks \
-H "X-API-Key: $ARZAN_KEY" \
-H "Content-Type: application/json" \
-d '{
"url": "https://shop.example.kz/webhook/arzan",
"events": ["invoice.status_changed", "invoice.refunded"],
"name": "Основной сайт"
}'
# 201 — в ответе secret: "whsec_..." (только в этом ответе)
Подпись считается над <timestamp>.<raw_body> (Stripe-style). Timestamp приходит
отдельным заголовком X-Webhook-Timestamp. Сначала проверяйте свежесть timestamp (±5 мин — защита
от replay), потом подпись через timingSafeEqual.
import crypto from 'node:crypto';
import express from 'express';
const app = express();
// ВАЖНО: raw body, не используйте express.json() ДО проверки
app.use('/webhook', express.raw({ type: 'application/json' }));
app.post('/webhook', (req, res) => {
const sig = req.headers['x-webhook-signature'];
const ts = Number(req.headers['x-webhook-timestamp']);
// 1. Anti-replay: timestamp не старше 5 мин
if (!ts || Math.abs(Math.floor(Date.now() / 1000) - ts) > 5 * 60) {
return res.status(401).send('stale or missing timestamp');
}
// 2. Подпись над `<ts>.<raw_body>` — req.body здесь Buffer с сырым телом
const expected = Buffer.from('sha256=' + crypto
.createHmac('sha256', process.env.WEBHOOK_SECRET)
.update(`${ts}.`)
.update(req.body)
.digest('hex'));
const received = Buffer.from(String(sig || ''));
// timingSafeEqual бросает исключение при разной длине — проверяем длину заранее
if (received.length !== expected.length || !crypto.timingSafeEqual(received, expected)) {
return res.status(401).send('bad signature');
}
const event = JSON.parse(req.body.toString('utf8'));
// обработать event
res.status(200).send('ok');
});
import hmac, hashlib, time
from flask import Flask, request, abort
app = Flask(__name__)
SECRET = os.environ['WEBHOOK_SECRET']
@app.post('/webhook')
def webhook():
raw = request.get_data() # raw body
sig = request.headers.get('X-Webhook-Signature', '')
ts = request.headers.get('X-Webhook-Timestamp', '')
# 1. Anti-replay: timestamp не старше 5 мин
try:
ts_int = int(ts)
except ValueError:
abort(401, 'bad timestamp')
if abs(int(time.time()) - ts_int) > 5 * 60:
abort(401, 'stale timestamp')
# 2. Подпись над `<ts>.<raw_body>`
expected = 'sha256=' + hmac.new(
SECRET.encode(),
f"{ts_int}.".encode() + raw,
hashlib.sha256,
).hexdigest()
if not hmac.compare_digest(sig, expected):
abort(401, 'bad signature')
event = request.get_json()
return 'ok', 200
<?php
$raw = file_get_contents('php://input');
$sig = $_SERVER['HTTP_X_WEBHOOK_SIGNATURE'] ?? '';
$ts = (int)($_SERVER['HTTP_X_WEBHOOK_TIMESTAMP'] ?? 0);
$secret = getenv('WEBHOOK_SECRET');
// 1. Anti-replay
if (!$ts || abs(time() - $ts) > 5 * 60) {
http_response_code(401);
exit('stale timestamp');
}
// 2. Подпись над `<ts>.<raw_body>`
$expected = 'sha256=' . hash_hmac('sha256', $ts . '.' . $raw, $secret);
if (!hash_equals($expected, $sig)) {
http_response_code(401);
exit('bad signature');
}
$event = json_decode($raw, true);
http_response_code(200);
echo 'ok';
timestamp + "." + raw_body, не над одним body.
GET /v1/invoices/{id} — получает текущее состояние из нашей БД. Если статус не обновляется
долго, можно принудительно подтянуть из Kaspi через POST /v1/invoices/status/check.
curl https://pay.arzan.cloud/api/v1/invoices/inv_e80bbb3b \
-H "X-API-Key: $ARZAN_KEY"
Передайте массив ids (наши id счетов, QR и ссылок) — для каждого статус будет подтянут
из Kaspi. Пустой массив — 422. Удобно для крон-задачи синхронизации, если пропустили
webhook'и, и для счетов на номер старше 24 часов. Этот вызов обновляет статусы, но webhook'и
не отправляет — результат берите из ответа.
curl -X POST https://pay.arzan.cloud/api/v1/invoices/status/check \
-H "X-API-Key: $ARZAN_KEY" \
-H "Content-Type: application/json" \
-d '{ "ids": ["inv_a", "inv_b", "qr_c"] }'
# Ответ:
# { "data": [
# { "id": "inv_a", "ok": true, "payment": { ...счёт... }, "kaspi": { ... } },
# { "id": "qr_c", "ok": false, "error": "payment_not_found", "message": "Платеж не найден." }
# ] }
Разработчику → Webhooks → Лог доставок).
QR и payment-link — одноразовые и короткоживущие. Это правило Kaspi, а не наше, и от него зависит, как строить оплату.
expires_at.
pay.kaspi.kz — то же самое, что отсканировать QR. Если клиент открыл её и ушёл без оплаты, Kaspi отменяет операцию (придёт failed), а повторное открытие покажет «QR не распознан»POST /v1/payment-links или POST /v1/qrPOST /v1/invoices) — он придёт push'ом в Kaspi-приложениеPOST /v1/invoices/{id}/cancel для QR и ссылок вернёт 422 cancel_not_supported_for_qr. Неоплаченный QR сам перейдёт в expiredpending без webhook'а — сверяйтесь через POST /v1/invoices/status/checkТри шага, после которых можно убрать is_sandbox и принимать реальные оплаты.
Создайте тестовый счёт, проверьте список и отмену. Симулируйте оплату через
POST /v1/sandbox/invoices/{id}/simulate.
Добавьте URL, сохраните secret, проверяйте подпись на raw body. Не парсите JSON до проверки.
Уберите is_sandbox. Передавайте external_order_id и Idempotency-Key.
Не создавайте счета в healthcheck.
is_sandbox: true в теле создания счёта, QR или ссылки. Kaspi не вызывается, деньги не двигаютсяpayment_url — заглушка: у sandbox-QR и ссылок она не открывается и не оплачивается. Проверяйте только вёрсткуPOST /v1/sandbox/invoices/{id}/simulate с телом {"status": "paid"}; допустимы paid, canceled, failed, pending. Работает только для sandbox-счетов, после вызова приходит invoice.status_changed402 plan_requiredcurl -X POST https://pay.arzan.cloud/api/v1/sandbox/invoices/inv_e80bbb3b/simulate \
-H "X-API-Key: $ARZAN_KEY" \
-H "Content-Type: application/json" \
-d '{ "status": "paid" }'
Полный список ниже, все пути — относительно https://pay.arzan.cloud/api. Без префикса
/v1 есть только два ApiPay-совместимых пути для миграции: POST /api/invoices и
POST /api/webhook/configure (полные URL — https://pay.arzan.cloud/api/invoices и
https://pay.arzan.cloud/api/webhook/configure). Другие старые пути (/api/invoice/*,
/api/qr/*, /api/history/* и т.п.) не поддерживаются.
GET /v1/invoices принимает параметры: status, external_order_id (точное
совпадение), type (invoice — по умолчанию, только счета на номер; qr — QR и
ссылки; all — всё), search (подстрока по id, external_order_id, комментарию, id в
Kaspi), is_sandbox, page, page_size (до 100).
| Метод | Путь | Статус | Что делает |
|---|---|---|---|
| POST | /organizations/verify | live | Проверить API-ключ и организацию. |
| GET | /v1/me | live | Текущий мерчант, его план и возможности. |
| GET | /v1/clients/lookup?phone= | live | Узнать имя Kaspi-клиента по номеру до выставления счёта. Лимит 30/мин на IP. |
| POST | /v1/invoices | live | Создать счёт по номеру — push в Kaspi-приложение клиента. Тело: phone_number, amount, description?, external_order_id?, is_sandbox?. |
| GET | /v1/invoices | live | Список с пагинацией и фильтрами: status, external_order_id, type=invoice|qr|all, search, is_sandbox, page, page_size. |
| GET | /v1/invoices/{id} | live | Получить счёт по ID (работает и для QR/ссылок qr_...). |
| POST | /v1/invoices/{id}/cancel | live | Отменить неоплаченный счёт на номер. Для QR — 422 cancel_not_supported_for_qr. |
| POST | /v1/invoices/status/check | live | Подтянуть свежие статусы из Kaspi. Тело: {"ids": [...]}. Webhook'и не шлёт. |
| POST | /v1/qr | live | Сгенерировать QR (sandbox или реальный). Ответ в camelCase: qrToken, unifiedQr, deeplink, expiresAt. |
| POST | /v1/payment-links | live | Создать ссылку оплаты без номера клиента. Ответ в snake_case (см. сценарий «QR / ссылка»). |
| POST | /v1/payments/link | live | Алиас payment-links для совместимости. |
| GET | /v1/payments/{id} | live | Платёж по ID. |
| POST | /v1/invoices/{id}/refund | live | Возврат по счёту или QR. Тело: amount? (по умолчанию — вся сумма), reason?. Ответ: {refund, invoice, kaspi}. |
| GET | /v1/refunds | live | Список возвратов. |
| POST | /v1/refunds | live | Создать возврат. Тело: invoice_id (или paymentId), amount (обязательно), reason?, external_order_id?. |
| GET | /v1/invoices/{id}/refunds | live | Возвраты по конкретному счёту. |
| GET | /v1/transactions | live | Лента всех операций (счета, возвраты). |
| GET | /v1/kaspi/session | live | Статус подключения Kaspi: последний успех, ошибка. |
| POST | /v1/sandbox/invoices/{id}/simulate | live | Симулировать оплату/отмену/ошибку без Kaspi. |
| GET | /v1/webhooks | live | Список webhook-адресов. |
| POST | /v1/webhooks | live | Добавить адрес. Тело: url, events[] (обязательно), name?. Ответ 201 с secret — показывается один раз. Приватные/небезопасные URL — 422. |
| DELETE | /v1/webhooks/{id} | live | Удалить webhook. |
| POST | /v1/webhooks/{id}/test | live | Тестовая доставка invoice.status_changed только на этот адрес (см. «Webhook события»). |
| GET | /v1/webhook-deliveries | live | История доставок. |
| POST | /v1/webhook-deliveries/{id}/retry | live | Переотправить тот же payload на тот же адрес (новая подпись, с повторами). Ответ 202. |
| GET | /v1/audit-logs | live | Лог действий в кабинете. |
| GET / POST | /v1/subscriptions | beta | Подписки: создаются и хранятся, но автосписания пока не выполняются — счета по ним не выставляются. По умолчанию is_sandbox: true. |
| GET | /v1/subscriptions/{id} | beta | Подписка по ID. |
| PUT | /v1/subscriptions/{id} | beta | Изменить amount, phone_number, description, external_subscriber_id. Невалидные значения — 422. |
| POST | /v1/subscriptions/{id}/pause | beta | Поставить на паузу. |
| POST | /v1/subscriptions/{id}/resume | beta | Возобновить. |
| POST | /v1/subscriptions/{id}/cancel | beta | Отменить. |
| GET | /v1/subscriptions/{id}/invoices | beta | Счета подписки (пока автосписания не работают — пусто). |
| GET / POST | /v1/catalog | mvp | Каталог товаров для интеграций. Хранится у нас, в Kaspi не синхронизируется. |
| POST | /invoices | compat | ApiPay-совместимое создание счёта. Полный URL — https://pay.arzan.cloud/api/invoices. |
| POST | /webhook/configure | compat | ApiPay-совместимая настройка webhook. Полный URL — https://pay.arzan.cloud/api/webhook/configure. |
Каждый webhook — POST с JSON-телом, в котором есть поле event с именем события. Подпись —
в заголовке X-Webhook-Signature: sha256=<hex>, считается над
<timestamp>.<raw_body>. Timestamp — в отдельном заголовке
X-Webhook-Timestamp (unix-seconds), проверяйте свежесть ±5 минут. Раскройте любое событие,
чтобы увидеть пример payload'а.
"events": ["invoice.status_changed", "invoice.refunded"] и не подписывайтесь на
payment.*. Поле events в POST /v1/webhooks обязательно, а
secret из ответа показывается один раз.
Формат зависит от того, на какие события подписан адрес. На одно событие адрес получает ровно одну доставку.
invoice.status_changed / invoice.refunded и не подписан на payment.*. Внутри — объект invoice с нашим id (inv_... или qr_...), external_order_id и status. Отмена через API, возвраты, песочница и тестовая доставка шлются только в этом форматеpayment.success, payment.failed, payment.expired. В нём paymentId — id операции в Kaspi, status — сырой статус Kaspi (например Processed), а нашего id и external_order_id нет. Шлётся только при изменении статуса, найденном фоновым опросом. Подписка на payment.* автоматически добавляет invoice.status_changed, но доставка всё равно идёт в плоском формате(event, invoice.id, invoice.status)payment.canceled и события подписок не отправляются{
"event": "invoice.status_changed",
"paymentId": "15660074306",
"invoice": {
"id": "inv_e80bbb3b",
"external_order_id": "order_1042",
"amount": "10000.00",
"currency": "KZT",
"status": "paid",
"description": "Заказ № 1042",
"phone_number": "77011234567",
"client_name": "Иван Иванов",
"kaspi_invoice_id": "15660074306",
"payment_url": null,
"is_sandbox": false,
"is_qr_token": false,
"created_at": "2026-05-24T14:10:00.000Z",
"updated_at": "2026-05-24T14:12:49.689Z",
"paid_at": "2026-05-24T14:12:49.689Z",
"canceled_at": null
},
"source": "Arzan Pay API",
"timestamp": "2026-05-24T14:12:50.123Z"
}
paymentId на верхнем уровне — id операции в Kaspi (у sandbox-счетов — наш id). Для поиска
заказа используйте invoice.id и invoice.external_order_id.
{
"event": "invoice.refunded",
"paymentId": "15660074306",
"invoice": {
"id": "inv_e80bbb3b",
"external_order_id": "order_1042",
"amount": "10000.00",
"currency": "KZT",
"status": "paid",
"description": "Заказ № 1042",
"kaspi_invoice_id": "15660074306",
"is_sandbox": false,
"is_qr_token": false,
"paid_at": "2026-05-24T14:12:49.689Z"
},
"source": "Arzan Pay API",
"timestamp": "2026-05-24T15:00:01.000Z"
}
Отдельного объекта возврата в payload нет — только исходный счёт. После полного возврата
invoice.status = refunded, после частичного остаётся paid. Сумму и
список возвратов берите из ответа на создание возврата или из GET /v1/invoices/{id}/refunds.
{
"event": "payment.success",
"paymentId": "15660074306",
"type": "invoice",
"status": "Processed",
"statusDesc": "",
"amount": 10000,
"qrToken": null,
"receiptUrl": null,
"orderNumber": null,
"data": { "Status": "Processed" },
"timestamp": "2026-05-24T14:12:50.123Z"
}
paymentId здесь — id операции в Kaspi, status — сырой статус Kaspi. Нашего
id и external_order_id в этом формате нет.
POST /v1/webhooks/{id}/test (или кнопка «Проверить уведомления» в кабинете) отправляет на
этот адрес обычное событие invoice.status_changed в invoice-формате с тестовым
sandbox-счётом: id вида inv_test_..., status: "paid", сумма 100 ₸.
Подпись настоящая — удобно проверить свой обработчик. В журнал платежей тестовый счёт не попадает. Отдельного
события webhook.test нет — отличайте тест по префиксу inv_test_.
GET /v1/webhook-deliveries; любую можно переотправить через POST /v1/webhook-deliveries/{id}/retryPOST /v1/invoices/status/checkВсе примеры — выше в сценарии «Проверка подписи». Главное:
X-Webhook-Signature: sha256=<hex> и X-Webhook-Timestamp: <unix-seconds>timestamp + "." + raw_body, не над одним body (Stripe-style — нельзя подменить timestamp не сломав подпись)crypto.timingSafeEqual, hmac.compare_digest, hash_equals). В Node.js сначала сравните длины буферов и при несовпадении сразу верните 401 — timingSafeEqual бросает исключение на буферах разной длиныJSON.stringify(req.body) — порядок ключей и пробелы могут отличаться, подпись не сойдётся
Если ваша система случайно отправит один и тот же запрос дважды (потерянное соединение, retry в очереди) —
мы не создадим два счёта. Передавайте уникальный Idempotency-Key в заголовке.
Idempotency-Key на все create / refund операцииidempotencyKey, external_order_id или externalId (первое найденное)Idempotent-Replay: true409 idempotency_key_conflict. Новая попытка с другими данными — новый ключPOST /v1/invoices бессрочно ловит повтор external_order_id и возвращает уже существующий счёт (с idempotent: true) вместо создания нового. Учтите: такой ответ приходит во внутреннем camelCase-формате, а не в обычной схеме счётаorder_${orderId} с гарантированной уникальностью| Status | Значение |
|---|---|
pending | Ожидает оплаты |
processing | Редкий промежуточный статус: Kaspi вернул незнакомый промежуточный статус. Ждите финального |
paid | Оплачен клиентом. Остаётся paid, пока возвращена только часть суммы |
failed | Не оплачен: клиент отказался или не подтвердил, Kaspi отклонил, не хватило средств, ошибка. Для QR — в том числе если клиент открыл ссылку и ушёл |
canceled | Отменён через API / кабинет или отменён на стороне Kaspi |
expired | Истёк срок оплаты (для QR — не отсканирован за отведённое время) |
refunded | Один возврат покрыл всю сумму |
Финальные статусы: paid, failed, canceled, expired,
refunded (переход paid → refunded возможен). Обратите внимание на
написание: canceled с одной l.
Возврат синхронный: Kaspi отвечает сразу, и если запрос вернул 201, у возврата статус
refunded — деньги уже возвращены. Если Kaspi отказал, возврат не создаётся, а запрос возвращает ошибку
(например kaspi_refund_failed). Промежуточных статусов у возврата нет.
| Status | Значение |
|---|---|
active | Активна (после создания и после resume) |
paused | Приостановлена через pause |
canceled | Отменена через cancel |
| Period | Значение |
|---|---|
daily | Каждый день |
weekly | Каждую неделю |
biweekly | Каждые 2 недели |
monthly | Каждый месяц |
quarterly | Каждые 3 месяца |
yearly | Каждый год |
Самые частые интеграции — что куда подключать.
Форма Tilda → ваш bridge-сервер → POST /v1/invoices. Статус приходит через webhook, который меняет состояние заказа.
Payment gateway создаёт счёт на checkout. Webhook переводит order в processing или completed.
Бот спрашивает сумму и номер клиента, создаёт счёт. Webhook отправляет «✓ оплачено» прямо в чат.
Робот сделки → bridge → счёт. ID счёта в пользовательском поле. Webhook двигает сделку по воронке.
Используйте external_order_id, чтобы связать чек или заказ с счётом Arzan Pay.
OpenAPI спека и Postman-коллекция — для генерации клиентов на любом языке за пять минут.
Все ошибки возвращают JSON с машинным кодом error, человеческим message, необязательными
details и timestamp. Ориентируйтесь на error, а не на текст
message.
{
"error": "invalid_amount",
"message": "Сумма должна быть от 1 до 10 000 000 KZT.",
"timestamp": "2026-05-24T14:10:00.000Z"
}
/v1/clients/lookup — 30 в минуту на IPX-RateLimit-*. При превышении — 429 rate_limit_exceeded с полем retry_after (секунды) и заголовком Retry-After7XXXXXXXXXX или 8XXXXXXXXXX (11 цифр). Пробелы, скобки, дефисы и + отбрасываются, номер приводится к 7XXXXXXXXXX: +7 (701) 123-45-67 и 87011234567 станут 77011234567402 plan_required. Если превышена квота оплаченных платежей и прошла отсрочка — не-GET запросы получают 402 plan_upgrade_required. Квота считает только оплаченные платежи. Использование тарифа — в заголовках X-Plan-*| HTTP | Когда | Что делать |
|---|---|---|
| 400 | Bad Request | Некорректный запрос: неверный телефон или сумма при создании счёта/QR/ссылки (invalid_phone, invalid_amount), не хватает обязательных полей (validation_error), не передан Content-Type: application/json. |
| 401 | Unauthorized | API-ключ отсутствует, неверен или отозван. |
| 402 | Payment Required | Нет оплаченного тарифа (plan_required) или превышена квота (plan_upgrade_required). Оплатите или смените тариф в кабинете. |
| 403 | Forbidden | Аккаунт приостановлен (merchant_suspended) или запрос пришёл с IP вне allowlist ключа (ip_not_allowed). |
| 404 | Not Found | Ресурс не существует или принадлежит другому мерчанту. |
| 409 | Conflict | Конфликт состояния: Kaspi не подключён, счёт уже в финальном статусе, счёт не оплачен для возврата, ключ идемпотентности повторён с другим телом. |
| 422 | Unprocessable | Запрос понятен, но не выполним: отмена QR, возврат больше остатка, неверная сумма возврата, пустой ids, небезопасный URL webhook'а и т.п. |
| 429 | Rate limit | Превышен лимит запросов. Подождите retry_after секунд (или Retry-After). |
| 502 | Kaspi error | Kaspi не принял запрос (коды kaspi_*_failed). Повторите позже; если повторяется — проверьте подключение Kaspi в кабинете. |
error)| Код | HTTP | Когда и что делать |
|---|---|---|
api_key_required | 401 | Ключ не передан. Добавьте X-API-Key или Authorization: Bearer. |
invalid_api_key | 401 | Ключ неверен или отозван. Создайте новый в кабинете. |
plan_required | 402 | Триал закончился, тариф не оплачен. Оплатите тариф в кабинете. |
plan_upgrade_required | 402 | Квота оплаченных платежей исчерпана и отсрочка прошла. Повысьте тариф; GET-запросы продолжают работать. |
merchant_suspended | 403 | Аккаунт приостановлен. Свяжитесь с поддержкой. |
ip_not_allowed | 403 | IP не входит в allowlist ключа. Добавьте IP в настройках ключа. |
invalid_phone | 400 | Неверный телефон. Передавайте 7XXXXXXXXXX или 8XXXXXXXXXX. |
invalid_amount | 400 / 422 | Сумма вне диапазона 1 – 10 000 000 ₸ (400 при создании) или сумма возврата ≤ 0 / больше суммы платежа (422). |
validation_error | 400 / 422 | Не хватает обязательных полей или они невалидны (например нет events у webhook'а, пустой ids). Смотрите message и details. |
invoice_not_found, payment_not_found | 404 | Счёт/платёж не найден. Проверьте ID и принадлежность к вашему мерчанту. |
webhook_not_found | 404 | Webhook не существует. Проверьте ID. |
idempotency_key_conflict | 409 | Тот же Idempotency-Key пришёл с другим телом. Для новой операции — новый ключ. |
kaspi_not_connected | 409 | Kaspi не подключён или сессия потеряна. Подключите в кабинете → Kaspi. |
invoice_not_cancelable | 409 | Счёт уже в финальном статусе — отменить можно только pending или processing. |
invoice_not_paid, payment_not_paid | 409 | Возврат возможен только по оплаченному (paid) платежу. |
cancel_not_supported_for_qr | 422 | QR и ссылки нельзя отменить через API — они сами истекают через 5 минут. |
refund_exceeds_remaining | 422 | Сумма возвратов превысила бы сумму оплаты. Уменьшите сумму. |
rate_limit_exceeded | 429 | Превышен лимит. Подождите retry_after секунд. |
kaspi_invoice_failed, kaspi_qr_failed, kaspi_refund_failed, kaspi_cancel_failed | 502 | Kaspi не выполнил операцию. Текст причины от Kaspi — в message. Повторите позже. |