Документация · API v1

REST API для приёма Kaspi Pay.

Эта страница — для разработчиков, которые подключают 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.

I.

Что такое API Arzan Pay.

Один HTTP-вызов выставляет счёт в Kaspi Pay. Один webhook сообщает, что оплата прошла. Всё остальное — ваша бизнес-логика.

Arzan Pay — не банк и не эквайер. Это сервис автоматизации поверх вашего Kaspi Pay-аккаунта. Деньги клиентов идут напрямую на ваш счёт в Kaspi — мы их не храним, не получаем и не задерживаем.

  • Что вы получаете — REST API для создания счетов, QR-кодов, ссылок и возвратов (подписки — в бете).
  • Что нужно — подключённый Kaspi Pay (один раз в кабинете) и API-ключ.
  • Где деньги — на вашем счёте в Kaspi Business. Arzan Pay касается только метаданных и статусов.
  • Sandbox — пока тестируете, передавайте is_sandbox: true. Никаких реальных счетов в Kaspi.
II.

Быстрый старт за один curl.

Минимальный пример — создание счёта по номеру клиента в 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 (см. «Песочница»).

III.

Сценарии интеграции.

Готовые рецепты по шагам для типовых задач. Copy-paste и адаптируй под свой стек.

01

Получите API-ключ

В кабинете → Разработчику → API-ключи → Создать ключ. Полный ключ покажется один раз. Сохраните его в переменные окружения вашего приложения.

02

Создайте счёт по номеру клиента

Передайте сумму в тенге, номер телефона клиента и (опционально) свой 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'])
03

Дождитесь webhook'а об оплате

Когда клиент оплатит счёт в 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 и ссылки»).
01

Создайте payment-link

Без номера клиента. В ответе вернутся 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,
};
02

Покажите клиенту

На десктопе: сгенерируйте QR-картинку из unified_qr любой QR-библиотекой (qrcode.js, python-qrcode, phpqrcode) — такой QR сканируют приложения Kaspi и других банков. Если unified_qr пустой — используйте payment_url. На мобильном: кнопка «Открыть в Kaspi» со ссылкой deeplink — тап откроет приложение Kaspi сразу.

QR одноразовый и живёт 5 минут: на каждую попытку оплаты создавайте новый, а саму ссылку не открывайте «для проверки». Подробно — в разделе «Срок жизни QR и ссылки».

03

Получите webhook об оплате

Аналогично сценарию по телефону — webhook прилетит с invoice.status_changed и status: "paid". По полю invoice.external_order_id вы найдёте свой заказ. Если клиент открыл ссылку и ушёл без оплаты, придёт status: "failed"; если QR так и не отсканировали — expired.

Перед запуском прочитайте «Срок жизни QR и ссылки»: 5 минут на всё, одноразовость, почему нельзя открывать ссылку для проверки и почему QR нельзя отменить через API.
01

Найдите ID оплаченного счёта

Либо у себя в БД (вы сохранили invoice.id при создании), либо запросом GET /v1/invoices?status=paid&external_order_id=order_1042 (точное совпадение). По умолчанию список отдаёт только счета на номер; чтобы искать и среди QR/ссылок, добавьте &type=all (или type=qr — только QR и ссылки).

02

Создайте возврат

Без указания суммы — полный возврат. С суммой меньше — частичный. Работает и для счетов на номер, и для 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: 'Возврат части товара',
  }),
});
03

Получите webhook о возврате

Сразу после успешного возврата прилетит событие invoice.refunded с объектом invoice исходного счёта (отдельного объекта возврата в нём нет). Когда возвращена вся сумма (одним или несколькими возвратами) — счёт переходит в refunded; пока возвращена часть — остаётся paid. Список возвратов по счёту — GET /v1/invoices/{id}/refunds.

01

Сохраните secret при создании webhook

Когда вы добавляете 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_..." (только в этом ответе)
02

