ApiPay.kz — плейбук интеграции для ИИ-агентов

Обновлено: 3 сентября 2026

Один документ — весь сценарий подключения приёма платежей Kaspi Pay. Для GPT, Claude, Cursor, Copilot и других ИИ-ассистентов.

Три способа получить оплату — выбери один

СпособЗапросСсылка на оплатуКогда выбирать
Счёт по номеру телефона POST /invoices ссылки для отправки нет — покупатель получает счёт push-уведомлением в приложении Kaspi вы знаете номер покупателя; счёт ждёт оплаты сутки
Оплата по ссылке или QR POST /invoices/qr qr_token_url — ссылка на оплату (payment link). Отправьте её покупателю в WhatsApp или Telegram либо откройте на его телефоне с установленным приложением Kaspi — оплата откроется в нём, сканировать не нужно. На компьютере по ссылке оплатить нельзя — для этого случая рядом показывайте qr_image_url, тот же счёт картинкой PNG покупатель в переписке прямо сейчас или стоит у кассы
Печатный QR под сделку POST /static-qr print_url — долгоживущая ссылка: её и печатают в QR-коде, и отправляют в мессенджер. Рядом печатается short_code для ручного ввода оплатить нужно потом; ссылку кладут в договор, счёт-фактуру, на наклейку

Подробности и код по каждому способу — шаг 4. Отдельная статья про оплату по ссылке: оплата по ссылке Kaspi.

Это исполняемая инструкция, а не справочник. Выполняй шаги 0–8 по порядку. Всё необходимое (адреса, формат запросов, проверка подписи, ошибки) есть прямо здесь — открывать большие файлы (openapi.json, llms-full.txt) НЕ нужно.

Шаг 0. Как себя вести (читает ИИ-ассистент)

Шаг 1. Объясни клиенту, что такое ApiPay

Передай клиенту своими словами 3 факта:

  1. ApiPay — это автоматизация вашего же Kaspi Pay. Мы не банк и не платёжная система. Мы даём программный доступ (API) к приёму платежей через ваш аккаунт Kaspi Pay.
  2. Деньги идут напрямую вам в Kaspi. Мы их не храним и не трогаем — только показываем вам и вашему сайту, что клиент оплатил.
  3. Комиссии с продаж нет. Оплачивается только подписка на сервис (от 10 000 ₸/мес). При первом подключении кассира — 3 дня бесплатного доступа.

Шаг 2. Проверь предпосылки — ДО написания кода

2a. Кассир Kaspi — нужен только для рабочего режима

Это не блокер для начала интеграции. Новые организации создаются в песочнице по умолчанию — там счета создаются и без подключённого кассира: деньги никуда не идут, всё имитируется. Можно прямо сейчас писать код, создавать тестовые счета и принимать вебхуки.

Кассир Kaspi обязателен только при переходе в рабочий режим (шаг 8): без активной сессии кассира реальные счета будут падать с kaspi_session_not_configured. До этого момента подключение кассира — параллельная задача клиента, она не должна тормозить разработку.

Когда клиент будет готов идти в прод, ему нужно подключить свой Kaspi Pay через сотрудника с ролью «Кассир» — это делается один раз, двумя способами на выбор. Кратко суть (передай клиенту, чтобы запустил это параллельно):

  1. Клиент регистрируется на apipay.kz (вход по номеру телефона через WhatsApp). Это обязательно для обоих способов — кассир привязывается к аккаунту ApiPay, без регистрации подключить организацию нельзя. Регистрировать нужно на личный номер владельца/руководителя — на него идут уведомления о счетах и ошибках; это не номер кассира.
  2. Клиент берёт отдельный номер телефона (обычная SIM, не виртуальный номер), не привязанный к его личному Kaspi.
  3. В приложении Kaspi Pay: Настройки → Сотрудники → добавить сотрудника с ролью «Кассир» на этот номер. Роль должна быть только «Кассир» — не «Управляющий», не «Бухгалтер», не «Менеджер». После добавления Kaspi пришлёт на номер кассира ссылку на скачивание приложения Kaspi Pay — устанавливать ничего не нужно, это сообщение лишь означает, что сотрудник-кассир создан и готов.
  4. Способ 1 (основной) — мастер подключения в кабинете. Настройки → вкладка «Авторизация Kaspi» → кнопка подключения, она открывает мастер /settings/connect-cashier. Мастер ведёт по шагам: что такое кассир → три проверки перед привязкой → номер и роль кассира → предупреждение «не заходите в приложение Kaspi Pay под этим номером» → код из SMS → готово. Занимает 2–3 минуты. Сама вкладка «Авторизация Kaspi» — витрина статуса: показывает, подключён ли кассир, когда была последняя активность, и даёт кнопки «Подключить заново» и «Сменить кассира».
  5. Способ 2 (запасной) — поддержка. Если мастер не проходит, клиент пишет в WhatsApp +7 700 307 65 12 номер кассира и название организации.
  6. Подробная инструкция по обоим способам: apipay.kz/connect-cashier.

Кассирами можно управлять и программно, без кабинета: GET/POST /connections и авторизация кассира POST /connections/{id}/auth/initauth/send-phoneauth/verify-otp, отключение — auth/logout. Ручки требуют ключа, которому разрешено управление кассирами. Подробности — в llms-full.txt; для обычной интеграции это не нужно, хватит мастера в кабинете.

Не жди подключения кассира. Иди по шагам 3–7 в песочнице — там всё работает без кассы. Параллельно дай клиенту ссылку /connect-cashier — она пригодится на шаге 8, когда будете переходить в рабочий режим.

2b. Где будет жить интеграция — нужен сервер

Определи тип проекта клиента:

Важно: X-API-Key — это секрет. Он используется ТОЛЬКО на сервере. Никогда не помещай ключ в код, который выполняется в браузере клиента (frontend) — его увидят все посетители сайта.

2c. Анкета о бизнесе и лимит на старте — предупреди клиента заранее

У новых организаций есть мягкий лимит на старте: пока не заполнена и не одобрена короткая анкета «Расскажите о вашем бизнесе», в рабочем режиме можно создать 1 реальный счёт в сутки. В песочнице лимита нет — тестируй сколько нужно, разработку это не тормозит.

