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

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

Этот плейбук нужен, когда пользователь поручил интеграцию ApiPay. Для вопроса о сервисе сначала найди ответ в [базе знаний](https://apipay.kz/guides/index.md), для ошибки — в [диагностике](https://apipay.kz/guides#troubleshooting) и [каталоге ошибок](https://apipay.kz/errors.md). Ответь по источнику; начинать подключение для чтения публичной документации не требуется.

**Если у тебя есть терминал и задача — подключить ApiPay к проекту, а MCP `apipay-setup` не подключён (инструмента `apipay_next` у тебя нет), — остановись.** Код не пиши и `apipay-cli` не запускай. Скажи человеку, что помощник ApiPay (MCP `apipay-setup`) у него не подключён, дай команду подключения для его ИИ-клиента и жди. Команды для всех клиентов — на https://apipay.kz/setup/ai ; для Claude Code — `claude mcp add --transport http --scope user apipay-setup https://mcp.apipay.kz/mcp`, для Codex — `codex mcp add apipay-setup --url https://mcp.apipay.kz/mcp`. После подключения человек перезапускает ИИ-клиент и снова пишет «Встрой apipay.». Если терминала у тебя нет (чат в браузере, конструктор сайтов), — работай по этому плейбуку.

Если у клиента подключён ApiPay Setup MCP, что он умеет без входа и после входа человека — в разделе «Setup MCP» ниже. Шаги 0–8 далее описывают REST-интеграцию; шаг 9 нужен только по задаче клиента. Для типового сценария используй примеры здесь; точные поля, методы и HTTP-статусы сверяй с [актуальным REST-контрактом](https://apipay.kz/openapi.json). Останавливайся на пометках **СТОП** — это ручные действия человека, и без его подтверждения дальше идти нельзя.

- [Встрой apipay — коротко](#встрой-apipay-коротко)
- [Setup MCP: что он умеет](#setup-mcp-что-он-умеет)
- [Четыре способа получить оплату](#четыре-способа-получить-оплату)
- [Шаг 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-смены-наличные-сверка)
- [Краткий справочник](#краткий-справочник)
- [Другие материалы для машин](#другие-материалы-для-машин)

## Встрой apipay — коротко

Тебе написали «Встрой apipay»? Сначала спроси человека: «Подключу приём оплаты Kaspi через ApiPay — сначала в песочнице, без настоящих денег. Продолжить?»

**Путь с MCP.** Если в ИИ-клиенте подключён сервер `apipay-setup`, первым вызови `apipay_next` и дальше исполняй его ответы: `say_to_human` передай человеку, вопросы `ask_human` задай и верни ответы полем `answers`, `do_locally` сделай в коде, `call_next` вызови как есть. Свой порядок шагов не придумывай. Если MCP не подключён, а терминал у тебя есть, — остановись и попроси человека подключить MCP (в начале плейбука).

**Путь без терминала.** Работай по шагам 0–8 этого плейбука и по спецификации https://apipay.kz/openapi.json ; подключить MCP человек может на https://apipay.kz/setup/ai . Короткая инструкция для ИИ-помощника — https://apipay.kz/SKILL.md .

**В обоих путях:**

- Сначала песочница: анкета и кассир для неё не нужны. Порядок дальше — анкета о бизнесе → проверка анкеты → кассир → тариф → перевод в рабочий режим. Всё это делает человек в браузере. У помощника с терминалом перевод — заявка `apipay-cli golive` (шаг 8), которую человек подтверждает на экране; без терминала человек переводит организацию сам в кабинете. Режим сам не переключай.
- Секреты: значения из `.env*` не читай и не выводи. У помощника с терминалом ключ и секрет уведомлений в `.env` кладёт только команда `apipay-cli pull --wait` после подтверждения человека в кабинете (шаг 3), вставку вручную не предлагай; без терминала значения в настройки сервера или хостинга вписывает сам человек. Имена переменных — `APIPAY_API_KEY`, `APIPAY_WEBHOOK_SECRET`, `APIPAY_BASE_URL`, `APIPAY_SANDBOX_MODE`.
- В подключении через MCP уведомления об оплате (вебхук) обязательны. Одно событие может прийти дважды, в том числе уведомление о чеке, — обработчик обязан быть идемпотентным. Пауза доставки включается сама после серии отказов подряд, а при затянувшемся отказе отправка отключается до проверки в кабинете; события счёта, скана, возврата и `qr_refund.*` за паузу не повторяются — смотри журнал доставок и сверяй статусы опросом (подписки и чеки паузу не соблюдают).
- Обязательные исходы песочницы: оплата, отмена, истечение, ошибка, возврат, отказ в возврате, возврат по ссылке после отказа и повторная доставка уведомления; при ссылке на оплату или статическом QR — ещё оплата после истёкшей попытки и вторая оплата одного заказа.
- Возврат через API встраивается в любую интеграцию: всегда с `hold_for_buyer_confirmation: true`, а если Kaspi ответил `refund_requires_buyer_confirmation` или `refund_rejected_by_kaspi`, приложение само выпускает покупателю ссылку «Возврат ApiPay» (`POST /qr-refunds/links` с `invoice_id` и `source_refund_id`). Адрес ссылки не пиши в логи.
- Когда проверки пройдены, спроси: «Мы готовы. Выходим в прод?»

## Setup MCP: что он умеет

Единый вход для человека — https://apipay.kz/setup/ai : там он копирует настройку MCP для своего ИИ-клиента и фразу для тебя. Адрес сервиса — `https://mcp.apipay.kz/mcp`, имя сервера в ИИ-клиенте — `apipay-setup`. Сначала изучи проект и уточни назначение оплаты, источник суммы и ожидаемый результат. Не выводи бизнес-правила из отрасли. Для аналогии используй до трёх статей из https://apipay.kz/apipay-guides-index.json ; только `verified_case` с редакционными `public_facts` подтверждает реальное внедрение. Отсутствие статьи не блокирует работу.

**Навигатор `apipay_next` — первый и главный вызов.** Он возвращает один следующий шаг: `step`, `say_to_human` (что сказать человеку), `ask_human` (что спросить), `do_locally` (что сделать в коде), `done_when` (когда шаг сделан), `then` (что поменять перед следующим вызовом) и готовый `call_next` — следующий вызов, который делается как есть. `cursor` из ответа передавай в следующий вызов. Вопросы человеку — из каталога `apipay://questions`, не больше трёх за ход.

**Без входа** работают `apipay_next` (план, вопросы, код и локальная песочница), `get_recipe` (код по способу оплаты и стеку), `search_docs` (поиск по справочнику) и `read_doc` (полный текст документа или операции API, например `read_doc("api:POST /invoices")`).

**Остальные инструменты — только после входа человека.** ИИ-клиент открывает в браузере ссылку входа ApiPay; человек входит в свой кабинет, на экране согласия проверяет приложение и права, отмечает организации, с которыми агент будет работать, и нажимает «Разрешить» или «Отклонить». Работа агента — с песочной организацией. Боевую организацию человек открывает агенту отдельно: Настройки → «Кто имеет доступ к организации» → «ИИ-агенты»; там же отзывает доступ. Если агентский запрос отвечает `403 organization_not_granted` (организация не отмечена) или `403 production_access_required` (боевая без разрешения), дай человеку ссылку на этот раздел из ответа навигатора. После перевода организации в рабочий режим разрешения агентов к ней закрываются, и их нужно открыть заново. API-ключ REST для входа в MCP не используется.

После входа навигатор сам предлагает в `call_next`:

- `select_integration` — выбрать интеграцию и ключ из уже выпущенных;
- `set_webhook_url` — поставить адрес уведомлений тестового стенда (адрес боевого ключа меняет только человек в кабинете);
- `run_test_case` — случай проверки в песочнице: привязать счёт, который создало приложение клиента своим ключом, и задать исход;
- `simulate_refund_link` — шаги покупателя по ссылке «Возврат ApiPay» в песочнице;
- `report_app_check` — записать итог твоей проверки, изменился ли заказ в магазине (вопросы `app_check_*`; проверяешь сам через код проекта — по логу и базе приложения, `.env` не открывая);
- `delivery_logs`, `request_logs` — журнал доставок уведомлений и журнал запросов приложения к API;
- `send_test_webhook`, `replay_delivery` — тестовое уведомление и повтор доставки в песочнице (повтор уходит на адрес исходной доставки, поэтому его успех не доказывает, что работает текущий адрес);
- `save_kyc_draft` — черновик анкеты о бизнесе (без названия организации, БИН или ИИН, скриншотов и галочки — их вводит человек, `apipay://kyc`);
- `clear_sandbox` — очистить песочницу;
- `mark_go_live` — записать решение человека «выходим в прод» после его «да».

**Только человек в браузере**: выпуск ключа и секрета уведомлений, отправка анкеты о бизнесе (рассказ о бизнесе, ссылка на точку продаж и 1–3 скриншота), кассир с кодом из SMS, оплата тарифа и перевод в рабочий режим. Инструментов для этих шагов у MCP нет. Дай человеку ссылку из ответа навигатора (экраны `/business-profile`, `/settings/connect-cashier`, `/subscription/pay`) и не собирай её сам; ссылку на выпуск ключа и секрета человек получает только из вывода `apipay-cli pair` (шаг 3), ссылку на перевод в рабочий режим — из вывода `apipay-cli golive` (шаг 8; экран `/setup/production` по ссылке навигатора — запасной путь); если открыть браузер нельзя, покажи ссылку. После шага кабинет возвращает человека на https://apipay.kz/setup/ai?resume=1 , и он пишет в тот же чат готовую фразу. После выпуска ключа и перевода по заявке `apipay-cli` писать ничего не нужно: кнопку человека дожидается сама команда. Тогда снова вызови `apipay_next`: выполненным шаг считается по статусу, а не по сообщению. Без входа статус тебе не виден: не называй шаг подтверждённым, ход подключения человек видит на https://apipay.kz/setup/ai .

MCP не создаёт счета и возвраты, не проводит боевые операции, не выдаёт и не принимает API-ключ и секрет уведомлений и не даёт произвольного доступа к REST API. Тестовый счёт создаёт приложение клиента; исход задаёт `run_test_case`, а без входа в MCP — `POST /invoices/{id}/simulate-status` (шаг 6). Изменение заказа в магазине проверяется в самом приложении, а не по ответу HTTP. HTTP 200 не подтверждает бизнес-эффект, HTTP 500 не доказывает внутреннюю причину. Отрицательные исходы тестируй отдельными заказами. Внутренние логи проекта изучай в пределах разрешений. Перед исправлением найденной ошибки предложи конкретное изменение и дождись согласия. Не повторяй боевые уведомления. Сайт и код на рабочий или любой другой сервер всегда выкладывает человек или его разработчик: не выкладывай и не публикуй их, не настраивай выкладку и не выбирай сервер — даже если человек просит. Хостинг и домены не выбирай и не оплачивай, аккаунты не регистрируй, не проси человека войти в GitHub, хостинг или npm; ссылку и код из `apipay-cli pair` передавай как обычно. Если сайта в интернете нет, один раз скажи человеку, что опубликовать его — его дело или дело его разработчика, и продолжай: для подачи анкеты это не обязательно.

Не переключай боевую организацию в песочницу. Секреты попадают в `.env` только командой `apipay-cli pull --wait` после кнопки человека в кабинете: без значений в командах, query, логах и коде, который выполняется в браузере, без чтения `.env` агентом и автоматической ротации. Что ApiPay записывает в журнал MCP и сколько хранит — в ресурсе `apipay://diagnostics` и в [инструкции по MCP](https://apipay.kz/guides/podklyuchenie-apipay-mcp).

## Четыре способа получить оплату

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

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

**Правило выбора.** QR-счёт — только если покупатель платит сразу: QR живёт меньше трёх минут. «На потом» — ссылка на оплату (`POST /invoices/qr` со `static: true`) или печатный QR (`POST /static-qr`), и покупателю отправляется `payment_url`. Для ссылки на оплату нужен действующий тариф, иначе `403 tariff_inactive`. По ссылке на оплату и по листу счёт создаётся, только когда покупатель нажал «Оплатить в Kaspi» на странице оплаты: открытие страницы и предпросмотр в мессенджере счёт не создают. **Не пересоздавай QR-счёт в ожидании покупателя:** каждый QR-счёт, созданный по API-ключу, занимает место в дневном лимите тарифа в любом статусе — оплаченный, истёкший, отменённый.

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

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

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

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

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

- **Ты интегрируешь ApiPay** — независимый сервис приёма платежей поверх Kaspi Pay (Казахстан). ApiPay не аффилирован с АО «Kaspi Bank»; не называй интеграцию «официальной интеграцией Kaspi» и не давай обещаний от имени Kaspi. Деньги идут напрямую на Kaspi-счёт продавца.
- **Кому сервис адресован.** ApiPay — инструмент для бизнеса-продавца, а не приложение для покупателей. ApiPay не обслуживает казино, беттинг, лотереи и другие азартные игры, а также крипту и форекс, оружие и взрослый контент: это подтверждается в анкете «Расскажите о бизнесе», без одобрения которой боевой режим не открывается. Заявки таких компаний не принимаются, и предлагать им интеграцию не нужно.
- **Общайся с клиентом по-русски**, простыми словами. Аудитория — владельцы бизнеса 40+, не разработчики. Без жаргона: не «эндпоинт», а «адрес запроса»; не «деплой», а «публикация сайта».
- **Останавливайся на пометках «СТОП»** (ручные действия человека). Не пиши «готово», пока человек не подтвердил, что шаг выполнен.
- **Приложение запускает и перезапускает человек.** Второй экземпляр приложения (`npm run dev`, `next dev`, `npm start`, `node server.js`, `php artisan serve`, `uvicorn` и подобные) не запускай ни на каком порту: проверка пройдёт в копии, которую человек не видит. Если порт занят, скорее всего, приложение уже работает на нём. Обращайся к тестовой версии или к приложению на этом компьютере по адресу, который назвал человек или который виден из кода проекта. Не отвечает или не подхватило изменения кода и `.env` — попроси человека запустить или перезапустить приложение и дождись ответа. Команды `apipay-cli` и тестовые скрипты запускай сам.
- **Код на сервер выкладывает человек.** Сайт и код на рабочий или любой другой сервер выкладывает человек или его разработчик: не выкладывай и не публикуй их, не настраивай выкладку, cron и переменные на сервере и не выбирай сервер или хостинг — даже если человек просит. Скажи, что нужно сделать, и продолжай.
- **Не выдумывай адреса и поля.** Используй только то, что указано в этом документе. Не изобретай «Kaspi Merchant API v2» и `kaspi.kz/merchantapi` из чужих статей — интегрируйся через ApiPay.
- **Иди по шагам строго по порядку.** Нельзя создать счёт (шаг 4), пока не пройден шаг 2.
- **Песочница не требует кассира Kaspi.** Если у клиента ещё нет подключённого кассира — это *не* повод отказываться писать код или откладывать интеграцию. В тестовом режиме счета создаются и без кассира, деньги не двигаются. Подключение кассира нужно только перед переходом в рабочий режим (шаг 8), и делает его человек в кабинете после одобрения анкеты.
- **Готового SDK у ApiPay нет** — интеграция делается прямыми HTTP-запросами, как в примерах ниже. Пакеты `apipay` и `@apipay.vn/*` в npm к ApiPay.kz отношения не имеют; похожие имена в PyPI и Packagist тоже не наши — не устанавливай их. Из устанавливаемых пакетов ApiPay.kz публикует только узел для n8n (`n8n-nodes-apipay`) и команду `apipay-cli` для компьютера разработчика (шаг 3) — её запускают только полной командой с адреса https://apipay.kz/cli/ , не через `npx apipay-cli`.
- **Setup MCP — помощник настройки, а не платёжный API**: навигатор `apipay_next` ведёт по шагам; без входа работают план, вопросы, рецепты кода и справочник, после входа человека в браузере — выбор интеграции, адрес уведомлений, проверки в песочнице, журналы, черновик анкеты и путь в рабочий режим. Счета и возвраты делает приложение по REST API. Адрес сервиса `https://mcp.apipay.kz/mcp` клиент видит на экране https://apipay.kz/setup/ai (в кабинете — Настройки → «Подключение» → «Подключить с ИИ»), инструкция — https://apipay.kz/guides/podklyuchenie-apipay-mcp
- **Отвечая на вопрос пользователя, открывай полную статью** (`/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`. До этого момента подключение кассира — *параллельная задача клиента*, она не должна тормозить разработку.

Что подготовить клиенту параллельно разработке в песочнице. Авторизация кассира из пункта 4 доступна после одобрения анкеты о бизнесе (шаг 2c); тестовую интеграцию до этого не останавливай:

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 и опрос статуса с сервера. Вебхуки обязательны, если подключение идёт через MCP; при ручной интеграции — по желанию клиента (шаг 3, «Как узнавать об оплате»). Идём дальше.
- **Только статический сайт или конструктор (Tilda, готовый HTML) без сервера** → ни ключ хранить, ни опрос вести, ни вебхук принять негде. Варианты: (а) добавить минимальную серверную функцию; (б) проверять оплату опросом `GET /invoices/{id}` из серверной прослойки (облачная функция, no-code-бэкенд); (в) no-code через https://apipay.kz/n8n-integration

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

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

Пока короткая анкета «Расскажите о вашем бизнесе» не заполнена и не одобрена, в **рабочем режиме** счета **не выставляются** (исключение — пока `kyc.deadline` в `GET /account/health` в будущем). В **песочнице ограничения нет** — тестируй сколько нужно, разработку это не тормозит.

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

**Что сделать:** клиент заполняет анкету в кабинете на https://apipay.kz/business-profile (что продаёт, где продаёт, средний чек). Одобрение анкеты снимает ограничение KYC, но само по себе не подключает кассира и не подтверждает переход в рабочий режим. Проверь остальные условия шага 8; рабочий режим клиент включает сам в кабинете. При действующем ограничении KYC боевой `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 под ролью **Владелец** или **Разработчик** — у роли «Менеджер» доступа к ключам нет. Форма, экран «Готово» и кнопки карточки ключа ниже описаны для человека, который настраивает ключ сам. Если ключ для проекта получаешь ты, ИИ-помощник, — только командой `apipay-cli` (ниже, «В подключении с ИИ»).

**Создание ключа — одна форма и экран «Готово»:**

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

> **Секреты в чат не передавать.** Кнопка «Скопировать инструкцию без ключей» передаёт только инструкцию. Клиент сохраняет API-ключ локально в APIPAY_API_KEY, а секрет подписи — в APIPAY_WEBHOOK_SECRET, только если подключены уведомления; значения вписывает он сам. Файл с ключами не читай и не выводи его содержимое: ни при отладке, ни «чтобы проверить». В репозиторий этот файл не добавляй. Адрес webhook и секрет настраиваются на каждый ключ отдельно.

Кнопка **«Подключить с ИИ»** на этой же вкладке ведёт на https://apipay.kz/setup/ai : там следующий шаг подключения, настройка MCP и инструкция для ИИ без ключей. Сначала проверьте наличие подходящего ключа; новый выпускается человеком только при необходимости.

**В подключении с ИИ** ключ и секрет подписи уведомлений выпускает человек одной кнопкой, а в `.env` проекта их кладёт команда ApiPay для компьютера разработчика `apipay-cli` — без копирования и без чата. Нужен Node.js 18+; адрес архива версионный и не меняется. Запускай только полную команду с адресом и версией, не сокращай до `npx apipay-cli`. До первого запуска добавь `.apipay/` в `.gitignore`: там кеш npm, и он появляется раньше, чем команда сама допишет эту строку. Если `npx` пишет «permission denied» или `EPERM` про кеш npm, не используй sudo и не меняй владельца папок (chown): повтори ровно эту команду из корня проекта; снова не вышло — перескажи человеку текст ошибки и остановись на этом шаге. Строки `npm notice` и `npm warn` в выводе не обрабатывай и глобально ничего не устанавливай (`npm install -g` не нужен). Подсказки в выводе команды называют следующую команду целиком — запускай её как есть. Запускай в корне проекта:

```bash
npx -y --no-update-notifier --cache .apipay/npm-cache https://apipay.kz/cli/apipay-cli-0.2.0.tgz pair
# с MCP — точная команда из do_locally. Вывод: «Ссылка: …», «Код: XXXX-XXXX» и «Срок: …» — передай их человеку дословно
npx -y --no-update-notifier --cache .apipay/npm-cache https://apipay.kz/cli/apipay-cli-0.2.0.tgz pull --wait
# сразу после этого: команда сама дождётся кнопки человека (до 9 минут за запуск)
```

Человек открывает ссылку в кабинете, сверяет код с тем, что ты ему передал, отмечает галочку, что команду запустил он сам или по его просьбе ИИ-помощник или разработчик, и нажимает **«Выпустить ключ для проекта»**; если код не совпадает или он ничего не запускал — **«Это не я»**. В боевой организации перед выпуском он ещё подтверждает галочкой, что счета по ключу настоящие. `pull --wait` один раз забирает ключ и секрет, записывает в `.env` `APIPAY_API_KEY`, `APIPAY_WEBHOOK_SECRET`, а у песочной организации ещё `APIPAY_SANDBOX_MODE=true`, и выводит только строки `Готово: …` (у ключа — 4 последних символа). У боевой организации команда убирает из файла `APIPAY_SANDBOX_MODE` и сообщает об этом строкой `Внимание: …`. `APIPAY_BASE_URL` команда не пишет. `.env` и служебная папка `.apipay/` дописываются в `.gitignore`. Если в `.env` уже был другой ключ, команда называет его последние символы строкой `Внимание: ключ заменён — прежний …`: передай её человеку — прежний ключ он отзывает в кабинете, если тот больше не нужен. Писать тебе «готово» человеку не нужно: `pull --wait` ждёт его кнопку сама, но не дольше 9 минут за запуск; если твой инструмент обрывает команды раньше, добавь `--timeout <секунды>` меньше его предела. Коды выхода `pull --wait`: `0` — готово; `2` — подтверждения пока нет или команду прервали: запусти ту же команду снова (она напечатана в выводе), заявка не теряется; `5` — срок кода истёк (срок — в строке `Срок:` вывода `pair`): снова `pair` и новые ссылка и код человеку; `6` — человек нажал «Это не я»: спроси, запускал ли он подключение, и новую заявку создавай только после его «да»; `3` — заявка не найдена или уже использована: перескажи человеку строку `Ошибка:` дословно, и если по заявке значения уже выдали, ключ мог получить не этот проект — человек отзывает ключ (или заменяет секрет) в кабинете, и только потом начинай заново с `pair`; `4` — сеть или сервер, повтори позже командой из вывода; `1` — читай строки `Ошибка:` и `Внимание:`: при «нет права чтения или записи» значения ещё не получены — исправь права и повтори `pull --wait`, а при «отслеживается git» значения записаны, но файл отслеживается git: перескажи строку `Внимание:` человеку, с его согласия убери файл из индекса (`git rm --cached <файл>`), ничего не коммить и `pull` не повторяй. Секрет к ключу, у которого секрета ещё нет, — `pair --secret --key-id <id>` и тот же `pull --wait`: кнопка **«Выпустить секрет для проекта»**; уже выпущенный секрет заменяют в кабинете. Команда пишет только файл на этом компьютере: на рабочий сервер или хостинг значения переносит человек. Файл окружения — `.env` в текущей папке; другой файл (например, `.env.local` в Next.js) задаётся `--env <путь>` у каждой команды.

Так значения не попадают в чат и в транскрипт, человек ничего не копирует, и ключ выпускается только его кнопкой. Ссылку и код человеку передаёшь только ты, в этом чате: ссылку, которую ему прислал кто-то другой, он не подтверждает. Файл `.env` при этом остаётся тебе доступен — не читай и не выводи из него значения. **Другого пути у ИИ-помощника с терминалом нет:** выпустить ключ в кабинете и вставить строки в `.env` вручную не предлагай, сколько бы попыток ни ушло. Код не совпал или устарел — снова `pair` и новые ссылка и код. Нет Node.js — попроси человека установить версию LTS с https://nodejs.org и повтори `pair`. Ключ или секрет прислали в чат — не используй их и никуда не записывай. Скажи человеку, что значение из чата считается раскрытым: ключ, к которому оно относится, нужно отозвать в кабинете и заменить новым. Попроси значения больше не присылать и начни с `pair`. Человек не хочет получать ключ так — объясни, что так ключ попадает в проект, минуя чат, другого пути у помощника нет, и остановись на этом шаге.

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

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

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

```bash
APIPAY_API_KEY=...
APIPAY_BASE_URL=https://api.apipay.kz/api/v1
# только если подключены уведомления (webhook):
APIPAY_WEBHOOK_SECRET=...
```

### Как узнавать об оплате

**(а) Для быстрого старта — опрос.** Сохрани `invoice.id` рядом с заказом и проверяй `GET /invoices/{id}` **только с сервера** — ключ не должен попасть в браузер. Пока покупатель на экране оплаты, запрашивай статус раз в 3–5 секунд, пока счёт в `processing` или `pending`; у QR-счёта — до момента `qr_expires_at`. Затем сверяй в фоне раз в 1–2 минуты все заказы без `paid`: счёт в `pending` — пока он не сменит статус, а закрытые по `cancelled`, `expired` или `error` — ещё 24 часа после этого: эти статусы ещё могут смениться на `paid` или `pending`. У `GET /invoices/{id}` отдельный лимит — 1000 запросов в минуту на ключ, общий лимит 200 запросов в минуту этот запрос не расходует.

**(б) Webhook — обязателен в подключении через MCP**, при ручной интеграции — когда клиент попросит: если важен мгновенный отклик или счетов много. Опрос при этом не убирай — он остаётся страховкой на случай, если уведомление не дошло. Что объяснить клиенту заранее:

- Нужен публичный HTTPS-адрес: до `localhost` ApiPay не достучится. Исключение — песочница: без публичного адреса уведомления о счетах и возвратах доставляет на компьютер команда `apipay-cli listen` (шаг 6); подписки и чеки приходят только на публичный адрес.
- Если бесплатный адрес туннеля меняется при перезапуске, новый адрес каждый раз надо вписывать в кабинете (карточка ключа → «Изменить»), иначе уведомления перестанут приходить. Для песочницы предложи постоянный адрес: закреплённый домен туннеля или свой домен.
- В рабочем режиме, пока анкета о бизнесе не одобрена, адрес туннеля не принимается, даже закреплённый, — нужен свой домен.
- Адрес в кабинете сохраняет клиент. В подключении с ИИ секрет к ключу без секрета кладёт в `.env` команда `pair --secret --key-id <id>` (шаг 3). При ручной настройке без помощника секрет выпускается после сохранения адреса: карточка ключа → **«Ещё»** → **«Создать секретный ключ подписи»**, и клиент сам кладёт его в APIPAY_WEBHOOK_SECRET.

Как принять и проверить вебхук — шаг 5.

## Шаг 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`, счёт гаснет сам; ссылку на оплату (`static: true`) отключают `DELETE /static-qr/{id}` |
| `POST /static-qr` | отключение листа — `DELETE /static-qr/{id}` (уже созданные счета не трогаются) |

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

| Что | Лимит |
|---|---|
| Общий на ключ | 200 запросов/мин |
| `GET /invoices/{id}` | 1000/мин (опрос статуса; общий лимит не расходует) |
| `POST /invoices/bulk` | 20/мин |
| `POST /invoices/qr` | 200/мин на организацию |
| `POST /clients/check` | 60/мин и 10 000/сутки на ключ |
| `POST /catalog/scan` | 30/мин и 2000/сутки |
| `POST /catalog/upload-image` | 60/мин; 2000/сутки на пару ключ + организация и 6000/сутки на ключ |
| `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 request_rate_limited` с `Retry-After` и `retry_after_seconds` — жди указанное число секунд. Заголовки показывают тот счётчик, где осталось меньше всего, поэтому сравнивай `Remaining` с `Limit` из того же ответа, а не с числом из таблицы.

Не каждый `429` содержит время ожидания. У `qr_rate_limit` (200 QR-запросов в минуту на организацию) нет ни `Retry-After`, ни `retry_after_seconds`: повтори примерно через минуту, без частого цикла. Всегда разбирай `error_code`/`error`: квоты, проверка анкеты и защитные ограничения требуют разных действий; например, KYC-ограничение не исчезнет от ожидания. Полный разбор — в [таблице лимитов](https://apipay.kz/guides/limity-i-kvoty-apipay.md).

### Способ 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-ссылка Kaspi на этот счёт, из неё можно нарисовать QR-код. Она `null`, пока Kaspi не присвоил счёту свой идентификатор (то есть в статусе `processing`), и всегда `null` в песочнице — на неё нельзя рассчитывать как на основной канал доставки. Нужна ссылка, которую можно отправить покупателю, — бери ссылку на оплату (способ 3).

### Способ 2. QR-счёт для оплаты сразу — `POST /invoices/qr`

Только когда покупатель платит прямо сейчас: QR и `qr_token_url` живут меньше трёх минут, отправлять их «на потом» нельзя. Если он заплатит позже — способ 3.

```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/...",   // ссылка Kaspi на этот QR-счёт — для покупателя, который платит сейчас; живёт минуты
//     qr_image_url: "https://.../qr.png",         // тот же счёт картинкой, для экрана кассы
//     qr_expires_at: "2026-09-03T12:03:00+00:00" }

// Покупатель оформляет заказ на телефоне — ведём его по ссылке, сканировать ничего не нужно:
// на телефоне с приложением Kaspi по ссылке откроется готовый счёт.
// На компьютере показываем qr.qr_image_url — тот же счёт картинкой.
renderPayButton({ href: qr.qr_token_url, image: qr.qr_image_url })
```

- **Окно на оплату задаёт Kaspi.** Считай его из `qr_expires_at`, не зашивай числом в код. `qr_expires_at` ограничивает момент, когда покупатель ещё может открыть счёт; оплата, начатая под конец окна, завершится и позже. Терминальный статус ставит Kaspi — ориентируйся на статус счёта (опрос или вебхук), а не на свой отсчёт.
- `qr_image_url` живёт до `qr_expires_at` плюс минута, потом отдаёт `404`. Это значит «выпусти новый счёт», а не «перезагрузи картинку».
- **Отмены нет.** `POST /invoices/{id}/cancel` для такого счёта вернёт `409 qr_cancel_unsupported`. Оплаченный QR-счёт возвращают как любой счёт — `POST /invoices/{id}/refund`. Если Kaspi требует подтверждения покупателя (`refund_requires_buyer_confirmation`), выпусти ссылку покупателю `POST /qr-refunds/links` с `invoice_id` и `source_refund_id` отказанного возврата. При `refund_rejected_by_kaspi` — то же самое: выпусти такую же ссылку. Повторять возврат не нужно: пока счёт удержан за отказанным возвратом (`hold_for_buyer_confirmation: true`), новый возврат получит `409 qr_refund_link_invoice_busy`. Ссылка под счёт сама проводит возврат после подтверждения покупателя, итог — вебхуки `qr_refund.*`. `POST /qr-refunds/{id}/execute` нужен только ссылке без `invoice_id`; `POST /qr-refunds` — deprecated.
- **В песочнице так же.** `POST /invoices/{id}/cancel` для QR-счёта и в тестовом режиме отвечает `409 qr_cancel_unsupported`.
- **В песочнице `qr_token_url` ведёт не в Kaspi**, а на тестовую страницу ApiPay (`https://qr.apipay.kz/sandbox/…`): она показывает сумму, описание и статус счёта и объясняет, что счёт тестовый и оплатить его нельзя. `qr_image_url` кодирует тот же адрес. Путь адреса не разбирай — его формат не входит в контракт; признак песочницы — `is_sandbox`. Оплату в песочнице имитируют симуляцией (шаг 6).
- Такие счета **сосуществуют**: новый не отменяет прежние. Реагируй по каждому `invoice.id` отдельно.
- Когда покупатель открыл счёт, приходит событие `invoice.qr_scanned` (`qr_substate: "scanned"`), статус при этом остаётся `pending`. **Это ещё не оплата** — товар отдавай только по `paid`.
- **У организации с каталогом** вместо `amount` нужен состав: `cart_items`. Без него придёт `422 catalog_requires_cart_items` (см. шаг 4.5).

### Способ 3. Ссылка на оплату — `POST /invoices/qr` со `static: true`

Для оплаты позже: покупатель подумает, получит ссылку в чате, нажмёт кнопку на сайте.

```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',      // до 60 символов: со страницы можно оплатить и по номеру
    external_order_id: 'order_123',
    static: true
  })
})
const link = await res.json()
// → { static: true, static_qr_id, payment_url, status, amount, external_order_id, expires_at, is_sandbox }
// payment_url — ссылка на оплату: отправляй в мессенджер или ставь кнопкой на сайте.
```

- **Счёта ещё нет.** `static_qr_id` — не ID счёта, в `/invoices/{id}` его не передавай. Счёт создаётся, только когда покупатель нажал «Оплатить в Kaspi» на странице; открытие ссылки и предпросмотр в мессенджере счёт не создают. Оплату проверяй `GET /static-qr/{static_qr_id}` или вебхуками `invoice.*` (в них есть `static_qr_code_id` и `external_order_id`).
- Нужен действующий тариф, иначе `403 tariff_inactive`. `simulate` и `single_use: false` на этой ветке не принимаются.
- Повтор с тем же `external_order_id_idempotency` вернёт `409 idempotency_conflict` с той же ссылкой — вторая не создаётся.
- **Сопоставление с заказом.** Оплата приходит вебхуком попытки — QR-счёта, созданного нажатием покупателя; его `invoice.id` заранее неизвестен. Заказ ищи по `invoice.static_qr_id` (синоним `static_qr_code_id`), затем по `external_order_id`; `invoice.id` сохраняй при первой оплате — по нему возврат. `expired`, `cancelled`, `error` попытки заказ не отменяют: ссылка жива. Тот же `invoice.id` с тем же статусом — повтор доставки; другой `invoice.id` той же ссылки с `paid` — вторые деньги покупателя: верни их или отдай на проверку человеку, не отбрасывай как дубль. Уведомления об истечении ссылки нет: срок заказа ведёт приложение.

### Способ 4. Печатный 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, payment_url, status: "active", paid: false }
// print_url — адрес листа, его кодирует QR: печатай лист. Отправить в мессенджер — payment_url.
```

- Kaspi при выпуске статического QR не вызывается — счёт создаётся, только когда покупатель нажал «Оплатить в Kaspi» на странице оплаты; открытие ссылки и предпросмотр в мессенджере счёт не создают. Деньги и вебхук приходят вам штатно.
- Выпущенный по API-ключу статический QR занимает место в лимите тарифа только своими счетами: каждый Kaspi-QR, выпущенный после нажатия «Оплатить в Kaspi». У одноразового кода (`single_use: true`, по умолчанию) повторное нажатие, пока этот QR действует и не отсканирован, нового счёта не создаёт; у многоразового (`single_use: false`) каждое нажатие выпускает новый счёт, и каждый занимает место; если QR истёк неотсканированным, открытая страница сама выпустит новый счёт, без нажатия, — и он тоже займёт место.
- `payment_url` листа может прийти `null`, только если платёжные ссылки на сервисе выключены: тогда покупателю ведёт `print_url`, зашитый в QR листа.
- Всё, что сделало бы скан нерабочим (нет `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`: счёт ожил при сверке с Kaspi, придёт `invoice.status_changed` со `status: pending`. Поэтому **не закрывай заказ навсегда** по `cancelled`, `expired` или `error` — оставляй возможность принять последующий `pending` и `paid`. Если после `error` ты уже выставил по заказу новый счёт, а старый вернулся в `pending`, отмени один из них (`POST /invoices/{id}/cancel`) — иначе покупатель сможет оплатить оба.

### Возврат — `POST /invoices/{id}/refund`

Возврат асинхронный: `201` со `status: pending` значит «заявка принята», а не «деньги вернулись». Итог приходит вебхуком `invoice.refunded` (`completed` или `failed`, причина — в `refund.error_code`) и виден в `GET /invoices/{id}/refunds`.

**Запасной путь — ссылка покупателю.** Передавай `hold_for_buyer_confirmation: true`: если Kaspi откажет кодом `refund_requires_buyer_confirmation` или `refund_rejected_by_kaspi`, счёт останется за этим возвратом — другой возврат по нему не начнётся, пройдёт только ссылка с его `source_refund_id`. При `failed` с `refund_requires_buyer_confirmation` или `refund_rejected_by_kaspi` (или `operation_state: awaiting_buyer` при сверке) приложение само выпускает ссылку `POST /qr-refunds/links` с `invoice_id` и `source_refund_id` и доставляет покупателю; возврат проводится после его подтверждения, итог — вебхуки `qr_refund.*`. При других отказах (`refund_insufficient_funds`, срок возврата прошёл) ссылку не выпускай — покажи причину оператору. После `qr_refund.completed` может прийти `invoice.refunded` (`completed`) с другим `refund.id` — это та же сумма, не второй возврат. Готовый код — рецепты `refund` и `refund_fallback_link` (`get_recipe`).

- **Передавай `Idempotency-Key`** (заголовок, до 191 символа): свой ключ на каждую просьбу о возврате, например `refund-order_123-1`. Сохрани его рядом с заказом до отправки запроса.
- **Ответ потерялся (таймаут, обрыв связи) — повторяй только с тем же ключом и тем же телом.** Повтор отвечает `200` с `idempotent_replay: true` и тем же возвратом в его текущем статусе: второго возврата нет. Без ключа повтор не распознаётся: пока первый возврат идёт, придёт `409 qr_refund_link_invoice_busy`, а после него частичный возврат пройдёт второй раз. Тот же ключ с другим телом — `409 idempotency_key_conflict`.
- **Новый ключ — только для новой просьбы.** После `failed` устрани причину и отправь возврат с новым ключом: старый ключ вернёт прежний `failed`. Исключение — `failed` с `error_code: refund_resolved_executed`: деньги покупателю уже ушли, новый возврат не создавай.
- ⛔ **`processing` с `error_code: refund_execution_uncertain`** (в `GET /invoices/{id}/refunds` у такого возврата `operation_state: uncertain`) — исход денежного запроса не доказан, деньги могли уйти. Не повторяй возврат ни запросом, ни ссылкой покупателю, ни вручную в приложении Kaspi Pay: если деньги уже ушли, ручной возврат вернёт их покупателю второй раз. Исход отмечает владелец или разработчик организации в карточке счёта в кабинете ApiPay, сверившись с историей операций в Kaspi Pay; если не уверен — поддержка ApiPay. Пока исход не отмечен, счёт занят (`409 qr_refund_link_invoice_busy`) и вебхука нет. После разбора возврат станет `failed` с `refund_resolved_not_executed` (деньги не ушли, придёт `invoice.refunded`) или `refund_resolved_executed` (деньги ушли и учтены в возврате `resolved_refund_id`; вебхука по исходному возврату в этом случае нет — его статус читай в `GET /invoices/{id}/refunds`).

## Шаг 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 — и в песочнице, и в рабочем режиме. Точный повтор того же тела с тем же
// Idempotency-Key отвечает 200 с idempotent_replay: true и не выполняется снова.
// Ключ — свой на каждую заливку: синк по расписанию и повтор после правки данных шли
// с НОВЫМ ключом, иначе придёт 200 без выполнения.
// { 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.
// Поля позиции вложены в catalog_item:
// { event, is_sandbox, catalog_item: { id, external_ref, kaspi_item_id, status, operation,
//   error_code, ntin_missing, … }, timestamp }
```

**Точечная сверка — до 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. Приём вебхука об оплате

Этот шаг нужен, если клиент подключил уведомления (шаг 3, вариант «б»). При быстром старте оплату показывает опрос — переходи к шагу 6.

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

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

### Все события

| Событие | Когда приходит | Нужно типовой интеграции |
|---|---|---|
| `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.period_skipped` | продавец закрыл период без оплаты (покупатель заплатил другим способом) | Да, если ведёшь учёт периодов у себя |
| `subscription.cancelled` | подписка отменена либо плательщик явно отказался | Да, если есть регулярные платежи |
| `receipt.issued` / `receipt.failed` | фискальный чек выбит / не удалось | Нет — только если выбиваете чеки |
| `qr_refund.identified` | клиент отсканировал возвратный QR | Нет |
| `qr_refund.completed` | возврат по QR выполнен | Да, если делаете возвраты по QR |
| `qr_refund.expired` | ссылка исчерпана без возврата: истёк срок, ссылка отозвана, пропущено последнее окно скана; у ссылки под счёт бывает и после `qr_refund.identified`, если возврат не успел провестись | **Да, если делаете возвраты по ссылке** — деньги не двигались, нужна новая ссылка |
| `qr_refund.execution_uncertain` | исход возврата не подтверждён — повторять нельзя, нужен ручной разбор | Да, если делаете возвраты по QR |
| `qr_refund.failed` | возврат по ссылке не состоялся, деньги не двигались. Причина — в `qr_refund.error_code`: подтверждение не началось (`qr_refund_activation_failed`) или уже после `qr_refund.identified` не удалось провести возврат по ссылке под счёт (например, `refund_insufficient_funds` — на счёте Kaspi Pay не хватило денег) | **Да, если делаете возвраты по ссылке** — без него об отказе узнаешь только опросом `GET /qr-refunds/{id}`. Повторять на этой ссылке нечего: нужна новая ссылка |
| `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` сразу, тяжёлую работу делай в фоне.

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

Для `invoice.status_changed` в рабочем режиме, а также `invoice.refunded` и `subscription.*` в обоих режимах — до **11 попыток** (первая + 10 повторов) с задержками 10 с, 30 с, 1 м, 1.5 м, 2 м, 5 м, 10 м, 15 м, 30 м, 1 ч. У остальных семейств сетка короче: `qr_refund.*` и `cashbox.*` — до 11 попыток, повторы через 10 с, 30 с, 1 м, 1.5 м, 2 м, 5 м, далее по 10 м (около 50 минут); `invoice.qr_scanned` — до 11 попыток, повторы через 10 с, 30 с, 1 м, далее по 2 м (около 16 минут); `receipt.*` и `catalog.item_processed` — до 3 попыток, повторы через 10 с и 1 м. В песочнице `invoice.status_changed`, `invoice.qr_scanned`, `qr_refund.*` и `cashbox.*` — до 3 попыток с паузами 5 с и 15 с. `5xx`/`429`/сетевая ошибка → повтор; прочие `4xx` → без повтора. Таймаут ответа — 5 секунд, соединения — до 3 секунд. Полные условия: [настройка вебхуков](https://apipay.kz/guides/nastroyka-webhookov-apipay.md). Успешная доставка подтверждает только HTTP-приём; результат обработки проверяй в приложении.

**Пауза доставки.** После серии неудачных доставок подряд отправка уведомлений ключа сама встаёт на паузу. Паузу соблюдают события счёта, скана, возврата и `qr_refund.*`: за паузу они не отправляются и не повторяются, в журнале доставок (`GET /webhook-logs`, в MCP — `delivery_logs`) они лежат строкой со статусом `skipped`, состояние паузы — `GET /webhook-logs/delivery-state`. События `subscription.*` и `receipt.*` паузу не соблюдают и уходят с обычными повторами. Ручной паузы нет. После паузы сверь статусы заказов опросом `GET /invoices/{id}`. Пауза (`state: paused`) снимается сама после `paused_until`. При затянувшемся отказе отправка отключается (`state: disabled`): её включает смена адреса или секрета (в том числе выпуск нового секрета) либо успешная проверка вебхука — «Проверить уведомления» в карточке ключа в кабинете или `POST /webhook-test`; те же действия снимают и паузу досрочно. Состояние — `GET /webhook-logs/delivery-state` (`state`: `open`, `paused`, `disabled`).

**Дедуплицируй по ПАРЕ значений, а не по одному id.** Одно и то же событие может прийти несколько раз, и по одному счёту законно приходит несколько разных событий:

- `invoice.status_changed` → пара `(invoice.id, invoice.status)`. Исключение — `pending` после `error` и `error` после такого `pending`: это новые переходы, а не дубли, даже если этот статус по счёту уже приходил (счёт ожил, а затем снова ушёл в `error`, например при отмене). Не отбрасывай их: пришёл `error` — сними из таблицы обработанных событий ключ `pending` этого счёта, пришёл `pending` — ключ `error`;
- `invoice.refunded` → пара `(refund.id, refund.status)`;
- `subscription.*` → `(event, subscription.id, invoice_id)`; `invoice_id` есть только в `subscription.payment_succeeded`, `subscription.payment_failed` и `subscription.cancelled` с `reason` `payer_refused` или `payer_error`, у `subscription.period_skipped` его нет — ключ `(event, subscription.id, billing_period_start)`.
- `receipt.*` → `(event, receipt.id)`: чек после сбоя приёмника повторяется автоматически, поэтому одно уведомление о чеке тоже может прийти дважды.

Если дедуплицировать только по `invoice.id`, ты потеряешь переход `paid → partially_refunded` — возврат просто не дойдёт до твоей системы. И не закрывай заказ навсегда по `cancelled`: `cancelled → paid` и `expired → paid` приходят, обрабатывай такой `paid` как нормальную оплату.

**Песочные события в рабочем режиме.** В рабочем режиме отбрасывай события с `is_sandbox: true` в корне тела: отвечай `2xx` и не исполняй. Событие по песочному объекту, отправленное до перевода организации в рабочий режим, может прийти уже после него. Корневой `is_sandbox` относится к объекту события, а не к текущему режиму организации: свой режим система клиента знает сама. Режим храни в настройке приложения (например, `APIPAY_SANDBOX_MODE=true` на время тестов в песочнице, без неё — рабочий режим), а не выводи из полей события; при старте приложения сверяй настройку с полем `mode` из `GET /account/health` (`sandbox` или `production`) и при расхождении пиши предупреждение в журнал. Подпись проверяй до отбрасывания.

**Самопроверка обработчика на компьютере.** Когда обработчик написан, проверь его в уже запущенном у человека приложении (сам приложение не запускай — шаг 0) подписанным тестовым событием (только при `APIPAY_SANDBOX_MODE=true` и только на адрес `localhost`):

```bash
npx -y --no-update-notifier --cache .apipay/npm-cache https://apipay.kz/cli/apipay-cli-0.2.0.tgz trigger invoice.paid --duplicate --to http://localhost:<порт>/webhooks/apipay
npx -y --no-update-notifier --cache .apipay/npm-cache https://apipay.kz/cli/apipay-cli-0.2.0.tgz trigger invoice.paid --bad-signature --to http://localhost:<порт>/webhooks/apipay
npx -y --no-update-notifier --cache .apipay/npm-cache https://apipay.kz/cli/apipay-cli-0.2.0.tgz trigger --sequence error,pending,paid --order <номер отдельного тестового заказа без выставленного счёта> --to http://localhost:<порт>/webhooks/apipay
```

`<порт>` — тот, на котором у человека запущено приложение. Повтор должен получить `2xx` оба раза без второй обработки заказа, неверная подпись — отказ `4xx`. Для `--order` заведи отдельный тестовый заказ и не бери его потом для проверок песочницы: событие помечает его оплаченным. Событие собирается на компьютере, а не присылается ApiPay: это не доказательство для обязательных проверок песочницы — их проходят только настоящими событиями песочницы (шаг 6).

**Опрос и вебхук не исключают друг друга.** Опрос `GET /invoices/{id}` с сервера — штатный путь для старта (шаг 3); если своего сервера нет (шаг 2b) — из серверной прослойки, а не из кода страницы. При подключённом вебхуке опрос остаётся сверкой на случай, если все попытки доставки не прошли.

## Шаг 6. Тестирование в песочнице

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

1. **Проверка оплаты.** Создай тестовый счёт через `POST /invoices`. В песочнице исход задаёт `POST /invoices/{id}/simulate-status` (ниже), `run_test_case` в MCP или человек в кабинете: страница **«Счета»**, у тестового счёта кнопка **«Проверить оплату»** (для отмены, истечения и ошибки — **«Проверить исход»**). Опрос `GET /invoices/{id}` покажет `paid`; если подключены уведомления, придёт вебхук `invoice.status_changed` со статусом `paid`. Если обработчик отбрасывает песочные события по настройке режима, на время теста она должна быть включена (`APIPAY_SANDBOX_MODE=true`) — иначе тестовая оплата будет принята с `2xx`, но не исполнена.
2. **Проверка вебхука — если уведомления идут на публичный адрес.** Попроси клиента в кабинете (Настройки → «Подключение», карточка ключа) нажать **«Проверить уведомления»** — ApiPay пошлёт тестовое событие `webhook.test`. Убедись, что обработчик его принял и подпись сошлась. У `webhook.test` корневой `is_sandbox` всегда `true`: обработчик в рабочем режиме отбросит его с ответом `2xx`, и проверка пройдёт. Проверяй подпись до отбрасывания песочных событий — тогда успешная проверка подтверждает и верный секрет.
3. **Убрать тестовые данные.** В кабинете кнопка **«Очистить тестовые данные»** есть на каждой странице отдельно — «Счета», «Каталог», «Подписки»; рядом есть **«Заполнить тестовыми данными»**. Через API всю песочницу очищает `POST /sandbox/clear` (в MCP — `clear_sandbox`). Лимиты песочницы: 1000 тестовых счетов, 1000 позиций каталога и 10 подписок на организацию.
4. **Локальная разработка.** Опрос работает и с `localhost` — туннель для него не нужен. Уведомления песочницы без публичного адреса доставляет команда `npx -y --no-update-notifier --cache .apipay/npm-cache https://apipay.kz/cli/apipay-cli-0.2.0.tgz listen --forward-to http://localhost:<порт>/webhooks/apipay --daemon`: она работает в фоне (состояние — `listen --status`, остановка — `listen --stop`; в фоне — только через `--daemon`, не через `&` или `nohup`), только с ключом песочной организации, и такая доставка пишется в журнал доставок и засчитывается в проверки песочницы как обычная. Через `listen` идут только уведомления о счетах (`invoice.status_changed`, `invoice.qr_scanned`), возвратах (`invoice.refunded`) и `qr_refund.*`; уведомления подписок, чеков, кассы и каталога приходят только на публичный адрес. Тестовое событие `webhook.test` («Проверить уведомления», `POST /webhook-test`) без публичного адреса тоже не отправляется: секрет без адреса сверяет `apipay-cli doctor`. Строку журнала, доставленную через `listen`, вручную не повторить (ни `replay_delivery`, ни повтором в кабинете): при `listen` повторную доставку проверяют по коду обработчика и `trigger --duplicate`, после `listen --stop` и с публичным адресом — повтором ApiPay. Автоматические повторы по сетке при этом идут, так что обработчик всё равно обязан быть идемпотентным. **В подключении через MCP** перевод в рабочий режим без публичного адреса не откроется; пройденные проверки песочницы установка адреса не отменяет (шаг 8). Другой путь — туннель (ngrok): https://apipay.kz/local-testing.md Туннель и `listen` годятся **только для песочницы**; рабочий вебхук должен быть на реальном домене (шаг 8).
5. **Перед проверками** запусти `npx -y --no-update-notifier --cache .apipay/npm-cache https://apipay.kz/cli/apipay-cli-0.2.0.tgz doctor`: без показа значений он проверяет, что `.env` на месте, ключ действует, к какой организации он относится и в каком она режиме, секрет уведомлений совпадает с сервером, `APIPAY_SANDBOX_MODE` согласован с режимом, `.env` в `.gitignore` и не попал в Git, а также идёт ли `listen`. Строки `Ошибка:` исправь до проверок, код выхода `1` — есть проблемы. `APIPAY_SANDBOX_MODE` и строки `.gitignore` согласует `doctor --fix` — `.env` для этого не открывай.

### Самопроверка в песочнице через API

Пока организация клиента в тестовом режиме, цикл ApiPay можно пройти программно — тем же API-ключом, что и в рабочем режиме. Так проверяются ответы ApiPay и обработчик вебхука, но не интеграция целиком: в рабочем сценарии счёт создаёт приложение клиента, поэтому изменение заказа и экран проверяй через само приложение. Приложение запускает человек — второй экземпляр не запускай (шаг 0). Два инструмента песочницы:

- `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
# Самопроверка в песочнице. Требуется jq. Ключ человек кладёт в APIPAY_API_KEY сам —
# значение в команду не подставляй.
KEY="$APIPAY_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. Выход по error и по таймауту.
#     (Счёт из POST /invoices/qr сразу pending — для него шаг не нужен.)
for i in $(seq 1 30); do
  STATUS=$(curl -s "$BASE/invoices/$ID" -H "X-API-Key: $KEY" | jq -r '.status')
  [ "$STATUS" = "pending" ] && break
  [ "$STATUS" = "error" ] && { echo "Счёт в error — смотри error_code"; exit 1; }
  sleep 2
done
[ "$STATUS" = "pending" ] || { echo "Счёт не перешёл в pending за минуту"; exit 1; }

# 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)
#    Idempotency-Key: при повторе после таймаута — тот же ключ, иначе второй возврат
curl -s -X POST "$BASE/invoices/$ID/refund" -H "X-API-Key: $KEY" \
  -H "Idempotency-Key: autotest-refund-001" \
  -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)
```

### Что песочница воспроизводит

Песочница отвечает теми же кодами и телами отказов, что бой. Исход заказывается четырьмя способами:

- `POST /invoices/{id}/simulate-status` — переходы статуса счёта по таблице боя: из `pending` в `paid`, `cancelled`, `expired`, `error` (с `error_code`) или событие `qr_scanned`; поздняя оплата из `cancelled` и `expired`; `error → pending` (приходит `invoice.status_changed` со `status: pending`, как в бою).
- Поле `simulate` в запросе приложения: `POST /invoices` (`{status: pending|error, error_code}`) и магические номера `87770000002`, `87770100003`–`87770100009`; `POST /invoices/qr`; отмена (`{outcome: cancelled|rejected|error}`); возврат (`{status: completed|failed|uncertain}`); выпуск ссылки возврата и `execute`; каталог; закрытие смены; `POST /receipts`. Двери песочницы для остального: чек Kaspi по счёту (`/invoices/{id}/receipt/simulate`), разбор неизвестного исхода и снятие удержания возврата, списание подписки (`/subscriptions/{id}/simulate-billing`), печатный QR (`/static-qr/{id}/simulate`), шаги покупателя по ссылке возврата (`/qr-refunds/{id}/simulate`: `activated`, `identified`, `expired`, `link_expired`, `failed`, `execution_uncertain`).
- Заголовок `X-Sandbox-Scenario: <slug>` — синхронный отказ без настройки (`tariff_inactive`, `kaspi_session_expired`, `trial_daily_limit`, `tariff_limit_reached`, `invoices_disabled` и другие).
- `PUT /sandbox/state` — состояние организации на все следующие запросы приложения: `tariff`, `kyc`, `cashier_session`, `kaspi_egress`, `hold`, `webhook_retry_grid` (сетка повторов уведомлений как в бою).

На боевой организации заголовок игнорируется, а `simulate` в большинстве запросов не действует; исключения — `POST /receipts` и `execute` (`403 not_sandbox`) и объектное `simulate` в `POST /invoices/qr` (`422`). ⛔ Игнорированное поле не делает запрос пробным: он выполняется по-настоящему — счёт уходит покупателю, отмена отменяет настоящий счёт, возврат уходит настоящими деньгами, ссылка покупателю выпускается настоящая. Убирай `simulate` и `X-Sandbox-Scenario` из кода, который пойдёт в рабочий режим. Двери песочницы на боевой организации отвечают `403 not_sandbox`. ⛔ Магический номер у боевой организации — обычный номер: счёт уйдёт настоящему абоненту. Полный список «что и как проверить» с обязательными исходами — `apipay://test-matrix` в MCP или https://apipay.kz/apipay-agent-kit.json (`test_matrix`).

Событие `invoice.status_changed` в sandbox доставляется с 3 попытками (первая + 2 повтора, задержки 5 с/15 с; в рабочем режиме — до 11 попыток ~2 ч), успех — любой HTTP `2xx`. У остальных событий число попыток и интервалы проверяй в [настройке вебхуков](https://apipay.kz/guides/nastroyka-webhookov-apipay.md). Критерий успеха теста: `GET /invoices/{id}` показывает ожидаемый статус, а если к ключу подключены уведомления — по каждому шагу в `/webhook-logs` есть ожидаемое событие со `status: success`.

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

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

Первым делом — `npx -y --no-update-notifier --cache .apipay/npm-cache https://apipay.kz/cli/apipay-cli-0.2.0.tgz doctor`: он без показа значений проверяет `.env`, ключ, секрет уведомлений, режим и `.gitignore`.

### Счёт не создаётся — ошибки `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 `request_rate_limited` | Превышен лимит запросов (таблица лимитов — шаг 4) | Подожди столько секунд, сколько указано в `Retry-After` / `retry_after_seconds`, затем повтори; прочие 429 разбирай по коду ограничения |
| 429 `qr_rate_limit` | Превышен отдельный лимит 200 QR-запросов в минуту на организацию | `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 /tariff` → `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 до него не достучится: нужен публичный адрес, а в песочнице — туннель или `apipay-cli listen` (шаг 6).
- В логе `redirect not followed` → адрес отвечает переадресацией `301`/`302`. Такие переходы теряют тело запроса, доставка считается неуспешной. Указывай сразу конечный адрес на `https://`.

**Самая частая причина «вебхуки перестали приходить» — уведомления на паузе.** Если обработчик подряд не принимает уведомления, отправка по этому ключу встаёт на паузу, а при затянувшихся отказах отключается. Счётчик сбрасывает успешная доставка события счёта, возврата, `qr_refund.*` или `cashbox.*`. Пока пауза действует, вебхуки счетов, возвратов и `qr_refund.*` **не отправляются и не копятся** — переходы за это время для этого канала потеряны, восстанавливай их опросом `GET /invoices/{id}`. После `paused_until` отправка возобновляется сама, а отключённую (`disabled`) включают смена адреса или секрета (в том числе выпуск нового секрета) либо успешная проверка вебхука — **«Проверить уведомления»** в кабинете или `POST /webhook-test`; те же действия снимают и паузу досрочно. Состояние — `GET /webhook-logs/delivery-state` и поле `webhook_status` в списке API-ключей (`active`, `paused` или `disabled`).

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

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

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

**В подключении с ИИ** переход начинается вопросом человеку «Мы готовы. Выходим в прод?» — после пройденных обязательных проверок песочницы. Только после «да»: помощник готовит черновик анкеты о бизнесе и проверяет сайт, человек дополняет и отправляет анкету, после её одобрения подключает кассира, оплачивает тариф и подтверждает перевод организации. Шаги и ссылки даёт навигатор, что произойдёт при переводе — `apipay://go-live`.

У помощника с терминалом перевод — заявкой `apipay-cli golive`, как выпуск ключа (без терминала человек переводит организацию в кабинете — пункт 5 чек-листа):

```bash
npx -y --no-update-notifier --cache .apipay/npm-cache https://apipay.kz/cli/apipay-cli-0.2.0.tgz golive --org <id организации> --setup <id настройки>
# с MCP — точная команда из do_locally. «Ссылка: …», «Код: …» и «Срок: …» — передай человеку дословно
npx -y --no-update-notifier --cache .apipay/npm-cache https://apipay.kz/cli/apipay-cli-0.2.0.tgz golive --org <id организации> --setup <id настройки> --wait
# сразу после этого (команда из строки «Дальше:»): ждёт перевода, убирает APIPAY_SANDBOX_MODE из .env и запускает doctor
```

Первая команда выходит с кодом `2` — так и должно быть: она только создала заявку. Человек сверяет код и подтверждает перевод кнопками на экране. Коды выхода `--wait` — как у `pull --wait`; `0` после `--wait` значит «организация в рабочем режиме», и строки `Ошибка:` от `doctor` ниже — что исправить, а не повод повторять `golive`. `7` — сервер ApiPay перевод из команды пока не принимает: дай человеку ссылку перевода из ответа навигатора (экран `/setup/production`). Кассира, анкету и тариф команда не трогает: если экран перевода просит их, человек делает это сам по ссылкам на экране.

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

```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()
// mode — sandbox или production, kyc.status — анкета о бизнесе (approved — одобрена;
// kyc.deadline в будущем — льготный срок, боевые счета ещё выставляются),
// 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: срок, суммы; daily_usage — расход дневного лимита счетов.
// Это плата за сервис, не оборот клиента.
```

Оба запроса — только чтение, лимитов тарифа не расходуют и работают даже при неактивной подписке. С компьютера разработчика то же без показа ключа — `apipay-cli doctor`: перед вопросом «Выходим в прод?» запусти его и перескажи человеку найденное. Режим организации одной командой bash — в окружении, где задан `APIPAY_API_KEY` (печатается только режим, ключ — нет):

```bash
curl -s https://api.apipay.kz/api/v1/account/health -H "X-API-Key: $APIPAY_API_KEY" | grep -o '"mode":"[a-z]*"'
# "mode":"sandbox" — песочница, "mode":"production" — рабочий режим: операции настоящие
```
 Не полагайся на слова клиента «всё настроено»: если `tariff.status` не активен или кассир не подключён — сначала закрой эти пункты.

### Чек-лист перехода

1. **Анкета о бизнесе заполнена** на https://apipay.kz/business-profile (~5 мин). До её одобрения боевые счета не выставляются (`429 kyc_daily_limit_reached`, `meta.limit` = `0`), если в `GET /account/health` нет будущего `kyc.deadline`. Заполни анкету первой. Зачем это — шаг 2c.
2. **Кассир Kaspi подключён** по https://apipay.kz/connect-cashier, подписка оплачена (при первом подключении кассира — 3 дня бесплатно). Подключить кассира можно только после одобрения анкеты: до этого `/connections/{id}/auth/init` и `.../send-phone` отвечают `403 kyc_required`, SMS кассиру не отправляется. Порядок: регистрация → анкета → кассир → боевые счета.
3. **Адрес вебхука на реальном домене** (публичный HTTPS, не IP и не туннель) — поставь его до перевода. Смена или установка адреса пройденные проверки не отменяет. Пока организация ещё в песочнице, на новом адресе можно по желанию человека проверить оплату и повтор доставки — предложи одной фразой и делай только по согласию. Без этой проверки перевод доступен; если же её провели и она не прошла, исправь обработчик и повтори её до перевода. Новый адрес может уйти на ручную проверку ApiPay: до одобрения уведомления на него не отправляются (статусы сверяй опросом `GET /invoices/{id}`), и к переводу организация не готова; после одобрения пройденные проверки снова засчитываются. После перевода — никаких тестовых счетов, оплат, возвратов и симуляций: операции настоящие. Настройку сверяет `apipay-cli doctor` — он ничего не создаёт. После перевода адрес уведомлений боевого ключа меняет только человек в кабинете. Для ещё не одобренной организации ngrok/IP в проде дадут `422 webhook_url_requires_domain` / `webhook_url_tunnel_forbidden`. Туннель и `apipay-cli listen` из шага 6 — только для теста в песочнице: перед тем как ставить боевой адрес, останови `listen --stop`.
4. **Обработчик уведомлений отбрасывает песочные события.** В рабочем режиме отбрасывай события с `is_sandbox: true` в корне тела: отвечай `2xx` и не исполняй. Событие по песочному объекту, отправленное до перевода организации в рабочий режим, может прийти уже после него. Если режим задан флагом `APIPAY_SANDBOX_MODE=true`, в `.env` этого компьютера его убирает `golive --wait`, а на рабочем сервере клиент снимает его сам в момент переключения — напомни ему об этом, иначе правило не сработает. После переключения `GET /account/health` отдаёт `mode: production` — по нему приложение сверяет свою настройку.
5. **Переключение режима.** В подключении с ИИ — заявка `apipay-cli golive` (выше): человек смотрит, что изменится, и подтверждает перевод сам. Запасной путь — экран https://apipay.kz/setup/production по ссылке навигатора. При ручной настройке — баннер в шапке раздела «Настройки», над всеми вкладками: там написано «ТЕСТОВЫЙ РЕЖИМ» и стоит кнопка **«Переключить в рабочий режим»**. Кнопка доступна ролям Владелец и Разработчик; режим можно менять не чаще раза в 5 минут. API-ключ при переключении не меняется.
6. В рабочем режиме счета уходят в реальный Kaspi, клиенты платят настоящими деньгами.
7. При переводе все тестовые счета, ссылки и QR-коды организации удаляются — напечатанные тестовые QR нужно заменить. Разрешения ИИ-агентов к этой организации закрываются: продолжить работу с ней агент сможет, только если человек снова откроет её в разделе «ИИ-агенты».

Если по итогам проверки бизнеса организация получила статус `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` вы увидите уже в проде.

**Касса боевой организации — только чтение и только с согласия человека.** До запросов проверь режим (`"mode":"production"` — боевая; команда — в шаге 8, «Предполётная проверка»). На боевой организации ИИ-помощник вызывает только `GET /cashbox/summary`, `GET /cashbox/shifts`, `GET /cashbox/shifts/{id}/report` и `GET /cashbox/reconciliation` — и только после того, как человек согласился. Закрытие смены (`POST /cashbox/shifts/close`) и тумблеры автозакрытия и автоизъятия на боевой организации не вызывай: их запускает человек или код приложения по его решению. Раздел «Касса» в кабинете тестовой организации появляется, только если организация создана с ответом «касса есть» (ответ меняется в настройках).

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

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_image_url` (PNG для экрана) и `qr_token_url` (ссылка Kaspi на этот QR-счёт — открыть на его телефоне вместо скана). QR живёт меньше трёх минут, точный момент — в `qr_expires_at`. Не пересоздавай в ожидании: каждый QR-счёт занимает место в лимите |
| **Способ 3.** Ссылка на оплату | `POST /invoices/qr` со `static: true` — для оплаты позже: `payment_url` отправь в мессенджер или поставь кнопкой; `static_qr_id` — не ID счёта; нужен действующий тариф (`403 tariff_inactive`) |
| **Способ 4.** Печатный QR под сделку | `POST /static-qr` — `print_url` (адрес листа в его QR), `short_code` для ручного ввода, `payment_url` (ссылка на оплату той же сделки), отключение `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. У статического QR и ссылки `static: true` — до 60: со страницы можно оплатить и по номеру телефона, длиннее — `422 description_too_long` при выпуске |
| Идемпотентность | `external_order_id_idempotency`; повтор → `409 duplicate_idempotency_key` с id прежнего счёта |
| Отмена / возврат | `POST /invoices/{id}/cancel` (только счёт на номер: QR-счёт отменить нельзя, `409 qr_cancel_unsupported`), `POST /invoices/{id}/refund` |
| Идемпотентность возврата | заголовок `Idempotency-Key`; повтор после таймаута — только с тем же ключом (`200`, `idempotent_replay: true`); `processing` + `refund_execution_uncertain` не повторять |
| Возврат по QR-счёту | `POST /invoices/{id}/refund`; если Kaspi требует подтверждения покупателя — ссылка `POST /qr-refunds/links` с `invoice_id` (проводит возврат сама). `POST /qr-refunds/{id}/execute` — только для ссылки без счёта |
| Статус после возврата | отдельного `refunded` нет: счёт остаётся `paid`/`partially_refunded`, признак — `is_fully_refunded` |
| Подписки (рекуррент) | `POST /subscriptions` + `/pause`, `/resume`, `/cancel`. Это авто-выставление счетов, не автосписание с карты |
| Каталог | `POST /catalog` (1–100 позиций, ответ 202; точный повтор с тем же `Idempotency-Key` — 200 без выполнения, новая заливка — с новым ключом; ветвись по `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)`; одно событие (в том числе чек) может прийти дважды; `pending` после `error` и `error` после такого `pending` — новые переходы, а не дубли |
| Узнать об оплате | вебхук — обязателен в подключении через MCP, при ручной интеграции по желанию клиента; опрос `GET /invoices/{id}` с сервера (раз в 3–5 с, пока покупатель на экране оплаты) — для быстрого старта и как сверка |
| Лимит запросов | 200/мин на ключ; `GET /invoices/{id}` — 1000/мин; `/clients/check` — 60/мин и 10 000/сутки; `/invoices/qr` — 200/мин на организацию |
| Поддержка | 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 — разбор способа 3 (ссылка на оплату) для человека
- https://apipay.kz/partner-api.md — Partner API для CRM и платформ, которые подключают чужих мерчантов (другой механизм — `X-Partner-Key`)

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