Проверяйте подпись и timestamp на каждом запросе

Подпись считается над <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';
Никогда не парсите JSON до проверки подписи и не пересериализуйте тело — это сломает подпись. Большинство веб-фреймворков по умолчанию парсят body. Отключите парсинг на webhook-эндпоинте. Подпись считается над timestamp + "." + raw_body, не над одним body.
01

По одному счёту

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"
02

Массовая проверка

Передайте массив 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": "Платеж не найден." }
# ] }
Webhook'и — основной механизм. Polling используйте только как fallback. Если webhook'и не доходят — проверьте логи в кабинете (Разработчику → Webhooks → Лог доставок).
IV.

Срок жизни QR и ссылки.

QR и payment-link — одноразовые и короткоживущие. Это правило Kaspi, а не наше, и от него зависит, как строить оплату.

5 минут на всё. С момента создания у клиента есть 180 секунд, чтобы отсканировать QR (или открыть ссылку), и после скана — 65 секунд, чтобы подтвердить оплату в Kaspi. Общий срок жизни — 5 минут, он же в поле expires_at.
  • QR одноразовый. Открыть ссылку pay.kaspi.kz — то же самое, что отсканировать QR. Если клиент открыл её и ушёл без оплаты, Kaspi отменяет операцию (придёт failed), а повторное открытие покажет «QR не распознан»
  • Не открывайте ссылку «для проверки». Вы потратите QR, и клиент уже не сможет по нему заплатить
  • Новый QR на каждую попытку оплаты. Не кэшируйте QR и не переиспользуйте старый — создавайте новый через POST /v1/payment-links или POST /v1/qr
  • Несколько живых QR одновременно — можно. Например, на нескольких кассах или для нескольких клиентов сразу
  • «Оплатить позже из мессенджера» — не отправляйте ссылку, она истечёт через 5 минут. Выставляйте счёт на номер клиента (POST /v1/invoices) — он придёт push'ом в Kaspi-приложение
  • QR нельзя отменить через API — POST /v1/invoices/{id}/cancel для QR и ссылок вернёт 422 cancel_not_supported_for_qr. Неоплаченный QR сам перейдёт в expired
  • Как быстро узнаём об оплате — статусы опрашиваются у Kaspi каждые 5 секунд, так что оплата и webhook появляются в течение ~5 секунд
  • Счёт на номер — у нас своего срока нет: отслеживаем его до финального статуса в Kaspi, но не дольше 24 часов. После этого (или если сессия Kaspi истекла) счёт остаётся pending без webhook'а — сверяйтесь через POST /v1/invoices/status/check
V.

Чек-лист перед продакшеном.

Три шага, после которых можно убрать is_sandbox и принимать реальные оплаты.

V · 01

Тест в sandbox

Создайте тестовый счёт, проверьте список и отмену. Симулируйте оплату через POST /v1/sandbox/invoices/{id}/simulate.

V · 02

Подключите webhook

Добавьте URL, сохраните secret, проверяйте подпись на raw body. Не парсите JSON до проверки.

V · 03

Перейдите в live

Уберите is_sandbox. Передавайте external_order_id и Idempotency-Key. Не создавайте счета в healthcheck.

Песочница

  • Как включить — is_sandbox: true в теле создания счёта, QR или ссылки. Kaspi не вызывается, деньги не двигаются
  • Push не отправляется — sandbox-счёт на номер никому не приходит, оплату автоматически ничто не симулирует
  • payment_url — заглушка: у sandbox-QR и ссылок она не открывается и не оплачивается. Проверяйте только вёрстку
  • Смена статуса — POST /v1/sandbox/invoices/{id}/simulate с телом {"status": "paid"}; допустимы paid, canceled, failed, pending. Работает только для sandbox-счетов, после вызова приходит invoice.status_changed
  • Тариф — после окончания триала без оплаченного тарифа песочница тоже отвечает 402 plan_required