Зачем это нужно (объясни клиенту причину): это разовая проверка, по итогам которой лимиты на приём платежей настраиваются под обороты клиента.

Что сделать: клиент заполняет анкету в кабинете на /business-profile (~5 минут: что продаёт, где продаёт, средний чек). Одобрение обычно за 1 рабочий день — после него лимит снимается автоматически. При попытке создать второй счёт за сутки API вернёт 429 kyc_daily_limit_reachedmeta.reset_at — когда лимит сбросится). Это не блокер разработки: продолжай в песочнице.

Шаг 3. Получи доступы в личном кабинете

Это делает человек в кабинете на apipay.kz. Нужно войти под ролью Владелец или Разработчик — у роли «Менеджер» доступа к ключам нет. Точный путь по меню:

Мастер создания ключа — 4 шага

  1. Настройки → вкладка «Подключение» → кнопка «Создать новый ключ». Открывается мастер.
  2. Шаг 1. Назовите ключ — понятное имя: «Мой сайт», «Telegram-бот», «Программа магазина».
  3. Шаг 2. Куда сообщать об оплате — поле «Адрес для уведомлений»: публичный адрес обработчика на сайте клиента, например https://ваш-сайт.kz/webhooks/apipay. Это и есть «webhook URL». Шаг можно пропустить и вписать адрес позже.
  4. Шаг 3. Защитим уведомления — секретный ключ подписи (webhook secret) выпускается автоматически, искать и придумывать ничего не нужно.
  5. Шаг 4. Готово! Сохраните данные — API-ключ и секрет показываются один раз. На этом экране есть кнопка «Скопировать всё для ИИ-помощника»: она кладёт в буфер обмена сразу API-ключ, адрес для уведомлений и секрет вместе с пояснением, что с ними делать.
СТОП. Попроси клиента на последнем экране мастера нажать «Скопировать всё для ИИ-помощника» и вставить скопированное тебе в чат — одной кнопкой, ничего не переписывая руками. Там сразу API-ключ, адрес для уведомлений и секретный ключ подписи. Напомни: адрес уведомлений и секрет настраиваются на каждый ключ отдельно — отдельной общей страницы «вебхуки» нет.
Если клиент только начинает и ключа ещё нет, на вкладке «Подключение» есть карточка «Копировать инструкцию для ИИ» — она даёт тебе готовое описание задачи. Попроси клиента нажать её и прислать текст.

Если ключ уже создан раньше

Сохрани значения в переменные окружения сервера (никогда — в репозиторий):

APIPAY_API_KEY=...
APIPAY_WEBHOOK_SECRET=...
APIPAY_BASE_URL=https://api.apipay.kz/api/v1

Шаг 4. Встрой получение оплаты — три способа

ПараметрЗначение
Базовый адрес APIhttps://api.apipay.kz/api/v1
Авторизациязаголовок X-API-Key: ваш_ключ (только на сервере)
Content-Typeapplication/json
Базовый адрес API всегда https://api.apipay.kz/api/v1 — он не зависит от того, на каком сайте ты читаешь эту инструкцию. Не используй адрес документации (например localhost или apipay.kz) как адрес API.

Какой способ выбрать

СпособЗапросЧто возвращаетСрок жизниОтмена
Счёт по номеру телефона POST /invoices 201, статус processing. Покупатель получает push в приложении Kaspi. Ссылки для отправки нет 24 часа есть — POST /invoices/{id}/cancel
Оплата по ссылке или QR POST /invoices/qr 201, статус pending сразу. qr_token_url — ссылка на оплату: отправьте её покупателю в WhatsApp или Telegram, либо откройте на его телефоне с приложением Kaspi — оплата откроется в нём, сканировать не нужно. На компьютере по ссылке не оплатить — показывайте рядом qr_image_url, PNG того же счёта минуты; точный момент — поле qr_expires_at в ответе. Не зашивай длительность окна константой нет — 409 qr_cancel_unsupported
Печатный QR под сделку POST /static-qr print_url — долгоживущая ссылка (её и печатают в QR-коде, и отправляют в мессенджер), short_code для ручного ввода, qr_image_url — готовый PNG для печати. Счёт создаётся в момент скана пока лист не оплачен, не отключён и не истёк заданный expires_at отключение листа — DELETE /static-qr/{id}

Сценарий клиента → способ

Что говорит клиентЧто делаем
«Покупатель пишет мне в WhatsApp/Instagram прямо сейчас» POST /invoices/qr → отправить qr_token_url в тот же чат
«У меня интернет-магазин, покупатель оставляет номер телефона» POST /invoices — счёт придёт ему push-уведомлением в Kaspi
«Клиент оплатит потом» / «нужно напечатать на договоре, счёте, наклейке» POST /static-qr → напечатать QR с print_url или отправить эту ссылку в мессенджер
«Покупатель стоит у кассы в зале» POST /invoices/qr → показать qr_image_url на экране

Лимиты запросов

