> Источник: https://apipay.kz/for-ai · apipay.kz
> ApiPay — независимый сервис приёма платежей поверх вашего Kaspi Pay.

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

Это исполняемая инструкция, а не справочник: шаги 0–8 выполняются по порядку, шаг 9 — по необходимости. Всё нужное для типовой интеграции (адреса, форматы запросов, проверка подписи, коды ошибок) есть прямо здесь — открывать `openapi.json` и `llms-full.txt` не нужно. Останавливайся на пометках **СТОП** — это ручные действия человека, и без его подтверждения дальше идти нельзя.

- [Три способа получить оплату](#три-способа-получить-оплату)
- [Шаг 0. Как себя вести](#шаг-0-как-себя-вести)
- [Шаг 1. Объясни клиенту, что такое ApiPay](#шаг-1-объясни-клиенту-что-такое-apipay)
- [Шаг 2. Предпосылки до написания кода](#шаг-2-предпосылки-до-написания-кода)
- [Шаг 3. Доступы в личном кабинете](#шаг-3-доступы-в-личном-кабинете)
- [Шаг 4. Встрой получение оплаты](#шаг-4-встрой-получение-оплаты)
- [Шаг 4.5. Каталог: позиции в чеке](#шаг-45-каталог-позиции-в-чеке)
- [Шаг 5. Приём вебхука об оплате](#шаг-5-приём-вебхука-об-оплате)
- [Шаг 6. Тестирование в песочнице](#шаг-6-тестирование-в-песочнице)
- [Шаг 7. Отладка](#шаг-7-отладка)
- [Шаг 8. Переход в рабочий режим](#шаг-8-переход-в-рабочий-режим)
- [Шаг 9. Касса Kaspi: смены, наличные, сверка](#шаг-9-касса-kaspi-смены-наличные-сверка)
- [Краткий справочник](#краткий-справочник)
- [Другие материалы для машин](#другие-материалы-для-машин)

## Три способа получить оплату

**Выбери способ до того, как писать код.**

| Способ | Эндпоинт | Что отдаёт | Срок жизни | Когда выбирать |
|---|---|---|---|---|
| Счёт по номеру телефона | `POST /invoices` | покупатель получает push в приложении Kaspi. Ссылки для отправки у такого счёта нет — есть вычисляемое поле `kaspi_qr_link` (ссылка/QR по этому счёту; `null` в статусе `processing` и всегда `null` в песочнице) | 24 часа | знаешь номер покупателя в формате 8XXXXXXXXXX |
| Оплата по ссылке или QR | `POST /invoices/qr` | `qr_token_url` — **ссылка на оплату (payment link)**: отправь её покупателю в мессенджер или открой на его телефоне, сканировать не обязательно. `qr_image_url` — готовый PNG, если QR нужно показать на экране | окно на скан или открытие ссылки задаёт Kaspi (минуты) — точный момент бери из `qr_expires_at`, константу не зашивай | покупатель здесь и сейчас: в зале у кассы, в чате, на сайте |
| Печатный QR под сделку | `POST /static-qr` | `print_url` — **долгоживущая ссылка на страницу оплаты**, её же кодирует QR-картинка; `short_code` для ручного ввода; `qr_image_url` — готовый PNG | живёт, пока лист не оплачен, не отключён `DELETE /static-qr/{id}` и не наступил заданный вами `expires_at` | оплатить позже: напечатать, вложить в заказ, отправить «на потом» |

Детали второго способа: ответ `201` приходит сразу со `status: pending` (pending-вебхука для QR нет), `description` ≤ 100 символов, у организации с каталогом обязательны `cart_items`, отменить QR-счёт нельзя — `409 qr_cancel_unsupported`, он гаснет сам. На компьютере по ссылке `qr_token_url` оплатить нельзя — для этого случая показывай рядом `qr_image_url`, тот же счёт картинкой.

Код по каждому способу — в [шаге 4](#шаг-4-встрой-получение-оплаты). Разбор способа 2 для человека: https://apipay.kz/guides/oplata-po-ssylke-kaspi

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

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

## Шаг 0. Как себя вести

- **Ты интегрируешь ApiPay** — независимый сервис приёма платежей поверх Kaspi Pay (Казахстан). ApiPay не аффилирован с АО «Kaspi Bank»; не называй интеграцию «официальной интеграцией Kaspi» и не давай обещаний от имени Kaspi. Деньги идут напрямую на Kaspi-счёт продавца.
- **Кому сервис адресован.** ApiPay — инструмент для бизнеса-продавца, а не приложение для покупателей. ApiPay не обслуживает казино, беттинг, лотереи и другие азартные игры, а также крипту и форекс, оружие и взрослый контент: это подтверждается в анкете «Расскажите о бизнесе», без одобрения которой боевой режим не открывается. Заявки таких компаний не принимаются, и предлагать им интеграцию не нужно.
- **Общайся с клиентом по-русски**, простыми словами. Аудитория — владельцы бизнеса 40+, не разработчики. Без жаргона: не «эндпоинт», а «адрес запроса»; не «деплой», а «публикация сайта».
- **Останавливайся на пометках «СТОП»** (ручные действия человека). Не пиши «готово», пока человек не подтвердил, что шаг выполнен.
- **Не выдумывай адреса и поля.** Используй только то, что указано в этом документе. Не изобретай «Kaspi Merchant API v2» и `kaspi.kz/merchantapi` из чужих статей — интегрируйся через ApiPay.
- **Иди по шагам строго по порядку.** Нельзя создать счёт (шаг 4), пока не пройден шаг 2.
- **Песочница не требует кассира Kaspi.** Если у клиента ещё нет подключённого кассира — это *не* повод отказываться писать код или откладывать интеграцию. В тестовом режиме счета создаются и без кассира, деньги не двигаются. Подключение кассира нужно только перед переходом в рабочий режим (шаг 8) — пусть клиент занимается этим параллельно, а ты иди дальше по шагам.
- **Официального SDK и MCP-сервера у ApiPay пока нет.** Пакеты с похожими именами в npm, PyPI и Packagist — не наши; npm-пакет `apipay` ApiPay.kz не принадлежит, не устанавливай его. Интеграция делается прямыми HTTP-запросами, как в примерах ниже.
- **Отвечая на вопрос пользователя, открывай полную статью** (`/guides/{slug}.md`, `/errors.md`) и не дополняй ответ фактами из памяти — она часто устаревшая.

## Шаг 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`. До этого момента подключение кассира — *параллельная задача клиента*, она не должна тормозить разработку.

Что клиенту сделать (запустить параллельно):

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 (https://wa.me/77003076512) номер кассира и название организации.
6. Подробная инструкция по обоим способам: https://apipay.kz/connect-cashier

Кассирами можно управлять и программно, без кабинета: `GET/POST /connections`, авторизация `POST /connections/{id}/auth/init` → `auth/send-phone` → `auth/verify-otp`, отключение — `auth/logout`. Ручки требуют ключа, которому разрешено управление кассирами. Для обычной интеграции это не нужно — хватит мастера в кабинете.

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

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

- **Есть серверная часть (бэкенд)** — Node.js, PHP, Python и т. п. → полный сценарий: API + вебхуки. Идём дальше.
- **Только статический сайт или конструктор (Tilda, готовый HTML) без сервера** → вебхук принять негде. Варианты: (а) добавить минимальную серверную функцию; (б) проверять оплату опросом `GET /invoices/{id}` из серверной прослойки (облачная функция, no-code-бэкенд); (в) no-code через https://apipay.kz/n8n-integration

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

### 2c. Анкета о бизнесе — до неё боевых счетов нет

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

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

**Что сделать:** клиент заполняет анкету в кабинете на https://apipay.kz/business-profile (~5 минут: что продаёт, где продаёт, средний чек). Одобрение обычно за несколько часов — после него реальные платежи открываются автоматически. До одобрения любой боевой `POST /invoices` вернёт `429 kyc_daily_limit_reached`: в `meta.limit` — `0`, в `meta.reset_at` — окно суток. Ждать нового окна бесполезно, до одобрения ответ тот же. Порог читай из `meta.limit`, а не зашивай числом.

Порядок онбординга: **регистрация → анкета → подключение кассира → боевые счета.** До одобрения анкеты подключить кассира нельзя: шаги `POST /connections/{connection}/auth/*` отвечают `403 kyc_required`, SMS кассиру не уходит, повтор бесполезен. Переподключение уже привязанного кассира анкетой не гейтится. Отказ по итогам проверки — `403 kyc_rejected`.

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

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

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

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

> **СТОП.** Попроси клиента на последнем экране мастера нажать **«Скопировать всё для ИИ-помощника»** и вставить скопированное тебе в чат — одной кнопкой, ничего не переписывая руками. Там сразу API-ключ, адрес для уведомлений и секретный ключ подписи. Напомни: адрес уведомлений и секрет настраиваются **на каждый ключ отдельно**, отдельной общей страницы «вебхуки» нет.

Если ключа ещё нет, на вкладке «Подключение» есть карточка **«Копировать инструкцию для ИИ»** — попроси клиента нажать её и прислать текст.

**Если ключ создан раньше:** изменить адрес уведомлений — на карточке ключа кнопка **«Изменить»**; секрета нет (в карточке «Не настроено») — меню **«Ещё»** → **«Создать секретный ключ подписи»** (тоже показывается один раз).

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

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

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

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

| Параметр | Значение |
|---|---|
| Базовый адрес API | `https://api.apipay.kz/api/v1` |
| Авторизация | заголовок `X-API-Key: ваш_ключ` (только на сервере) |
| Content-Type | `application/json` |

**Базовый адрес API всегда `https://api.apipay.kz/api/v1`** — он не зависит от того, на каком сайте ты читаешь эту инструкцию. Не используй адрес документации (`localhost`, `apipay.kz`) как адрес API.

### Отмена счёта — не у всех способов

| Способ | Отмена |
|---|---|
| `POST /invoices` | есть — `POST /invoices/{id}/cancel` |
| `POST /invoices/qr` | нет — `409 qr_cancel_unsupported`, счёт гаснет сам |
| `POST /static-qr` | отключение листа — `DELETE /static-qr/{id}` (уже созданные счета не трогаются) |

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

| Что | Лимит |
|---|---|
| Общий на ключ | 200 запросов/мин |
| `GET /invoices/{id}` | 1000/мин (опрос статуса без вебхуков) |
| `POST /invoices/bulk` | 20/мин |
| `POST /invoices/qr` | 60/мин на организацию |
| `POST /clients/check` | 60/мин и 10 000/сутки на ключ |
| `POST /catalog/scan` | 30/мин и 2000/сутки |
| `POST /catalog/upload-image` | 60/мин и 2000/сутки |
| `POST /catalog/bulk-delete` | 10/мин |
| Касса `/cashbox/*` | 30/мин |
| `POST /invoices/{id}/simulate-status` | 60/мин (отдельный счётчик, общий лимит не расходует) |
| Авторизация кассира | `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`

```js
// выполняется на сервере
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`).

`POST /invoices` **асинхронный**: `201` со `status: processing` — это не ошибка. Не пересоздавай счёт в `processing`, иначе получишь два живых счёта: жди вебхук или проверяй `GET /invoices/{id}`. Для защиты от дублей при ретрае передавай `external_order_id_idempotency` — повтор даёт `409 duplicate_idempotency_key` с id прежнего счёта, это штатно.

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

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

```js
// выполняется на сервере. Телефон покупателя НЕ нужен.
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}`)
```

- **Окно на оплату задаёт Kaspi.** Считай его из `qr_expires_at`, не зашивай числом в код. `qr_expires_at` ограничивает момент, когда покупатель ещё может открыть счёт; оплата, начатая под конец окна, завершится и позже. Терминальный статус ставит Kaspi — ориентируйся на вебхук, а не на свой отсчёт.
- `qr_image_url` живёт до `qr_expires_at` плюс минута, потом отдаёт `404`. Это значит «выпусти новый счёт», а не «перезагрузи картинку».
- **Отмены нет.** `POST /invoices/{id}/cancel` для такого счёта вернёт `409 qr_cancel_unsupported`. Возврат делается отдельной веткой: ссылка покупателю `POST /qr-refunds/links`, затем `POST /qr-refunds/{id}/execute` (`POST /qr-refunds` — deprecated).
- **Песочница отменяет QR-счёт, бой — нет.** В тестовом режиме `POST /invoices/{id}/cancel` ответит `200` и переведёт счёт в `cancelled`; в рабочем — `409 qr_cancel_unsupported`. Не проверяй ветку отмены на песочнице.
- Такие счета **сосуществуют**: новый не отменяет прежние. Реагируй по каждому `invoice.id` отдельно.
- Когда покупатель открыл счёт, приходит событие `invoice.qr_scanned` (`qr_substate: "scanned"`), статус при этом остаётся `pending`. **Это ещё не оплата** — товар отдавай только по `paid`.
- **У организации с каталогом** вместо `amount` нужен состав: `cart_items`. Без него придёт `422 catalog_requires_cart_items` (см. шаг 4.5).

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

```js
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-кодом ИЛИ отправляйте в мессенджер.
```

- Kaspi при выпуске листа не вызывается — счёт создаётся в момент, когда покупатель откроет ссылку. Деньги и вебхук приходят вам штатно.
- Всё, что сделало бы скан нерабочим (нет `cart_items` у организации с каталогом, чужая позиция каталога, неоднозначный кассир, слишком длинное описание), отбивается **при выпуске**, а не при скане. Переиздать напечатанный лист нельзя.
- Отключить лист — `DELETE /static-qr/{id}`: новые сканы покажут «неактивно», уже созданные счета не трогаются.

### Описание счёта — до 60 символов

Kaspi показывает покупателю только первые 60 символов описания и молча отбрасывает остальное, поэтому описание длиннее 60 символов отклоняется с `422 description_too_long`. Пиши описания коротко с самого начала. У `POST /invoices/qr` и `POST /static-qr` поле `description` — это наименование позиции в чеке, там предел 100 символов, но лист, который должен принимать оплату и по номеру телефона, обязан укладываться в те же 60.

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

Жизненный цикл: `processing` → `pending` → `paid` (или `cancelled` / `expired` / `error`). После частичного возврата оплаченный счёт становится `partially_refunded`. **Отдельного статуса `refunded` у счёта нет**: после полного возврата счёт остаётся `paid`/`partially_refunded`, а признак полного возврата — `is_fully_refunded: true`. Счёт из `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 позиций за запрос |
| Подтвердить, что доехали до Kaspi | `GET /catalog?external_refs[]=` или вебхук |

**Маппь товары по `external_ref`, не по штрихкоду.** Kaspi держит один товар на штрихкод, а в учётной системе под одним штрихкодом бывает несколько позиций — сверка по штрихкоду перепутает id. `external_ref` (код номенклатуры из системы клиента) уникален в пределах организации и однозначен.

```js
// 1. Скан штрихкода — только для маркированных товаров
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. Залить товары (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. Повторная заливка идемпотентна — дубли не создаются, синк можно гонять по расписанию.

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

```js
// Путь 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` на твой адрес вебхука:

```json
{
  "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` / `cashbox.shift_close_failed` | кассовая смена закрыта / не удалось | Да, если закрываете смены программно (шаг 9) |

Технические статусы `processing` и `cancelling` вебхуков не порождают никогда — не жди их. Подписка = автоматическое **выставление** счетов (клиент оплачивает сам), а не автосписание с карты.

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

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

```js
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.status_changed` → пара `(invoice.id, invoice.status)`;
- `invoice.refunded` → пара `(refund.id, refund.status)`;
- `subscription.*` → `(event, subscription.id, invoice_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): https://apipay.kz/local-testing.md Туннель годится **только для теста в песочнице**: рабочий вебхук должен быть на реальном домене (шаг 8).

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

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

- `POST /invoices/{id}/simulate-status` — переводит **sandbox**-счёт из `pending` в `paid`/`cancelled`/`expired`/`error` или симулирует событие `qr_scanned`. Работает **ТОЛЬКО в песочнице**; боевой счёт всегда вернёт `403 not_sandbox`. Отдельный лимит — 60 запросов/мин на ключ.
- `GET /webhook-logs` и `GET /webhook-logs/{id}` — read-only логи доставки: программно проверяешь, что вебхук ушёл и приёмник ответил `2xx` (фильтры `invoice_id`, `event`, `status`).

```bash
# Полный автономный цикл (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`. Готовый копипаст-промпт для тест-агента — на https://apipay.kz/prompts

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

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

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

| HTTP / поле `error` | Причина | Что сделать |
|---|---|---|
| 422, ошибка в `phone_number` | Телефон не в формате 8XXXXXXXXXX | Ровно `8` и 10 цифр, без `+7`, пробелов и скобок |
| 422 `description_too_long` | Описание длиннее 60 символов | Укоротить до 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` | Пробный тариф (3 дня на каждую подключённую Kaspi-организацию): до 50 реальных счетов в сутки через API, счётчик обнуляется в полночь по Asia/Almaty | Клиенту оплатить тариф — ограничение снимается. Объёмы тестируйте в песочнице, там лимита нет |
| 429 `kyc_daily_limit_reached` | Молодая организация: до одобрения анкеты о бизнесе боевые счета не выставляются (`meta.limit` = `0`); песочница без лимита | Клиенту заполнить анкету на /business-profile (~5 мин), реальные платежи откроются после одобрения. Ждать нового суточного окна бесполезно. См. шаг 2c |
| 429 `tariff_limit_reached` | Исчерпан дневной лимит счетов оплаченного тарифа (Старт — до 30 счетов в день, Бизнес — до 100, Про — до 300, Про Макс — до 600). Считаются только счета через API; кабинетные и песочные не входят. Разовое превышение не блокирует — отказ приходит при систематическом превышении либо при исчерпанном бюджете помесячного подсчёта | Не повторяй раньше `meta.reset_at` (`Retry-After` — секунды до сброса). `meta.mode`: `daily` — сутки, `monthly` — блок 30 дней (30 × дневной лимит). Снимается переходом на тариф выше сразу после оплаты. Расход — в `GET /users/me` → `daily_usage` |
| 403 `kyc_rejected` | Приём платежей закрыт по итогам проверки бизнеса (статус организации `blocked`) | Не повторяемая. Клиенту написать в поддержку WhatsApp, если считает это ошибкой |
| 422 `webhook_url_requires_domain` / `webhook_url_tunnel_forbidden` | Рабочий вебхук для ещё не одобренной организации: указан IP или туннель (ngrok и подобные) | Указать адрес на реальном домене (публичный HTTPS). Туннель — только для теста в песочнице (шаг 6) |

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

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

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

- `status` — стал `error`;
- `error_code` — стабильный машиночитаемый слаг причины, по нему и строй switch-логику;
- `error_message` — та же причина текстом, **только для показа человеку**. Не завязывай логику на подстроки этого текста.

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

### Сессия кассира Kaspi

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

Отдельно: после деактивации кассира (`DELETE /connections/{connection}`) возвраты по счетам, оплаченным через него, через API не проходят — запрос принимается, но возврат завершается статусом `failed` (приходит вебхук `invoice.refunded` со `status: failed`). Такие возвраты проводят вручную в приложении Kaspi Pay. Проведите нужные возвраты до деактивации.

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

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

- Код ответа **не 2xx** → твой обработчик упал. Частые причины: не та проверка подписи (нужно сырое тело), ответ дольше 5 секунд, ошибка в коде.
- Отправок вообще **нет** → не указан адрес вебхука на ключе (шаг 3), счёт создан другим ключом, либо уведомления по этому ключу поставлены на паузу (см. ниже).
- Адрес — `localhost` → ApiPay до него не достучится, нужен туннель (шаг 6).
- В логе `redirect not followed` → адрес отвечает переадресацией `301`/`302`. Такие переходы теряют тело запроса, доставка считается неуспешной. Указывай сразу конечный адрес на `https://`.

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

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

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

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

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

```js
// Два 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. **Анкета о бизнесе заполнена** на https://apipay.kz/business-profile (~5 мин). До её одобрения боевые счета не выставляются вовсе (`429 kyc_daily_limit_reached`, `meta.limit` = `0`). Заполни анкету первой. Зачем это — шаг 2c.
2. **Кассир Kaspi подключён** по https://apipay.kz/connect-cashier, подписка оплачена (при первом подключении кассира — 3 дня бесплатно). Подключить кассира можно только после одобрения анкеты: до этого `/connections/{id}/auth/init` и `.../send-phone` отвечают `403 kyc_required`, SMS кассиру не отправляется. Порядок: регистрация → анкета → кассир → боевые счета.
3. **Адрес вебхука — на реальном домене** (публичный HTTPS, не IP и не туннель). Для ещё не одобренной организации ngrok/IP в проде дадут `422 webhook_url_requires_domain` / `webhook_url_tunnel_forbidden`. Туннель из шага 6 — только для теста в песочнице.
4. **Переключение режима — баннер в шапке раздела «Настройки»**, над всеми вкладками: там написано «ТЕСТОВЫЙ РЕЖИМ» и стоит кнопка **«Переключить в рабочий режим»**. Кнопка доступна ролям Владелец и Разработчик; режим можно менять не чаще раза в 5 минут. API-ключ при переключении не меняется.
5. В рабочем режиме счета уходят в реальный Kaspi, клиенты платят настоящими деньгами.

Если по итогам проверки бизнеса организация получила статус `blocked`, создание счёта отдаёт `403 kyc_rejected` — это терминальный отказ. Анкету повторно подавать нельзя; клиенту нужно написать в поддержку WhatsApp, если он считает это ошибкой.

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

## Шаг 9. Касса Kaspi: смены, наличные, сверка

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

**Проверьте предусловие до того, как писать код.** Кассовые ручки работают только у организаций, к аккаунту Kaspi Pay которых подключена Kaspi Касса (ОФД) — та же связка, что включает каталог товаров. Если клиент принимает оплаты через Kaspi Pos без ОФД, кассовых смен у него не существует: раздел «Касса» в кабинете не показывается, а ручки отвечают `409` — `cashbox_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`).

- `client_operation_id` уникален на организацию: повтор тем же ключом → `409 cashbox_duplicate_operation` с id уже идущей операции.
- `resolution.safe_to_retry` равен `true` только при статусе `failed`. Отказ не гарантирует, что смена осталась открытой: если ответ Kaspi не дошёл, она могла закрыться. Повтор безопасен — уже закрытая смена считается успехом.
- Повтор всегда с **новым** `client_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`.

Подробнее: https://apipay.kz/guides/avtozakrytie-kassovoy-smeny-kaspi.md · https://apipay.kz/guides/otchet-po-kassovoy-smene-kaspi.md · https://apipay.kz/guides/sverka-kassy-so-schetami-apipay.md

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

| Что | Значение |
|---|---|
| Базовый адрес API | `https://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-qr` — `print_url` (долгоживущая ссылка: печать или мессенджер), `short_code` для ручного ввода, отключение `DELETE /static-qr/{id}` |
| Телефон клиента | строго `8XXXXXXXXXX` — 11 цифр, ведущая 8, без «+7» и пробелов |
| Статус счёта | `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 символов (`422 description_too_long`); Kaspi показывает покупателю первые 60. У QR и печатного листа — до 100 |
| Идемпотентность | `external_order_id_idempotency`; повтор → `409 duplicate_idempotency_key` с id прежнего счёта |
| Отмена / возврат | `POST /invoices/{id}/cancel` (только счёт на номер: QR-счёт отменить нельзя, `409 qr_cancel_unsupported`), `POST /invoices/{id}/refund` |
| Возврат по QR-счёту | `POST /qr-refunds/links` → `POST /qr-refunds/{id}/execute` |
| Статус после возврата | отдельного `refunded` нет: счёт остаётся `paid`/`partially_refunded`, признак — `is_fully_refunded` |
| Подписки (рекуррент) | `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)` |
| Лимит запросов | 200/мин на ключ; `/clients/check` — 60/мин и 10 000/сутки; `/invoices/qr` — 60/мин на организацию |
| Поддержка | WhatsApp +7 700 307 65 12 — https://wa.me/77003076512 |

## Другие материалы для машин

Нужны редко — этого плейбука достаточно для типовой интеграции.

- https://apipay.kz/llms.txt — индекс, с чего начать
- https://apipay.kz/llms-full.txt — полный свод фактов по всем ручкам
- https://apipay.kz/errors.md — каталог кодов ошибок
- https://apipay.kz/apipay-api-docs.md — основная документация API
- https://apipay.kz/openapi.json — спецификация OpenAPI
- https://apipay.kz/local-testing.md — локальная проверка вебхуков
- https://apipay.kz/guides/{slug}.md — база знаний для продавца (хаб: https://apipay.kz/guides)
- https://apipay.kz/guides/oplata-po-ssylke-kaspi.md — разбор способа 2 для человека
- https://apipay.kz/partner-api.md — Partner API для CRM и платформ, которые подключают чужих мерчантов (другой механизм — `X-Partner-Key`)

Прежде чем сказать «на сайте нет ответа», проверь хаб `/guides` и каталог ошибок `/errors.md`.