curl -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" }'
VI.

Все эндпоинты API v1.

Полный список ниже, все пути — относительно 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/verifyliveПроверить API-ключ и организацию.
GET/v1/meliveТекущий мерчант, его план и возможности.
GET/v1/clients/lookup?phone=liveУзнать имя Kaspi-клиента по номеру до выставления счёта. Лимит 30/мин на IP.
POST/v1/invoicesliveСоздать счёт по номеру — push в Kaspi-приложение клиента. Тело: phone_number, amount, description?, external_order_id?, is_sandbox?.
GET/v1/invoicesliveСписок с пагинацией и фильтрами: 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}/cancelliveОтменить неоплаченный счёт на номер. Для QR — 422 cancel_not_supported_for_qr.
POST/v1/invoices/status/checkliveПодтянуть свежие статусы из Kaspi. Тело: {"ids": [...]}. Webhook'и не шлёт.
POST/v1/qrliveСгенерировать QR (sandbox или реальный). Ответ в camelCase: qrToken, unifiedQr, deeplink, expiresAt.
POST/v1/payment-linksliveСоздать ссылку оплаты без номера клиента. Ответ в snake_case (см. сценарий «QR / ссылка»).
POST/v1/payments/linkliveАлиас payment-links для совместимости.
GET/v1/payments/{id}liveПлатёж по ID.
POST/v1/invoices/{id}/refundliveВозврат по счёту или QR. Тело: amount? (по умолчанию — вся сумма), reason?. Ответ: {refund, invoice, kaspi}.
GET/v1/refundsliveСписок возвратов.
POST/v1/refundsliveСоздать возврат. Тело: invoice_id (или paymentId), amount (обязательно), reason?, external_order_id?.
GET/v1/invoices/{id}/refundsliveВозвраты по конкретному счёту.
GET/v1/transactionsliveЛента всех операций (счета, возвраты).
GET/v1/kaspi/sessionliveСтатус подключения Kaspi: последний успех, ошибка.
POST/v1/sandbox/invoices/{id}/simulateliveСимулировать оплату/отмену/ошибку без Kaspi.
GET/v1/webhooksliveСписок webhook-адресов.
POST/v1/webhooksliveДобавить адрес. Тело: url, events[] (обязательно), name?. Ответ 201 с secret — показывается один раз. Приватные/небезопасные URL — 422.
DELETE/v1/webhooks/{id}liveУдалить webhook.
POST/v1/webhooks/{id}/testliveТестовая доставка invoice.status_changed только на этот адрес (см. «Webhook события»).
GET/v1/webhook-deliveriesliveИстория доставок.
POST/v1/webhook-deliveries/{id}/retryliveПереотправить тот же payload на тот же адрес (новая подпись, с повторами). Ответ 202.
GET/v1/audit-logsliveЛог действий в кабинете.
GET / POST/v1/subscriptionsbetaПодписки: создаются и хранятся, но автосписания пока не выполняются — счета по ним не выставляются. По умолчанию 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}/pausebetaПоставить на паузу.
POST/v1/subscriptions/{id}/resumebetaВозобновить.
POST/v1/subscriptions/{id}/cancelbetaОтменить.
GET/v1/subscriptions/{id}/invoicesbetaСчета подписки (пока автосписания не работают — пусто).
GET / POST/v1/catalogmvpКаталог товаров для интеграций. Хранится у нас, в Kaspi не синхронизируется.
POST/invoicescompatApiPay-совместимое создание счёта. Полный URL — https://pay.arzan.cloud/api/invoices.
POST/webhook/configurecompatApiPay-совместимая настройка webhook. Полный URL — https://pay.arzan.cloud/api/webhook/configure.
VII.

Webhook события.

Каждый webhook — POST с JSON-телом, в котором есть поле event с именем события. Подпись — в заголовке X-Webhook-Signature: sha256=<hex>, считается над <timestamp>.<raw_body>. Timestamp — в отдельном заголовке X-Webhook-Timestamp (unix-seconds), проверяйте свежесть ±5 минут. Раскройте любое событие, чтобы увидеть пример payload'а.