ЧтоЛимит
Общий на ключ200 запросов/мин
GET /invoices/{id}1000/мин (опрос статуса без вебхуков)
POST /invoices/bulk20/мин
POST /invoices/qr60/мин на организацию
POST /clients/check60/мин и 10 000/сутки на ключ
POST /catalog/scan30/мин и 2000/сутки
POST /catalog/upload-image60/мин и 2000/сутки
POST /catalog/bulk-delete10/мин
Касса /cashbox/*30/мин
POST /invoices/{id}/simulate-status60/мин (отдельный счётчик, общий лимит не расходует)
Авторизация кассираauth/init и auth/send-phone — 5/мин, auth/verify-otp — 10/мин

Каждый ответ несёт X-RateLimit-Limit и X-RateLimit-Remaining. При превышении приходит 429 с заголовком Retry-After и полем retry_after_seconds в теле — жди указанное число секунд, а не повторяй сразу. Заголовки показывают тот счётчик, где осталось меньше всего, поэтому сравнивай Remaining с Limit из того же ответа, а не с числом из таблицы.

Способ 1. Счёт по номеру телефона — POST /invoices

// выполняется на сервере
const res = 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: '87001234567',     // обязательно. Формат строго 8XXXXXXXXXX (11 цифр)
    amount: 15000,                   // обязательно. Сумма в тенге, только целая
    description: 'Заказ №123',       // необязательно, до 60 символов
    external_order_id: 'order_123'   // необязательно — ваш ID заказа для сверки
  })
})
const invoice = await res.json()
// → { id: 42, amount: "15000.00", status: "processing", paid_at: null,
//     phone: "87001234567", is_imported: false, created_at: "2026-09-03T12:00:00+00:00" }
// amount — СТРОКА, не число. paid_at заполнится после оплаты.
// is_imported: true — продажа подтянута из истории Kaspi (проведена в приложении мимо ApiPay).

Клиент получит уведомление в приложении Kaspi и оплатит там. Сохрани invoice.id рядом со своим заказом. Часть полей ответа условная — они появляются, только когда есть значение (subtotal и discount_sum при скидке или корзине, error_message при статусе error).

Ссылка у счёта по номеру. У такого счёта нет ссылки, которую можно отправить покупателю как приглашение к оплате — он приходит push-уведомлением. В ответах GET /invoices/{id} есть поле kaspi_qr_link: это ссылка на оплату именно этого счёта, из неё можно нарисовать QR-код. Она null, пока Kaspi не присвоил счёту свой идентификатор (то есть в статусе processing), и всегда null в песочнице — на неё нельзя рассчитывать как на основной канал доставки. Нужна ссылка сразу и наверняка — бери POST /invoices/qr.

Способ 2. Оплата по ссылке или QR — POST /invoices/qr

// выполняется на сервере. Телефон покупателя НЕ нужен.
const res = await fetch('https://api.apipay.kz/api/v1/invoices/qr', {
  method: 'POST',
  headers: {
    'X-API-Key': process.env.APIPAY_API_KEY,
    'Content-Type': 'application/json'
  },
  body: JSON.stringify({
    amount: 15000,                   // сумма; тиыны здесь принимаются
    description: 'Заказ №123',       // необязательно, до 100 символов
    external_order_id: 'order_123'
  })
})
const qr = await res.json()
// → { id: 77, amount: "15000.00", status: "pending", phone: null, is_qr_token: true,
//     qr_token_url: "https://qr.kaspi.kz/...",   // ССЫЛКА НА ОПЛАТУ — отправьте покупателю
//     qr_image_url: "https://.../qr.png",         // тот же счёт картинкой, для экрана кассы
//     qr_expires_at: "2026-09-03T12:03:00+00:00" }

// Отправляем ссылку покупателю в мессенджер — сканировать ничего не нужно:
// на телефоне с приложением Kaspi по ссылке откроется готовый счёт.
await sendToCustomerChat(`Оплатите заказ №123 по ссылке: ${qr.qr_token_url}`)

Способ 3. Печатный QR под сделку — POST /static-qr

const res = await fetch('https://api.apipay.kz/api/v1/static-qr', {
  method: 'POST',
  headers: { 'X-API-Key': process.env.APIPAY_API_KEY, 'Content-Type': 'application/json' },
  body: JSON.stringify({
    amount: 15000,                  // целая сумма: лист умеет и оплату по номеру телефона
    description: 'Договор №14',     // до 100 символов
    external_order_id: 'deal_14',
    single_use: true                // одна сделка: после оплаты скан покажет «Оплачено»
  })
})
const sheet = await res.json()
// → { id, token, short_code: "K7M9P2Q4", print_url: "https://qr.apipay.kz/...",
//     manual_url, qr_image_url, status: "active", paid: false }
// print_url — долгоживущая ссылка: печатайте её QR-кодом ИЛИ отправляйте в мессенджер.
Описание счёта — до 60 символов. Kaspi показывает покупателю только первые 60 символов описания и молча отбрасывает остальное. Поэтому с 5 сентября 2026 описание длиннее 60 символов отклоняется с 422 description_too_long; организации, зарегистрированные с 26 августа 2026, живут на этом лимите уже сейчас. Пиши описания коротко с самого начала. У POST /invoices/qr и POST /static-qr поле description — это наименование позиции в чеке, там предел Kaspi 100 символов, но лист, который должен принимать оплату и по номеру телефона, обязан укладываться в те же 60.

Проверить статус — GET /invoices/{id}

Жизненный цикл статуса: processingpendingpaid (или cancelled / expired / error). После частичного возврата оплаченный счёт становится partially_refunded, после полного — refunded. Счёт из POST /invoices/qr появляется сразу в pending, минуя processing. Массовая проверка нескольких счетов сразу — POST /invoices/status/check с телом {"invoice_ids":[1,2,3]}.

Статусы ходят не только вперёд. Последовательности cancelled → paid и expired → paid законны: покупатель успел оплатить, пока счёт закрывался. Бывает и error → pending. Поэтому не закрывай заказ навсегда по cancelled или expired — оставляй возможность принять последующий paid.

Шаг 4.5. Залей каталог — если в чеке нужны позиции (опционально)

Этот шаг нужен, только если клиент хочет видеть в чеке Kaspi позиции (название, цена, количество), а не одну сумму. Если продаёте «на сумму» — пропусти шаг и оставайся на обычном POST /invoices.

Что делаемЭндпоинт
Найти НТИН/GTIN по штрихкоду (маркированные товары)POST /catalog/scan — 30/мин + 2000/сутки
Залить товары пачкойPOST /catalog — от 1 до 100 позиций за запрос
Подтвердить, что доехали до KaspiGET /catalog?external_refs[]= или вебхук
Маппь товары по external_ref, не по штрихкоду. Kaspi держит один товар на штрихкод, а в учётной системе под одним штрихкодом бывает несколько позиций — сверка по штрихкоду перепутает id. external_ref (код номенклатуры из системы клиента) уникален в пределах организации и однозначен.

1. Скан штрихкода — POST /catalog/scan (только для маркированных)

// выполняется на сервере
const scan = await fetch('https://api.apipay.kz/api/v1/catalog/scan', {
  method: 'POST',
  headers: { 'X-API-Key': process.env.APIPAY_API_KEY, 'Content-Type': 'application/json' },
  body: JSON.stringify({ input: '4870000000001' })  // штрихкод
})
const { data } = await scan.json()
// data[] — кандидаты { id, name, ntin, gtin, barcode, unit_id }. Пустой data[] = товар
// не из Нацкаталога — это НЕ ошибка, заводи без ntin/gtin.

2. Залить товары — POST /catalog (от 1 до 100 позиций)

const res = await fetch('https://api.apipay.kz/api/v1/catalog', {
  method: 'POST',
  headers: { 'X-API-Key': process.env.APIPAY_API_KEY, 'Content-Type': 'application/json' },
  body: JSON.stringify({
    items: [
      { name: 'Ручка гелевая синяя 0.5', selling_price: 350, unit_id: 1,
        barcode: '4870000000001', external_ref: '1c-000123' },  // external_ref = ключ маппинга
      { name: 'Тетрадь 48 листов клетка', selling_price: 420, unit_id: 1,
        barcode: '4870000000002', external_ref: '1c-000124' }
    ]
  })
})
// Ответ ВСЕГДА 202 — и в песочнице, и в рабочем режиме.
// { data: [...], rejected: [...] } — ключ rejected есть всегда, может быть пустым.
Ветвись по полю outcome, а не по matched_existing. matched_existing: true говорит только «мы нашли вашу строку» и НЕ говорит, что по ней открылась работа. Что именно произошло, читай в outcome: created — заведена новая строка; matched — сматчилась существующая, правка применена или поставлена в очередь; reissued — переиздана снятая позиция; unchanged — строка уже совпадает с каталогом Kaspi, делать нечего; revived — брошенное создание открыто заново; not_started — брошенное создание найдено, но работа НЕ открыта (дословный повтор того же тела ничего не чинит — меняй данные позиции либо дожимай через PATCH /catalog/{id}).
Одна битая позиция не роняет весь запрос. Каждая позиция проверяется отдельно: валидные уезжают в data[], невалидные — в rejected[]. 422 остаётся только за ошибками самого запроса: items отсутствует, не массив, пуст или длиннее 100. Повторная заливка идемпотентна — дубли не создаются, синк можно гонять по расписанию.

3. Подтвердить результат

Ответ 202 — «принято», не «готово». В песочнице позиция возвращается уже активной, в рабочем режиме получает pending и уезжает в Kaspi фоном. Финальный статус (active/failed) узнаётся одним из двух равноправных путей:

// Путь A (просто, для 1С/on-prem): поллинг по external_ref
const check = await fetch('https://api.apipay.kz/api/v1/catalog?' +
  'external_refs[]=1c-000123&external_refs[]=1c-000124',
  { headers: { 'X-API-Key': process.env.APIPAY_API_KEY } })
const items = (await check.json()).data
// status: 'active' — товар в Kaspi; 'failed' — смотри error_code.

// Путь B (SaaS с публичным URL): вебхук catalog.item_processed приходит на твой webhook_url
// с полями { id, external_ref, kaspi_item_id, status, operation, error_code, ntin_missing }.
Точечная сверка — до 200 значений суммарно. Больше 200 в external_refs[]/barcodes[]/ntins[]/ids[]422 catalog_match_overflow. Разбивай сверку на батчи по ~100.

Продажа с каталогом: в POST /invoices, POST /invoices/qr и POST /static-qr вместо amount передавай cart_items: [{ catalog_item_id, count }] — где catalog_item_id это id товара из каталога. У организации с каталогом POST /invoices/qr и POST /static-qr без cart_items отвечают 422 catalog_requires_cart_items.

СТОП. Скан штрихкода (POST /catalog/scan) ходит на живую сессию кассира Kaspi и в рабочем режиме требует подключённого кассира. Заведение каталога без маркировки (без ntin/gtin) тестируется в песочнице — там лимит 1000 позиций.

Шаг 5. Принимай вебхук об оплате

Когда счёт оплачен, ApiPay сам отправляет POST на твой адрес вебхука. Тело события:

{
  "event": "invoice.status_changed",
  "invoice": {
    "id": 42,
    "external_order_id": "order_123",
    "amount": "15000.00",
    "status": "paid",
    "paid_at": "2026-09-03T08:35:00Z"
  },
  "source": "имя вашего ключа",
  "timestamp": "2026-09-03T08:35:01Z"
}

Все события — что приходит и что нужно типовой интеграции

СобытиеКогда приходитНужно типовой интеграции
invoice.status_changed счёт перешёл в pending, paid, cancelled, expired, error или partially_refunded Да — это главное событие
invoice.qr_scanned покупатель открыл счёт по ссылке или отсканировал QR; статус остаётся pending Нет. Полезно для экрана кассы («клиент открыл оплату»). Это НЕ оплата
invoice.refunded возврат обработан: успех или неудача Да, если делаете возвраты
webhook.test кнопка «Проверить уведомления» в кабинете Да — им проверяют, что обработчик жив
catalog.item_processed позиция каталога обработана (по каждой строке заливки) Да, если заливаете каталог
subscription.createdподписка созданаНет
subscription.payment_succeededсчёт подписки оплачен Да, если есть регулярные платежи
subscription.payment_failedсчёт подписки не оплачен Да, если есть регулярные платежи
subscription.grace_period_startedпопытки списания исчерпаны, начался льготный периодНет
subscription.expiredльготный период истёк либо выбраны все оплаты Да, если есть регулярные платежи — здесь закрывают доступ
subscription.pausedподписка поставлена на паузуНет
subscription.resumedподписка возобновленаНет
subscription.cancelledподписка отменена либо плательщик явно отказался Да, если есть регулярные платежи
receipt.issuedфискальный чек выбитНет — только если выбиваете чеки
receipt.failedчек выбить не удалосьНет — только если выбиваете чеки
qr_refund.identifiedклиент отсканировал возвратный QRНет
qr_refund.completedвозврат по QR выполненДа, если делаете возвраты по QR
qr_refund.expiredвозвратный QR истёк до идентификацииНет
qr_refund.execution_uncertain исход возврата не подтверждён — повторять нельзя, нужен ручной разбор Да, если делаете возвраты по QR
qr_refund.failedподтверждение возврата по ссылке не началось — нужна новая ссылкаНет
cashbox.shift_closedкассовая смена закрытаДа, если закрываете смены программно (шаг 9)
cashbox.shift_close_failedсмену закрыть не удалосьДа, если закрываете смены программно (шаг 9)

Технические статусы processing и cancelling вебхуков не порождают никогда — не жди их.

Проверка подписи — обязательно

Каждый вебхук содержит заголовок X-Webhook-Signature: sha256=<hex> — это HMAC-SHA256 от сырого тела запроса с секретом вебхука.

const crypto = require('crypto')

// rawBody — НЕОБРАБОТАННОЕ тело запроса (Buffer/строка), НЕ результат JSON.parse.
// В Express: app.post('/webhooks/apipay', express.raw({ type: 'application/json' }), ...)
function verifyWebhook(rawBody, signature, secret) {
  const expected = 'sha256=' + crypto
    .createHmac('sha256', secret)
    .update(rawBody)
    .digest('hex')
  const got = Buffer.from(signature || '')
  const exp = Buffer.from(expected)
  if (got.length !== exp.length) return false
  return crypto.timingSafeEqual(exp, got)
}
Две частые ошибки: (1) подпись считают по уже разобранному JSON — нужно именно сырое тело; (2) обработчик отвечает дольше 5 секунд. Отвечай 2xx сразу, тяжёлую работу делай в фоне.

Повторные доставки и идемпотентность

Если твой сервер не ответил 2xx за 5 секунд (плюс до 3 секунд на соединение), ApiPay повторит доставку. Всего до 11 попыток (первая + 10 повторов) с нарастающей задержкой: 10с, 30с, 1м, 1.5м, 2м, 5м, 10м, 15м, 30м, 1ч — около 2 часов. 5xx/429/таймаут → повтор; прочие 4xx → без повтора.

Дедуплицируй по ПАРЕ значений, а не по одному id. Одно и то же событие может прийти несколько раз, и по одному счёту законно приходит несколько разных событий. Ключ идемпотентности: Если дедуплицировать только по invoice.id, ты потеряешь переход paid → partially_refunded — возврат просто не дойдёт до твоей системы.
Не закрывай заказ навсегда по cancelled. Последовательности cancelled → paid и expired → paid законны и приходят: покупатель успел заплатить, пока счёт закрывался. Обрабатывай paid после отмены как нормальную оплату, а не как ошибку.

Если сервера нет (шаг 2b) — вместо вебхука опрашивай статус: периодически вызывай GET /invoices/{id}, пока не будет paid. Опрос полезен и при наличии вебхука — как сверка, если все попытки доставки не прошли.

Шаг 6. Протестируй интеграцию

Тестируй в тестовом режиме (песочнице) — счета не уходят в реальный Kaspi, деньги не двигаются. Новая организация в песочнице по умолчанию.

  1. Проверка вебхука. Попроси клиента в кабинете (Настройки → «Подключение», карточка ключа) нажать кнопку «Проверить уведомления» — ApiPay пошлёт на адрес тестовое событие webhook.test. Убедись, что твой обработчик его принял и подпись сошлась.
  2. Проверка оплаты. Создай тестовый счёт через POST /invoices. В песочнице оплату счёта имитируют из кабинета: страница «Счета», у тестового счёта кнопка «Симулировать». Попроси клиента это сделать; придёт вебхук invoice.status_changed со статусом paid.
  3. Убрать тестовые данные. Кнопка «Очистить тестовые данные» есть на каждой странице отдельно — «Счета», «Каталог», «Подписки». Общей кнопки «очистить песочницу» в настройках нет. Рядом есть «Заполнить тестовыми данными».
  4. Локальная разработка. Если сервер пока на localhost, ApiPay до него не достучится — подними туннель (ngrok). Инструкция: apipay.kz/local-testing. Туннель годится только для теста в песочнице: рабочий вебхук должен быть на реальном домене (см. шаг 8).

Полностью автономный тест через API (без человека)

Если у тебя есть sandbox-ключ (X-API-Key тестовой организации, is_sandbox: true), весь цикл можно пройти программно — не дёргая человека в кабинете. Два инструмента песочницы:

# Полный автономный цикл (sandbox X-API-Key). Требуется jq.
KEY="YOUR_SANDBOX_API_KEY"; BASE="https://api.apipay.kz/api/v1"

# 1. Создать sandbox-счёт (external_order_id_idempotency защищает от дублей при ретрае)
ID=$(curl -s -X POST "$BASE/invoices" -H "X-API-Key: $KEY" -H "Content-Type: application/json" \
  -d '{"phone_number":"87770000001","amount":5000,"description":"Autotest","external_order_id_idempotency":"autotest-001"}' | jq -r '.id')

# 1a. Дождаться статуса pending — счёт создаётся в processing, переход асинхронный
#     (обычно 1-3 секунды); simulate-status требует pending. Поллинг раз в 1-2 сек.
#     (Счёт из POST /invoices/qr сразу pending — для него шаг не нужен.)
until [ "$(curl -s "$BASE/invoices/$ID" -H "X-API-Key: $KEY" | jq -r '.status')" = "pending" ]; do sleep 1; done

# 2. Симулировать оплату
curl -s -X POST "$BASE/invoices/$ID/simulate-status" -H "X-API-Key: $KEY" \
  -H "Content-Type: application/json" -d '{"status":"paid"}'

# 3. Проверить доставку вебхука invoice.status_changed (status: paid)
#    Если ответ пуст — проверьте по ?invoice_id=$ID без event; тип события есть в request_body.
curl -s "$BASE/webhook-logs?invoice_id=$ID&event=invoice.status_changed" \
  -H "X-API-Key: $KEY" | jq '.data[] | {event, status, response_status}'

# 4. Частичный возврат (paid → partially_refunded, в песочнице без Kaspi)
curl -s -X POST "$BASE/invoices/$ID/refund" -H "X-API-Key: $KEY" \
  -H "Content-Type: application/json" -d '{"amount":2000}'

# 5. Проверить вебхуки возврата: invoice.refunded + invoice.status_changed (partially_refunded)
curl -s "$BASE/webhook-logs?invoice_id=$ID" -H "X-API-Key: $KEY" | jq '.data[] | {event, status}'

# Другие ветки (каждая — новый sandbox-счёт, дождаться pending как в шаге 1a):
#   {"status":"cancelled"}  → invoice.status_changed (cancelled)
#   {"status":"expired"}    → invoice.status_changed (expired)
#   {"status":"error","error_message":"Тест"} → error_code: sandbox_simulated_error
# Оплата по ссылке или QR: POST /invoices/qr с телом {"amount":5000,"description":"QR autotest"}
#   (phone_number не нужен; счёт сразу pending, в ответе qr_token_url) + {"status":"qr_scanned"} →
#   invoice.qr_scanned (qr_substate: scanned, статус остаётся pending;
#   повторный qr_scanned → 400 already_scanned)

Sandbox-вебхуки по счетам доставляются с 3 попытками (задержки 5с/15с; в рабочем режиме — до 11 попыток ~2ч), успех — любой HTTP 2xx. Критерий успеха теста: по каждому шагу в /webhook-logs есть ожидаемое событие со status: success. Готовый копипаст-промпт для тест-агента — на apipay.kz/prompts.

СТОП. Действия в кабинете («Проверить уведомления», кнопка «Симулировать») выполняет человек. Дай ему точные указания и дождись результата, затем проверь, что событие дошло до кода.

Шаг 7. Отладка

Счёт не создаётся — таблица ошибок POST /invoices

HTTP / поле errorПричинаЧто сделать
422, ошибка в phone_numberТелефон не в формате 8XXXXXXXXXX Ровно 8 и 10 цифр, без +7, пробелов и скобок
422 description_too_long Описание длиннее 60 символов (с 05.09.2026 для всех; для организаций, зарегистрированных с 26.08.2026, — уже сейчас) Укоротить описание до 60 символов. Kaspi всё равно показывает покупателю только первые 60
422 amount_must_be_whole_tengeСумма с тиынами в счёте по номеру телефона Округлить до целых тенге либо использовать POST /invoices/qr — там тиыны принимаются
401Неверный, отсутствующий или истёкший X-API-Key Проверь ключ и заголовок (шаг 3). Здесь помогает перевыпуск ключа
403 organization_archived Организация этого ключа отправлена в архив. Ключ при этом активен Перевыпуск ключа НЕ поможет — доступ возвращает владелец аккаунта в кабинете. Не путай с 401
403 tariff_inactive Подписка на ApiPay не активна. Приходит на любой платной операции; чтение (GET) продолжает работать Клиенту оплатить тариф в кабинете. Льготного периода нет — блокировка наступает сразу после expires_at (он приходит в теле вместе с reason)
400, error: Organization not found or not verified Организация не подключена или не верифицирована (рабочий режим) Вернись к шагу 2 — подключить кассира и дождаться верификации; пока тестируй в песочнице
400 kaspi_session_not_configured Сессия Kaspi не настроена (только рабочий режим — в песочнице эта ошибка не возникает) Клиенту подключить кассира мастером: Настройки → «Авторизация Kaspi» → кнопка подключения, либо поддержка WhatsApp. Подробно — шаг 2a и /connect-cashier
409 kaspi_session_expired Сессия кассира Kaspi мертва, счёт не создан (только рабочий режим) Повтор запроса не поможет. Клиенту переподключить кассира по SMS — тот же мастер
503 kaspi_session_invalid Сессия Kaspi истекла или неисправна (только рабочий режим) Переподключить кассу по SMS (см. ниже)
422 connection_ambiguousУ организации несколько касс, основная не выбрана Передать в запросе kaspi_connection_id нужной кассы
422 catalog_requires_cart_items У организации включён каталог, а счёт пришёл одной суммой (POST /invoices/qr, POST /static-qr) Взять catalog_item_id в GET /catalog и передать cart_items (шаг 4.5)
400 sandbox_invoice_limitЛимит тестовых счетов исчерпан (1000) Клиенту нажать «Очистить тестовые данные» на странице «Счета»
429Превышен лимит запросов (таблица лимитов — в шаге 4) Подожди столько секунд, сколько указано в заголовке Retry-After и поле retry_after_seconds, затем повтори
429 trial_daily_limit Пробный тариф: до 50 реальных счетов в сутки через API Клиенту оплатить тариф — ограничение снимается. До этого лимит сбрасывается на следующие сутки; ждать столько, сколько в Retry-After
429 kyc_daily_limit_reached Молодая орг: до одобрения анкеты о бизнесе — 1 реальный счёт/сутки (песочница без лимита) Клиенту заполнить анкету на /business-profile (~5 мин), лимит снимется после одобрения. В meta.reset_at — когда сбросится. См. шаг 2c
429 tariff_limit_reached Исчерпан дневной лимит счетов оплаченного тарифа (Старт до 30, Бизнес до 100, Про до 300, Про Макс до 600). Считаются только счета через API; кабинетные и песочные не входят. Разовое превышение не блокирует — отказ приходит при систематическом превышении либо при исчерпанном бюджете помесячного подсчёта Не повторяй раньше meta.reset_at (Retry-After — секунды до сброса). meta.mode: daily — сутки, monthly — блок 30 дней. Снимается переходом на тариф выше сразу после оплаты. Расход — в GET /users/medaily_usage
403 kyc_rejected Приём платежей закрыт по итогам проверки бизнеса (статус орг blocked) Не повторяемая. Клиенту написать в поддержку WhatsApp, если считает это ошибкой
422 webhook_url_requires_domain / webhook_url_tunnel_forbidden Рабочий вебхук для ещё не одобренной орг: указан IP или туннель (ngrok и подобные) Указать адрес на реальном домене (публичный HTTPS). Туннель — только для теста в песочнице (шаг 6)

Полный каталог кодов ошибок с пометкой, какие имеет смысл повторять, — apipay.kz/errors.md (человеку удобнее apipay.kz/errors).

Счёт создался (201), но статус стал error

Создание счёта по номеру телефона асинхронное: POST /invoices возвращает 201 со статусом processing, дальше счёт уходит в Kaspi в фоне. Если что-то пошло не так на стороне Kaspi — это не ошибка HTTP, а статус error у счёта. Проверяй через GET /invoices/{id}:

Например client_not_found — номер не зарегистрирован в Kaspi (попроси клиента дать номер с установленным приложением Kaspi, повтор не поможет); network_unavailable — Kaspi временно недоступен (повтори создание счёта позже). Полный список слагов и их retryable-разметку смотри в errors.md.

Сессия кассира Kaspi (kaspi_session_invalid, kaspi_session_expired)

Сессия кассира рассчитана на долгую работу — ежедневно или по расписанию переподключать кассу не нужно. Прерваться она может, например, если кто-то вошёл в Kaspi или Kaspi Pay под номером кассира либо Kaspi сбросил сессию на своей стороне. Ретраи запроса не помогут: владелец организации один раз переподключает кассу по SMS — кабинет, Настройки → «Авторизация Kaspi» → кнопка подключения, либо через поддержку. Инструкция: /connect-cashier. Программно состояние сессии видно в GET /account/healthconnection.needs_reauth — так «сессия слетела» ловится опросом, без вебхука.

Оплата прошла, но подтверждение не пришло

Открой в кабинете Настройки → «Лог уведомлений» — там видно каждую отправку вебхука: адрес, HTTP-код ответа твоего сервера, отправленное тело и полученный ответ. Это главный инструмент диагностики:

Самая частая причина «вебхуки перестали приходить» — уведомления на паузе. Если обработчик подряд не принимает уведомления, отправка по этому ключу приостанавливается: 5 неудач подряд → пауза 5 минут, 10 → 30 минут, 20 → 2 часа, 50 → отправка по ключу отключается до ручного вмешательства. Пока пауза действует, вебхуки не отправляются и не копятся — переходы за это время для этого канала потеряны, восстанавливай их опросом GET /invoices/{id}. Пауза снимается сама первой успешной доставкой либо кнопкой «Проверить уведомления» в кабинете. Текущее состояние видно в списке API-ключей: поле webhook_statusactive, paused или disabled.

После ответа 4xx (кроме 429) автоматических повторов нет вовсе: попытка считается неуспешной, и этот переход больше сам не отправится. Переслать его можно только вручную — в кабинете, «Лог уведомлений», кнопка повтора у неудачной записи.

Шаг 8. Переход в рабочий режим

Когда тесты в песочнице прошли:

СТОП. Кассир Kaspi обязателен именно здесь. Если клиент ещё не подключил кассира — самое время. Без активной сессии кассира любой счёт в рабочем режиме сразу падает с kaspi_session_not_configured (см. шаг 7). Инструкция: /connect-cashier. Дождись подтверждения, что кассир подключён, и только после этого переключай режим.

Предполётная проверка — программно, до разговора с клиентом

// Два GET-запроса показывают, готов ли аккаунт к рабочему режиму.
const health = await (await fetch('https://api.apipay.kz/api/v1/account/health',
  { headers: { 'X-API-Key': process.env.APIPAY_API_KEY } })).json()
// connection — подключён ли кассир и жива ли сессия (connection.needs_reauth),
// tariff.status — активна ли подписка, invoicing.accumulating — копятся ли счета.

const tariff = await (await fetch('https://api.apipay.kz/api/v1/tariff',
  { headers: { 'X-API-Key': process.env.APIPAY_API_KEY } })).json()
// снимок подписки клиента на ApiPay: срок, суммы. Это плата за сервис, не оборот клиента.

Оба запроса — только чтение, лимитов тарифа не расходуют и работают даже при неактивной подписке. Если tariff.status не активен или кассир не подключён — не отправляй клиента переключать режим, сначала закрой эти пункты.

  1. Кассир Kaspi подключён по /connect-cashier, подписка оплачена (при первом подключении кассира — 3 дня бесплатно).
  2. Анкета о бизнесе заполнена на /business-profile (~5 мин). До её одобрения молодая орг создаёт лишь 1 реальный счёт/сутки (429 kyc_daily_limit_reached) — заполни заранее, чтобы к запуску лимит уже сняли. Зачем это — см. шаг 2c.
  3. Адрес вебхука — на реальном домене (публичный HTTPS, не IP и не туннель). Для ещё не одобренной орг ngrok/IP в проде дадут 422 webhook_url_requires_domain/webhook_url_tunnel_forbidden. Туннель из шага 6 — только для теста в песочнице.
  4. Переключение режима — баннер в шапке раздела «Настройки», над всеми вкладками. Там написано «ТЕСТОВЫЙ РЕЖИМ» и стоит кнопка «Переключить в рабочий режим». Кнопка доступна ролям Владелец и Разработчик; режим можно менять не чаще раза в 5 минут.
  5. В рабочем режиме счета уходят в реальный Kaspi, клиенты платят настоящими деньгами.
Если по итогам проверки бизнеса организация получила статус blocked, создание счёта отдаёт 403 kyc_rejected — это терминальный отказ. Анкету повторно подавать нельзя; клиенту нужно написать в поддержку WhatsApp, если он считает это ошибкой.

Готово — интеграция приёма платежей завершена.

Шаг 9. Касса Kaspi — смены, наличные, сверка (опционально)

Нужно, если у клиента есть касса Kaspi и он хочет закрывать смены, забирать отчёты и сверять кассу со счетами не руками. Требует подключённого кассира (шаг 8) и активной подписки; у кассовых ручек отдельный лимит — 30 запросов в минуту на ключ.

Проверьте предусловие до того, как писать код. Кассовые ручки работают только у организаций, к аккаунту Kaspi Pay которых подключена Kaspi Касса (ОФД) — та же связка, что включает каталог товаров. Если клиент принимает оплаты через Kaspi Pos без ОФД, кассовых смен у него не существует: раздел «Касса» в кабинете не показывается, а ручки отвечают 409cashbox_kkm_unknown у GET /cashbox/shifts и POST /cashbox/shifts/close, rfo_missing у GET /cashbox/summary и обоих тумблеров. Если ОФД у клиента нет — ждать нечего, номер кассы сам не появится.

Если клиент утверждает, что Kaspi Касса у него есть, а ручки всё равно отдают 409: при нескольких кассах передайте kaspi_connection_id нужной точки, а состояние организации пересверяется в кабинете — Настройки → вкладка «Организация» → карточка «Информация об организации» → кнопка «Обновить информацию» (доступна только владельцу организации, не чаще раза в 5 минут) — либо переподключением кассира. Предупредите клиента: то же действие включает каталог товаров, и после него POST /invoices/qr и POST /static-qr без cart_items начнут отвечать 422 catalog_requires_cart_items, а уже напечатанные QR-листы без состава перестанут работать — переиздать их нельзя.

В песочнице касса отвечает всегда — детерминированными данными, независимо от того, подключена ли она в бою и есть ли кассир. Интеграцию, прошедшую в песочнице, обязательно прогоните на боевой организации клиента, иначе первые 409 вы увидите уже в проде. Раздел «Касса» в кабинете тестовой организации при этом появляется, только если организация создана с ответом «касса есть» (ответ меняется в настройках).

Порядок вызовов

  1. GET /cashbox/summary?date=YYYY-MM-DD — наличные за день: остаток в кассе, внесено, изъято, продажи и возвраты наличными. Суммы приходят строками «N.NN» и могут быть null — это «Kaspi не отдал поле», а не ноль. Оплаты по счетам ApiPay сюда не попадают.
  2. GET /cashbox/shifts?date_from=&date_to= — список смен. Обе границы обязательны, окно не длиннее 31 дня, иначе 422. Запрос идёт в кассу Kaspi и отвечает не мгновенно. Этот вызов обязателен перед сверкой: он сохраняет смены на нашей стороне.
  3. GET /cashbox/shifts/{id}/report — отчёт по смене. Возвращает не файл, а подписанную ссылку со сроком жизни около 15 минут: скачивай сразу, ссылку не храни и не логируй — она открывается без ключа.
  4. GET /cashbox/reconciliation?shift_id={id} — сверка: рядом ваши оплаченные счета (ours, окно по дате оплаты) и итог кассы (kaspi) плюс discrepancies с причинами расхождения. Без шага 2 придёт 404 cashbox_shift_not_found.
Разницу сервис не считает. Итог смены в кассе — единая сумма: продажи наличными и продажи мимо ApiPay в ней не выделены. Полей verdict, comparable и delta в ответе нет — не придумывай их и не вычитай одну цифру из другой. При нескольких кассах помни: sales считаются по кассиру смены, а refunds — по всей организации.

Закрытие смены — асинхронное

POST /cashbox/shifts/close с телом {"client_operation_id": "…", "shift_number": 106} отвечает 202. Результат читай поллингом GET /cashbox/operations/{id} либо вебхуками cashbox.shift_closed / cashbox.shift_close_failed (дедуплицируй по паре event + operation.id).

Тумблеры автозакрытия и автоизъятия

PUT /cashbox/settings/auto-close и PUT /cashbox/settings/auto-withdrawal с телом {"enabled": true} отвечают {"changed":…, "new_value":…}. Запрос идемпотентен по живому значению на кассе: changed:false — не ошибка, там уже стояло запрошенное значение. 503 cashbox_toggle_in_progress — настройку меняет другой запрос; 503 cashbox_toggle_unavailable — изменение не применено.

GET /cashbox/settings отдаёт сохранённые значения и может вернуть null — это «сохранённого значения ещё нет», а не «выключено». Живое состояние приходит в других ответах: автозакрытие — в GET /cashbox/shifts, автоизъятие — в GET /cashbox/summary.

Подробнее — автозакрытие смены, отчёт по смене, сверка кассы со счетами.


Краткий справочник

ЧтоЗначение
Базовый адрес APIhttps://api.apipay.kz/api/v1
АвторизацияX-API-Key (заголовок, только на сервере)
Способ 1. Счёт по номеру телефона POST /invoices — push в приложении Kaspi, живёт 24 часа, ссылки для отправки нет
Способ 2. Оплата по ссылке или QR POST /invoices/qr — в ответе qr_token_url (ссылка на оплату, отправляется покупателю в мессенджер) и qr_image_url (PNG для экрана). Окно на оплату — в qr_expires_at
Способ 3. Печатный QR под сделку POST /static-qrprint_url (долгоживущая ссылка: печать или мессенджер), short_code для ручного ввода, отключение DELETE /static-qr/{id}
Статус счётаGET /invoices/{id}, POST /invoices/status/check
Готовность аккаунта к продуGET /account/health, GET /tariff
Сумма счёта на номерцелые тенге, 1 … 99 999 999; дробная → 422 amount_must_be_whole_tenge (копейки принимает только POST /invoices/qr)
Описание счётадо 60 символов (с 05.09.2026 — жёстко, 422 description_too_long); Kaspi показывает покупателю первые 60
Отмена / возвратPOST /invoices/{id}/cancel (только счёт на номер: счёт по ссылке или QR отменить нельзя, 409 qr_cancel_unsupported), POST /invoices/{id}/refund
Подписки (рекуррент)POST /subscriptions + /pause, /resume, /cancel
КаталогPOST /catalog (1–100 позиций, ответ всегда 202, ветвись по outcome)
Касса: смены и отчётGET /cashbox/shifts, GET /cashbox/shifts/{id}/report, POST /cashbox/shifts/close (202)
Касса: наличные и сверкаGET /cashbox/summary, GET /cashbox/reconciliation?shift_id=
Подпись вебхукаX-Webhook-Signature: sha256=…, HMAC-SHA256 от сырого тела
Дедупликация вебхуковпо паре (invoice.id, invoice.status)
ПоддержкаWhatsApp +7 700 307 65 12

Другие материалы для машин (нужны редко — этого плейбука достаточно для типовой интеграции): llms.txt — индекс, с чего начать · llms-full.txt — полный свод по всем ручкам · errors.md — каталог кодов ошибок · openapi.json — спецификация OpenAPI · оплата по ссылке Kaspi — разбор способа 2 для человека.

CRM и платформам, которые подключают чужих мерчантов: см. Partner API (другой механизм — X-Partner-Key).