Рекомендуем: при создании webhook'а передавайте "events": ["invoice.status_changed", "invoice.refunded"] и не подписывайтесь на payment.*. Поле events в POST /v1/webhooks обязательно, а secret из ответа показывается один раз.

Два формата payload'а

Формат зависит от того, на какие события подписан адрес. На одно событие адрес получает ровно одну доставку.

  • Invoice-формат — если адрес подписан на 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, но доставка всё равно идёт в плоском формате
  • Нет id события. Дедуплицируйте по паре (event, invoice.id, invoice.status)
  • payment.canceled и события подписок не отправляются
invoice.status_changed Статус счёта, QR или ссылки изменился: paid / failed / canceled / expired +
{
  "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.

invoice.refunded Сделан возврат по счёту — полный или частичный +
{
  "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.

payment.success / failed / expired Устаревший плоский формат — для новых интеграций не используйте +
{
  "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_.

Политика повторов

  • Ваш сервер должен ответить 2xx в течение 10 секунд — это успех
  • До 3 попыток доставки: сразу, через ~5 секунд и через ~30 секунд
  • Повторяем при сетевой ошибке, таймауте, HTTP 5xx, 408 и 429
  • Остальные 4xx не повторяются — считаем, что ваш обработчик сознательно отклонил запрос
  • Повторы переживают перезапуск сервиса. Все доставки видны в GET /v1/webhook-deliveries; любую можно переотправить через POST /v1/webhook-deliveries/{id}/retry
  • Если webhook так и не дошёл — сверяйтесь через POST /v1/invoices/status/check
VIII.

Подпись webhook'а.

Все примеры — выше в сценарии «Проверка подписи». Главное:

  • Заголовки: X-Webhook-Signature: sha256=<hex> и X-Webhook-Timestamp: <unix-seconds>
  • Сначала проверяйте timestamp ±5 минут (anti-replay), потом подпись
  • Подпись над timestamp + "." + raw_body, не над одним body (Stripe-style — нельзя подменить timestamp не сломав подпись)
  • Используйте HMAC-SHA256 с вашим webhook secret
  • Считайте от raw body — до парсинга JSON
  • Сравнивайте через timing-safe функцию (crypto.timingSafeEqual, hmac.compare_digest, hash_equals). В Node.js сначала сравните длины буферов и при несовпадении сразу верните 401 — timingSafeEqual бросает исключение на буферах разной длины
  • Не пересобирайте тело через JSON.stringify(req.body) — порядок ключей и пробелы могут отличаться, подпись не сойдётся
IX.

Защита от повторов.

Если ваша система случайно отправит один и тот же запрос дважды (потерянное соединение, retry в очереди) — мы не создадим два счёта. Передавайте уникальный Idempotency-Key в заголовке.

  • Что делать — добавляйте Idempotency-Key на все create / refund операции
  • Если заголовка нет — ключом служит поле тела idempotencyKey, external_order_id или externalId (первое найденное)
  • Что вернётся — при повторе с тем же ключом мы вернём тот же ответ, что и в первый раз, с заголовком Idempotent-Replay: true
  • Тот же ключ, другое тело — 409 idempotency_key_conflict. Новая попытка с другими данными — новый ключ
  • Срок хранения — 24 часа (не более 2000 последних ключей на систему, старые вытесняются раньше). Не полагайтесь на ключ дольше суток
  • Дополнительно для счетов на номер — POST /v1/invoices бессрочно ловит повтор external_order_id и возвращает уже существующий счёт (с idempotent: true) вместо создания нового. Учтите: такой ответ приходит во внутреннем camelCase-формате, а не в обычной схеме счёта
  • Хороший ключ — UUID, либо order_${orderId} с гарантированной уникальностью
X.

Статусы сущностей.

Счета (invoices)

StatusЗначение
pendingОжидает оплаты
processingРедкий промежуточный статус: Kaspi вернул незнакомый промежуточный статус. Ждите финального
paidОплачен клиентом. Остаётся paid, пока возвращена только часть суммы
failedНе оплачен: клиент отказался или не подтвердил, Kaspi отклонил, не хватило средств, ошибка. Для QR — в том числе если клиент открыл ссылку и ушёл
canceledОтменён через API / кабинет или отменён на стороне Kaspi
expiredИстёк срок оплаты (для QR — не отсканирован за отведённое время)
refundedОдин возврат покрыл всю сумму

Финальные статусы: paid, failed, canceled, expired, refunded (переход paid → refunded возможен). Обратите внимание на написание: canceled с одной l.

Возвраты (refunds)

Возврат синхронный: Kaspi отвечает сразу, и если запрос вернул 201, у возврата статус refunded — деньги уже возвращены. Если Kaspi отказал, возврат не создаётся, а запрос возвращает ошибку (например kaspi_refund_failed). Промежуточных статусов у возврата нет.

Подписки (subscriptions) — beta

Бета: подписки создаются и хранятся, но автосписания пока не выполняются — счета по ним не выставляются и webhook'и по подпискам не отправляются. Не стройте на них продакшен-логику.
StatusЗначение
activeАктивна (после создания и после resume)
pausedПриостановлена через pause
canceledОтменена через cancel

Периоды списания подписок

PeriodЗначение
dailyКаждый день
weeklyКаждую неделю
biweeklyКаждые 2 недели
monthlyКаждый месяц
quarterlyКаждые 3 месяца
yearlyКаждый год
XI.

Готовые сценарии.

Самые частые интеграции — что куда подключать.

XI · 01

Tilda

Форма Tilda → ваш bridge-сервер → POST /v1/invoices. Статус приходит через webhook, который меняет состояние заказа.

XI · 02

WordPress / Woo

Payment gateway создаёт счёт на checkout. Webhook переводит order в processing или completed.

XI · 03

Telegram-бот

Бот спрашивает сумму и номер клиента, создаёт счёт. Webhook отправляет «✓ оплачено» прямо в чат.

XI · 04

Bitrix24 / AmoCRM

Робот сделки → bridge → счёт. ID счёта в пользовательском поле. Webhook двигает сделку по воронке.

XI · 05

CRM / POS

Используйте external_order_id, чтобы связать чек или заказ с счётом Arzan Pay.

XI · 06

Внутренние системы

OpenAPI спека и Postman-коллекция — для генерации клиентов на любом языке за пять минут.

XII.

Лимиты и коды ошибок.

Все ошибки возвращают JSON с машинным кодом error, человеческим message, необязательными details и timestamp. Ориентируйтесь на error, а не на текст message.

Формат ошибки

{
  "error": "invalid_amount",
  "message": "Сумма должна быть от 1 до 10 000 000 KZT.",
  "timestamp": "2026-05-24T14:10:00.000Z"
}

Лимиты и ограничения

  • Запросы — 120 в минуту на API-ключ и 240 в минуту на мерчанта (все ключи вместе)
  • Операции с Kaspi — 30 в минуту на мерчанта для боевых созданий счетов, QR, ссылок и возвратов
  • /v1/clients/lookup — 30 в минуту на IP
  • Заголовки — текущее состояние лимита в X-RateLimit-*. При превышении — 429 rate_limit_exceeded с полем retry_after (секунды) и заголовком Retry-After
  • Сумма — от 1 до 10 000 000 ₸
  • Телефон — 7XXXXXXXXXX или 8XXXXXXXXXX (11 цифр). Пробелы, скобки, дефисы и + отбрасываются, номер приводится к 7XXXXXXXXXX: +7 (701) 123-45-67 и 87011234567 станут 77011234567
  • Тариф — после окончания триала без оплаченного тарифа любой запрос (включая песочницу) получает 402 plan_required. Если превышена квота оплаченных платежей и прошла отсрочка — не-GET запросы получают 402 plan_upgrade_required. Квота считает только оплаченные платежи. Использование тарифа — в заголовках X-Plan-*

HTTP-статусы

HTTPКогдаЧто делать
400Bad RequestНекорректный запрос: неверный телефон или сумма при создании счёта/QR/ссылки (invalid_phone, invalid_amount), не хватает обязательных полей (validation_error), не передан Content-Type: application/json.
401UnauthorizedAPI-ключ отсутствует, неверен или отозван.
402Payment RequiredНет оплаченного тарифа (plan_required) или превышена квота (plan_upgrade_required). Оплатите или смените тариф в кабинете.
403ForbiddenАккаунт приостановлен (merchant_suspended) или запрос пришёл с IP вне allowlist ключа (ip_not_allowed).
404Not FoundРесурс не существует или принадлежит другому мерчанту.
409ConflictКонфликт состояния: Kaspi не подключён, счёт уже в финальном статусе, счёт не оплачен для возврата, ключ идемпотентности повторён с другим телом.
422UnprocessableЗапрос понятен, но не выполним: отмена QR, возврат больше остатка, неверная сумма возврата, пустой ids, небезопасный URL webhook'а и т.п.
429Rate limitПревышен лимит запросов. Подождите retry_after секунд (или Retry-After).
502Kaspi errorKaspi не принял запрос (коды kaspi_*_failed). Повторите позже; если повторяется — проверьте подключение Kaspi в кабинете.

Коды ошибок (поле error)

КодHTTPКогда и что делать
api_key_required401Ключ не передан. Добавьте X-API-Key или Authorization: Bearer.
invalid_api_key401Ключ неверен или отозван. Создайте новый в кабинете.
plan_required402Триал закончился, тариф не оплачен. Оплатите тариф в кабинете.
plan_upgrade_required402Квота оплаченных платежей исчерпана и отсрочка прошла. Повысьте тариф; GET-запросы продолжают работать.
merchant_suspended403Аккаунт приостановлен. Свяжитесь с поддержкой.
ip_not_allowed403IP не входит в allowlist ключа. Добавьте IP в настройках ключа.
invalid_phone400Неверный телефон. Передавайте 7XXXXXXXXXX или 8XXXXXXXXXX.
invalid_amount400 / 422Сумма вне диапазона 1 – 10 000 000 ₸ (400 при создании) или сумма возврата ≤ 0 / больше суммы платежа (422).
validation_error400 / 422Не хватает обязательных полей или они невалидны (например нет events у webhook'а, пустой ids). Смотрите message и details.
invoice_not_found, payment_not_found404Счёт/платёж не найден. Проверьте ID и принадлежность к вашему мерчанту.
webhook_not_found404Webhook не существует. Проверьте ID.
idempotency_key_conflict409Тот же Idempotency-Key пришёл с другим телом. Для новой операции — новый ключ.
kaspi_not_connected409Kaspi не подключён или сессия потеряна. Подключите в кабинете → Kaspi.
invoice_not_cancelable409Счёт уже в финальном статусе — отменить можно только pending или processing.
invoice_not_paid, payment_not_paid409Возврат возможен только по оплаченному (paid) платежу.
cancel_not_supported_for_qr422QR и ссылки нельзя отменить через API — они сами истекают через 5 минут.
refund_exceeds_remaining422Сумма возвратов превысила бы сумму оплаты. Уменьшите сумму.
rate_limit_exceeded429Превышен лимит. Подождите retry_after секунд.
kaspi_invoice_failed, kaspi_qr_failed, kaspi_refund_failed, kaspi_cancel_failed502Kaspi не выполнил операцию. Текст причины от Kaspi — в message. Повторите позже.