# Partner API — публичная документация

Server-to-server API для партнёров-интеграторов (CRM, маркетплейсы, white-label-кассы).
Партнёр онбордит своих мерчантов, авторизует их Kaspi-кассы и выдаёт им платёжный
ключ `X-API-Key` — всё программно, из своей системы. Платёжные операции, webhook-события
и проверка HMAC живут в документации для мерчантов (`/docs`) и здесь не дублируются.

Перед боевым подключением привязку кассира можно прогнать в **песочнице партнёра** —
детерминированно, без единого реального вызова Kaspi и без SMS.

> **Это Partner API — для платформ и CRM, подключающих СВОИХ клиентов.**
> Если вы мерчант и хотите принимать платежи через Kaspi Pay в своём бизнесе — вам нужна
> обычная документация: [apipay.kz/docs](https://apipay.kz/docs) (SPA),
> [apipay.kz/docs.html](https://apipay.kz/docs.html) (статика),
> [apipay.kz/llms.txt](https://apipay.kz/llms.txt) (для ИИ).

---

## 1. Базовые URL

| Поверхность | Базовый URL |
|---|---|
| Partner API (онбординг, выдача X-API-Key) | `https://api.apipay.kz/api/partner` |
| Платёжный API мерчанта (счета, lookup, webhooks) — см. `/docs` | `https://api.apipay.kz/api/v1` |

## 2. Ключи аутентификации

Релевантны API ровно два ключа.

| Ключ / заголовок | Кто владеет | Для чего |
|---|---|---|
| **`X-Partner-Key`** | партнёр | server-to-server: создание организаций мерчантов, авторизация кассира, мониторинг своих организаций, выдача `X-API-Key` мерчанту. Берётся в партнёрском кабинете. |
| **`X-API-Key`** | конкретный мерчант (вы выдаёте ключ через Partner API) | платёжные операции от имени мерчанта (счета, статусы, возвраты, lookup) — описаны в документации для мерчантов [`/docs`](https://apipay.kz/docs), здесь не дублируются. |

`X-Partner-Key` выдаётся в партнёрском кабинете; ротация ключа и переключение
sandbox/production — тоже в кабинете (фронт), не через API.

---

## 3. Partner API (`X-Partner-Key`)

Префикс `/api/partner`. Лимит группы — **120 req/min** на партнёра. Server-to-server:
создавайте организации мерчантов, авторизуйте кассира, мониторьте свои организации и
выдавайте `X-API-Key` мерчанту.

### `POST /api/partner/organizations` — создать организацию мерчанта

Идемпотентно по `external_id` — повторный POST вернёт существующую org. Лимит **10/min**.
Ответ `201` (создана) / `200` (идемпотентный повтор).

**Поля запроса:**

| Поле | Тип | Обязательное | Описание |
|---|---|---|---|
| `has_catalog` | boolean | нет | Создать организацию с каталогом товаров |
| `external_id` | string | нет | Ваш идентификатор клиента в CRM (ключ идемпотентности) |

```bash
curl -X POST https://api.apipay.kz/api/partner/organizations \
  -H 'X-Partner-Key: <X-Partner-Key>' -H 'Content-Type: application/json' \
  -d '{"has_catalog":false,"external_id":"crm-client-42"}'
```
```json
{ "success": true, "organization": {
  "id": 50, "name": "ТОО Example", "idn": "123456789012",
  "status": "verified", "sandbox_mode": true, "has_catalog": false,
  "kaspi_connected": true, "session_mode": "self", "external_id": "crm-client-42",
  "origin": "created", "payment_status": "active", "has_active_payment": true,
  "created_at": "2026-05-16T10:00:00+05:00" } }
```

Опциональное поле `name` (max 255): пусто → автоген `PARTNER_<id>_<ts>`, позже подменяется
именем из Kaspi на `verify-otp`.

### `GET /api/partner/organizations` — список организаций (мониторинг)

Мониторинг своих организаций: список с пагинацией. В карточке есть `origin`.

**Query:** `per_page` (1–100, def 25), `page` (def 1),
`status` (`pending|verified|suspended`), `sandbox_mode` (true/false/1/0),
`is_test` (true/false/1/0), `kyc_status` (`required|submitted|needs_changes|approved|blocked`),
`search` (LIKE-поиск по `name` / `external_id` / `idn`, max 255).
Фильтры применяются **до** пагинации.

`kyc_status` принимает один статус или список через запятую:
`?kyc_status=required,needs_changes` — это клиенты, которым анкету нужно заполнить или
переделать. Неизвестное значение и пустая строка отбиваются `422`, молча они не игнорируются.
`is_test` — не синоним `sandbox_mode`: у боевой организации `sandbox_mode` остаётся `true` до
первой успешной авторизации кассира.

Ключ `data` — алиас `organizations` (back-compat).

```json
{ "success": true, "organizations": ["<card>"], "data": ["<card>"],
  "current_page": 1, "per_page": 25, "total": 42, "last_page": 2 }
```

### `GET /api/partner/organizations/{id}` — карточка организации

Получить карточку конкретной организации. → `{ "success": true, "organization": <card> }`

### `DELETE /api/partner/organizations/{id}` — отвязать организацию

Деактивирует все API-ключи org и делает soft-delete. → `{ "success": true }`

> **Отвязка сразу останавливает интеграцию мерчанта.** Его `X-API-Key` перестаёт работать, а
> возвраты по ранее оплаченным счетам через ApiPay провести уже нельзя — их делают вручную в
> приложении Kaspi Pay. Синхронной ошибки при этом не будет: запрос на возврат принимается, но
> завершается статусом `failed` — придёт вебхук `invoice.refunded` со `status: failed`.
> Завершите нужные возвраты до вызова `DELETE`.

### `POST /api/partner/organizations/{id}/api-key` — выдать `X-API-Key` мерчанту

Создать/перегенерировать ключ мерчанта + webhook. `webhook_url` проходит SSRF-валидацию
(приватные IP → `422`). **Не идемпотентно: повторный вызов перевыпускает ключ.** Прежний
`X-API-Key` перестаёт работать сразу, а `webhook_secret` заменяется новым, если вы не передали
его в теле, — проверка подписи у мерчанта сломается. Не повторяйте вызов по таймауту: факт
перевыпуска виден в ответе (`regenerated: true`). Дальше мерчант работает
этим ключом по документации для мерчантов ([`/docs`](https://apipay.kz/docs)).

На боевой организации `webhook_url` должен быть адресом на домене: IP-адрес или временный
туннель (ngrok и подобные) отклоняются с `422`. В ответе приходит `webhook_review_status` —
пока он не `approved`, события на этот адрес мерчанту не доставляются.

**Поля запроса:**

| Поле | Тип | Обязательное | Описание |
|---|---|---|---|
| `name` | string | нет | Произвольное название ключа |
| `webhook_url` | string | да | URL для webhook-уведомлений мерчанта |
| `webhook_secret` | string | нет | Секрет подписи (генерируется, если не указан) |

> `key` и `webhook_secret` показываются **ОДИН раз** — сохраните их при получении.

```bash
curl -X POST https://api.apipay.kz/api/partner/organizations/501/api-key \
  -H 'X-Partner-Key: <X-Partner-Key>' -H 'Content-Type: application/json' \
  -d '{"name":"CRM key","webhook_url":"https://crm.example.kz/sub/501/webhook"}'
```
```json
{ "success": true, "key": "<X-API-Key, один раз>", "key_id": 900,
  "webhook_url": "https://crm.example.kz/sub/501/webhook",
  "webhook_secret": "whsec_yyyy", "is_org_default": true, "regenerated": false }
```

### Авторизация кассира Kaspi (3 шага)

Префикс `/api/partner/organizations/{id}/kaspi-auth`. `process_id` живёт 10 минут — шаги
`send-phone` и `verify-otp` нужно выполнить в этом окне. Боевая org доступна только
production-партнёру (иначе `403 production_access_required`); тестовая org всегда идёт
мок-путём.

**Шаг 1 — `POST .../kaspi-auth/init`** · Body `{}` (опц. `{ "force": true }`)
```json
{ "success": true, "process_id": "SANDBOX-<uuid>", "process_status": "phone_required" }
```
`force: true` — переавторизация поверх активной сессии (смена кассира). Без `force` по уже
подключённой org → `409 already_connected`.

**Шаг 2 — `POST .../kaspi-auth/send-phone`** · Body `{ "cashier_phone": "7XXXXXXXXXX" }`
Kaspi отправляет SMS на номер кассира. Успех → `{ "success": true, "process_status": "otp_required" }`.
Ошибки: `invalid_phone` (422 — неверный формат), `not_cashier` (422 — номер не кассир Kaspi),
`not_registered` (422 — номер не зарегистрирован кассиром в Kaspi),
`no_process` (409 — нет активного процесса: вызовите init / он истёк),
`context_expired` (409 — Kaspi-контекст протух, ~10 мин: вызовите init заново и повторите send-phone),
`sms_failed` (502), `kaspi_busy` (503 — Kaspi временно недоступен; повторите примерно через минуту,
окна в ответе нет),
`cashier_unavailable` (409 — кассира сейчас нельзя подключить; причина не раскрывается, повтор
не поможет — направьте мерчанта в поддержку ApiPay),
`rate_limited` (429 — за сутки с аккаунта пробовали слишком много разных номеров кассиров; окно
суточное, выждите время из `Retry-After` / `retry_after_seconds`. Уже подключённые кассиры этого
владельца в счётчик не входят, переавторизация рабочей точки лимитом не блокируется).
В sandbox `not_cashier`, `sms_failed`, `not_registered`, `context_expired` и `kaspi_busy`
эмулируются магическими номерами (см. раздел Sandbox); `cashier_unavailable` и `rate_limited`
в песочнице не воспроизводятся: тестовая организация идёт мок-путём. Заложите обработку заранее —
на `409` нейтральное сообщение мерчанту и отправка в поддержку, на `429` пауза по `Retry-After`.

**Шаг 3 — `POST .../kaspi-auth/verify-otp`** · Body `{ "otp": "0000" }` (4–6 цифр)
Подтвердить код из SMS. Успех → org `status: verified`, `process_status: active`. Неверный код →
HTTP 200 `{ "success": false, "error": "invalid_otp", "process_status": "otp_required" }`
(повторяемо, сессия жива). Ещё возможен `cashier_unavailable` (409 — кассира сейчас нельзя
подключить; причина не раскрывается, повтор не поможет — направьте мерчанта в поддержку ApiPay). В sandbox код `0000` = успех, любой другой = `invalid_otp`.
```json
{ "success": true, "mode": "self", "organization": "<card status: verified>", "process_status": "active" }
```

**`GET .../kaspi-auth/status`** — текущий статус авторизации кассира.
```json
{ "success": true, "status": "active", "process_status": "active",
  "kaspi_connected": true, "expires_at": "2026-06-16T00:00:00+05:00" }
```
`status`: `none` | `pending` | `active` | `expired`.
`process_status`: `idle` | `phone_required` | `otp_required` | `active` | `failed` (выводится из persisted-сессии).

---

## 4. Карточка организации `<card>`

| Поле | Тип | Описание |
|---|---|---|
| `id` | number | ID организации |
| `name` | string | Название |
| `idn` | string | БИН/ИИН |
| `status` | string | `pending` \| `verified` \| `suspended` |
| `sandbox_mode` | boolean | Кассир Kaspi ещё не подключён. У боевой организации остаётся `true` до первой успешной авторизации кассира |
| `is_test` | boolean | Организация создана, пока аккаунт был в режиме sandbox. Неизменяемо: такие организации живут по мок-контуру и удаляются при переводе аккаунта в production |
| `kyc_status` | string | `required` \| `submitted` \| `needs_changes` \| `approved` \| `blocked` — статус анкеты клиента. Замечание модератора сюда не кладётся, оно только в `GET .../kyc` |
| `has_catalog` | boolean | Есть каталог |
| `kaspi_connected` | boolean | Касса Kaspi подключена |
| `session_mode` | string | Режим привязки (`self`) |
| `external_id` | string | Ваш CRM-идентификатор |
| `origin` | string | `referral` \| `created` \| `claimed` — происхождение орги (никогда не null) |
| `payment_status` | string | `none` \| `active` \| `expired` |
| `payment_expires_at` | string\|null | Когда истекает тариф |
| `has_active_payment` | boolean | Активная оплата |
| `tariff` | object | Действующие тарифные условия: `tier`, `tier_label`, `daily_limit`, `is_custom`, `limits_source` |
| `created_at` | string | Дата создания |

```json
{
  "id": 50, "name": "ТОО Example", "idn": "123456789012",
  "status": "pending|verified|suspended",
  "sandbox_mode": false, "is_test": false,
  "kyc_status": "required|submitted|needs_changes|approved|blocked",
  "has_catalog": false, "kaspi_connected": true,
  "session_mode": "self", "external_id": "crm-client-42",
  "origin": "referral|created|claimed",
  "payment_status": "none|active|expired",
  "payment_expires_at": "2026-06-16T00:00:00+05:00",
  "has_active_payment": false,
  "tariff": { "tier": "business", "tier_label": "Бизнес", "daily_limit": 100,
    "is_custom": false, "limits_source": "config|partner_grid|org_override" },
  "created_at": "2026-05-16T10:00:00+05:00"
}
```

**Про `tariff`.** Это то, по каким условиям мерчант работает прямо сейчас: `tier` — базовый
идентификатор тарифа либо `null` (значением `custom` он не бывает никогда), `tier_label` —
отображаемое имя условий (у договорных — их собственное, равенства с `tier` ждать не нужно),
`daily_limit` — суточный потолок счетов, `is_custom` — условия договорные, а не из общей сетки,
`limits_source` (`config` — общие условия, `partner_grid` — ваша сетка, `org_override` — льгота
этого клиента). Сумм здесь нет: цена живёт в `GET /organizations/{id}/tariff`.

---

## 5. Тариф и здоровье

Подписку подключённого мерчанта (`start`/`business`/`pro`/`pro_max`) партнёр оформляет и мониторит
через Partner API. Оплата — **счётом через Kaspi**: счёт уходит на телефон плательщика,
указанный в теле запроса, тариф активируется асинхронно после оплаты
(webhook `invoice.status_changed` → `paid`). Это подписочная плата за ApiPay, **не**
комиссия с оборота мерчанта.

### `GET /api/partner/tariff-plans` — каталог тарифов
Общий каталог (без привязки к орг): 4 тарифа + 12 планов (4 × 1/3/6 мес).
→ `{ "success": true, "tiers": [...], "plans": [...] }`

### `GET /api/partner/organizations/{id}/tariff` — статус подписки мерчанта
Снимок подписки. Нет тарифа → `status: "none"` (не 404). `next_payment.amount` — всегда.
```json
{ "success": true, "tariff": {
  "status": "none|trial|active|expired", "tier": "business", "is_trial": false,
  "started_at": "2026-06-20T10:00:00+05:00", "expires_at": "2026-09-20T10:00:00+05:00",
  "days_remaining": 92, "auto_renew": false,
  "last_payment": { "amount": 71250, "paid_at": "...", "period_months": 3, "tier": "business", "status": "completed" },
  "next_payment": { "due_at": "2026-09-20T10:00:00+05:00", "amount": 71250, "tier": "business" } } }
```

### `POST /api/partner/organizations/{id}/tariff/pay` — оплатить тариф
Body: `{ "tier_id": "business", "period_months": 3, "phone": "8XXXXXXXXXX", "set_billing_phone": false }`
(`phone` — плательщик, формат `8XXXXXXXXXX`; НЕ кассир). Боевая org — только **production**-партнёру
(иначе `403 production_access_required`); тестовая → мгновенная мок-активация (`201`,
`status:"completed"`, `self_api_invoice_id:null`).
Ответ `201`:
```json
{ "success": true, "payment": {
  "id": 42, "amount": 71250, "status": "pending", "tier": "business", "period_months": 3,
  "self_api_invoice_id": 778899, "external_id": "partner-tariff-501-1716900000",
  "paid_at": null, "expires_at": null, "created_at": "2026-06-20T10:00:00+05:00", "failure_reason": null } }
```
Ошибки: `403 production_access_required`, `404 organization_not_found`,
`409 tariff_payment_pending` (в теле — существующий `payment`), `422 invalid_tariff_plan`,
`429 tariff_payment_cooldown` (+`Retry-After`, `retry_after_seconds:1800`),
`503 tariff_payment_unavailable` (+`Retry-After`, `retry_after_seconds:30`).

### `POST /api/partner/organizations/{id}/tariff/assign` — назначить тариф без оплаты
Назначение тарифа без оплаты со стороны мерчанта: счёт не выставляется, с организации ничего
не списывается (`payment.amount: 0`, `status: "completed"`). Ручка доступна только партнёрам,
которым ApiPay включил режим выдачи `assignment`; в режиме `payment` она отвечает
`403 assignment_not_enabled`. Боевая org — только **production**-партнёру. Расчёты с ApiPay по
назначенным тарифам ведутся отдельно, вне API.
Body: `{ "tier_id": "business", "period_months": 3 }`
Ответ `201`:
```json
{ "success": true, "already_assigned": false, "payment": {
  "id": 44, "amount": 0, "status": "completed", "tier": "start",
  "period_months": 3, "self_api_invoice_id": null, "external_id": null,
  "paid_at": "2026-08-13T10:00:00+05:00", "expires_at": "2026-11-13T10:00:00+05:00",
  "created_at": "2026-08-13T10:00:00+05:00", "failure_reason": null } }
```
`201` — тариф назначен; `200` с `already_assigned: true` — запрошенный период уже покрыт
назначением того же тарифа, второго срока и второго платежа не появляется (ретрай CRM
безопасен). Назначение поверх активного тарифа не сжигает остаток: новый срок считается от
текущего `expires_at`, если он в будущем. Актуальные `tier` и `expires_at` читайте из
`GET .../tariff`, а не считайте у себя.
Ошибки: `403 assignment_not_enabled` (у вас включён режим оплаты) / `production_access_required`
/ `forbidden`; `404 organization_not_found`; `409 test_organization`, `tariff_payment_pending`
(есть неоплаченный счёт тарифа), `custom_tariff_locked` (у организации индивидуальные условия),
`hard_limited_org` (действует ограничение по лимиту счетов, снимается на стороне ApiPay);
`422 tier_not_in_partner_grid` (тариф вне вашей сетки), `422 assignment_horizon_exceeded`
(суммарный срок дальше 12 месяцев).
⛔ Отзыва назначения в API нет: если тариф назначен ошибочно — напишите нам, снимем со стороны
ApiPay. ⛔ Вебхук `tariff.activated` на **своё** назначение не приходит — это эхо на ваш же
запрос, результат виден в синхронном ответе.

### `POST /api/partner/organizations/{id}/tariff/invoice` — выписать счёт на оплату тарифа
Выставить **«Счёт на оплату»** тарифа (INVOICE для юрлиц). В отличие от `tariff/pay`
(push-счёт через Kaspi на телефон) здесь **синхронно** формируется **PDF-счёт** с реквизитами
покупателя-юрлица, который оплачивается **банковским переводом**; **успешный ответ (`201`)** сразу отдаёт публичную
ссылку `download_url` на PDF. Тариф активируется вручную на стороне ApiPay после подтверждения
поступления средств — **автоматической активации по факту выписки счёта НЕТ**. Боевая org — только **production**-партнёру (иначе `403 production_access_required`).
Body:
```json
{ "tier_id": "business", "period_months": 1, "buyer_bin": "123456789012",
  "buyer_name": "ТОО \"Ромашка\"", "buyer_address": "г. Алматы, ул. Абая, 1", "contract": "Договор №42" }
```
`buyer_bin` — ровно 12 цифр (обяз.); `buyer_name` — обяз. (≤255); `buyer_address` (≤500) и `contract` (≤255) — опц.
`upgrade` (bool, опц.) — счёт на **переход** между тарифами: сумма считается как разница базовых
цен, `tier_id` — целевой тариф, `period_months` обязан быть `1`. С какого тарифа выполняется
переход, определяет сервер по активному платному тарифу организации — передать это нельзя.
Ответ `201`:
```json
{ "success": true, "invoice": {
  "payment_id": 42, "number": 14, "amount": 25000, "tier": "business", "upgrade_from": null,
  "period_months": 1,
  "status": "pending", "buyer": {"bin":"123456789012","name":"ТОО \"Ромашка\"","address":null,"contract":null},
  "download_url": "https://apipay.kz/invoices/xxxxxxxx/download", "created_at": "2026-07-09T12:00:00+05:00" } }
```
`upgrade_from` — тариф, с которого выполнен переход, либо `null` у обычного плана. Ключа
`download_url` может не быть: он отсутствует, когда файл не сформировался.
Ошибки: `409 invoice_locked` (параллельная выписка — повторить), `422 invalid_tariff_plan` либо
Laravel-валидация полей, `429 invoice_cooldown` (+`Retry-After` — антиабуз-лимит выписок за 24ч),
`403 production_access_required`.
⛔ `409 invoice_pdf_failed` — **не повторять**: счёт уже выписан, сквозной номер выделен, сорвалась
только сборка PDF. Ретрай напечатает второй бухгалтерский документ. В теле придёт `invoice` с
`payment_id` и `number`, но **без** `download_url` — файла может не быть, и по токену вернётся 404.
Сохраните номер и напишите нам. Если у вас настроен автоматический повтор на `5xx` для этого
эндпоинта — снимите его: на этом коде повтор запрещён контрактом.
Отказы перехода (только при `upgrade: true`): `409 no_paid_tariff` (нет активного платного тарифа —
переходить не с чего), `400 downgrade_not_supported` (целевой тариф не выше текущего; понижение —
через поддержку), `422 invalid_upgrade_plan`, `422 upgrade_period_not_supported`,
`409 upgrade_invoice_pending` — неоплаченный счёт на переход уже есть, он придёт в поле `invoice`:
покажите его, второй такой счёт не выписывается. Срока жизни у него нет; аннулирование — через нас.
Счёт (`payment_method=invoice`) и оплата по телефону (`tariff/pay`) — **независимы**: неоплаченный
счёт не блокирует `tariff/pay`, и наоборот. Активация — поллингом `tariff/payments`
(`pending → completed`) + вебхук [`tariff.activated`](#событие-tariffactivated).

### `GET /api/partner/organizations/{id}/tariff/payments` и `/{paymentId}`
Список: `{ "success": true, "data": [ <payment> ] }` (по убыванию даты, триальные amount=0 исключены).
Один: `{ "success": true, "payment": <payment> }`; неизвестный `paymentId` → `404 tariff_payment_not_found`.
`<payment>`: `{ id, amount, status, tier, period_months, self_api_invoice_id, external_id, paid_at, expires_at, created_at, failure_reason }`.
Включает оба способа (`payment_method` `self_api` и `invoice`). У платежей `payment_method=invoice`
дополнительно присутствует под-объект `invoice: { number, download_url, buyer_name, upgrade_from }`.
У оплат по телефону этого под-объекта нет вовсе — искать `upgrade_from` рядом с `amount` бессмысленно.

### Событие `tariff.activated`
Единственный webhook партнёрского S2S-канала. Шлётся на `webhook_url` **партнёра**, когда тариф
активирован по ранее выписанному счёту — после подтверждения поступления средств. Подпись —
`X-Webhook-Signature: sha256=<HMAC-SHA256(body, webhook_secret)>` (тот же секрет/алгоритм, что у `invoice.*`).
Плоский payload (даты в `+05:00`):
```json
{ "event": "tariff.activated", "scope": "partner", "partner_id": 7, "organization_id": 501,
  "payment_id": 42, "invoice_number": 14, "tier": "business", "upgrade_from": null,
  "period_months": 1, "amount": 25000,
  "expires_at": "2026-08-09T12:00:00+05:00", "source": "Partner Key", "is_sandbox": false,
  "timestamp": "2026-07-09T12:00:00+05:00" }
```
`upgrade_from` — тариф, с которого выполнен переход, `null` у обычного платежа. Здесь это поле
**верхнего уровня**, а не внутри `invoice`, как в истории платежей.
Доставка ретраится автоматически (экспоненциальный бэкофф, до 11 попыток на
5xx/429/сеть; SSRF-провал и прочие 4xx — без ретрая). При окончательном сбое доставки используйте
поллинг `GET .../tariff/payments` (`status: pending → completed`).

### `GET /api/partner/health` — health аккаунта партнёра
Агрегат по всем вашим организациям. Блок `account` отдаётся всегда актуальным, остальные
агрегаты кэшируются на несколько секунд.
```json
{ "success": true, "api": {"status":"ok"},
  "account": {"mode":"production","type":"operating",
              "api_access_status":"granted","tariff_billing_mode":"assignment",
              "inbound_sync":{"enabled":true,"secret_configured":true,
                              "secret_hint":"••••a1b2","accepting":true}},
  "organizations": {"total":12,"kaspi_connected":9,"needs_reauth":1,"tariff_active":7,"tariff_expired":2,"on_trial":3,
                    "kyc":{"required":2,"submitted":1,"needs_changes":0,"approved":9,"blocked":0}},
  "webhooks": {"delivered_24h":340,"failed_24h":5,"success_rate":98.6},
  "rate_limits": {"partner_api_per_min":120} }
```

`account` — состояние самого аккаунта: `mode` (`sandbox|production`), `type`
(`referral|operating`), `api_access_status` (`none|pending|granted|rejected`; условие перехода
в production — `granted`) и `tariff_billing_mode` (`payment` — тариф мерчанта оплачивается
через `.../tariff/pay` или `.../tariff/invoice`; `assignment` — вы назначаете его без оплаты
через `.../tariff/assign`).

`account.inbound_sync` — состояние приёма входящих синхронизаций подписки: `enabled` (тумблер),
`secret_configured` (секрет задан и читается), `secret_hint` (последние 4 символа под маской
либо `null`), `accepting` (дверь открыта).
⛔ **Решение «дверь открыта» принимайте ТОЛЬКО по `accepting`** — из остальных полей его не
собрать. Смотрите сюда, а не в ответ боевого запроса: при закрытой двери он отдаёт `401`/`403`,
и отличить «не тот секрет» от «приём выключен» по нему нельзя. Сам секрет наружу не отдаётся
никогда — только `secret_hint`.

`organizations.kyc` — раскладка клиентов по статусу анкеты. Кто именно — фильтром
`GET /api/partner/organizations?kyc_status=…`.

---

## 6. KYC мерчанта

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

⛔ **Подать анкету за мерчанта нельзя, такой ручки нет.** В анкете есть подтверждение о
неторговле запрещённым — заверение, которое даёт тот, у кого факты. Анкета, заполненная
партнёром, превращает видимый пробел в невидимое утверждение.

### `GET /api/partner/organizations/{id}/kyc` — статус анкеты

```json
{ "success": true, "kyc": {
  "status": "needs_changes", "required_action": "fix_and_resubmit",
  "can_submit": true, "comment": "Скриншот витрины нечитаемый",
  "submitted_at": "2026-08-14T12:00:00+05:00", "submitted_via": "invite" } }
```

| Поле | Значения | Описание |
|---|---|---|
| `status` | `required` \| `submitted` \| `needs_changes` \| `approved` \| `blocked` | Статус анкеты клиента |
| `required_action` | `submit_profile` \| `wait_review` \| `fix_and_resubmit` \| `none` \| `contact_support` | Что должно произойти дальше. Производное поле — читайте его вместо собственного маппинга наших статусов |
| `can_submit` | boolean | Анкету сейчас можно подать или переподать |
| `comment` | string\|null | Замечание модератора. Приходит **только** при `needs_changes` — передайте клиенту дословно |
| `submitted_at` | string\|null | Когда анкета была подана |
| `submitted_via` | `merchant` \| `invite` \| null | Из кабинета мерчанта либо по выданной вами ссылке |

⚠️ Поллить каждую организацию не нужно. Сколько клиентов в каком состоянии — в
`GET /api/partner/health` (`organizations.kyc`); кто именно — фильтром
`GET /api/partner/organizations?kyc_status=required,needs_changes`. Статус каждой организации
есть и в её карточке — поле `kyc_status`.

### `POST /api/partner/organizations/{id}/kyc/invite` — выдать клиенту ссылку на анкету

Ответ `201`:
```json
{ "success": true, "invite_url": "https://apipay.kz/invite/2f6c…",
  "expires_at": "2026-08-29T12:00:00+05:00" }
```

Передайте ссылку мерчанту — он заполнит и подтвердит анкету сам, аккаунт в ApiPay ему для этого
не нужен.

⚠️ Ссылка показывается **один раз** — сохраните её при получении, повторно получить ту же не
выйдет. Новая выдача отзывает предыдущую. При этом открывать выданную ссылку можно сколько
угодно раз до истечения срока: если анкету вернут на доработку, мерчант исправит её по той же
ссылке.

⚠️ Передавайте адрес ровно так, как он пришёл в `invite_url`, — не собирайте его у себя из
токена. Тем же механизмом мерчанту выдаётся и ссылка на подключение кассира, а адреса у ссылок
разного назначения не обязаны совпадать.

Отказы: `409 test_organization` (тестовой организации ссылка не выдаётся),
`409 kyc_already_submitted` (анкета уже подана или проверена — смотрите её статус в
`GET .../kyc`), `403 forbidden`, `404 organization_not_found`, `429` — превышена частота выдачи
ссылок.

### Событие `kyc.status_changed`

Решение по анкете приходит на ваш `webhook_url` — поллить статус каждой организации не нужно.
Payload плоский:

```json
{ "event": "kyc.status_changed", "scope": "partner", "partner_id": 7,
  "organization_id": 501, "external_id": "crm-client-42",
  "previous_status": "submitted", "kyc_status": "needs_changes",
  "comment": "Скриншот витрины нечитаемый",
  "source": "Partner Key", "is_sandbox": false, "timestamp": "2026-08-15T12:00:00+05:00" }
```

Подпись — та же, что у `tariff.activated`:
`X-Webhook-Signature: sha256=<HMAC-SHA256(body, webhook_secret)>`.
Событие описывает **переход**, а не текущее состояние: оба статуса зафиксированы в момент
решения, поэтому повторная доставка не догоняет более позднее — актуальный статус всегда
читайте в `GET .../kyc`. `comment` приходит только при `needs_changes`.
⚠️ Событие приходит только по организациям, которыми вы управляете; мерчант, пришедший по вашей
реферальной ссылке, ведёт анкету сам.

---

## 7. Синхронизация подписки (подписанный запрос)

Для интеграторов, которые сами продают подписку своим клиентам и рассчитываются с ApiPay по
договору. Вы объявляете текущее состояние подписки мерчанта — мы приводим его тариф в
соответствие. Форма декларативная: повтор того же запроса безопасен, пропущенная доставка
чинится следующей, порядок доставки значения не имеет.

### `PUT /api/partner/organizations/{id}/subscription`

Заголовки: `X-Partner-Key`, `X-ApiPay-Timestamp` (unix-секунды) и
`X-ApiPay-Signature: sha256=<подпись>`. Метка времени должна быть свежей: расхождение в
несколько минут отклоняется.

**Схема подписи** — строка из четырёх частей через точку:
`{timestamp}.{МЕТОД}.{путь}.{сырое тело}`, например
`1786000000.PUT./api/partner/organizations/829/subscription.{"state":"active",…}`.
⛔ Метод и путь входят в подпись намеренно: организация приезжает в пути, и подпись по одному
телу позволила бы переиграть перехваченный запрос на другую вашу организацию, не изменив ни
байта тела.

⚠️ **Секрет приёма входящей синхронизации — отдельный**, это не тот секрет, которым мы
подписываем исходящие вебхуки. Выдаёт его ApiPay по вашему запросу, показывается он один раз,
перевыдача сразу перестаёт принимать старую подпись — плановую смену согласовывайте заранее.
Задан ли секрет и открыт ли приём, видно в `GET /api/partner/health` → `account.inbound_sync`;
там же `secret_hint` — последние 4 символа, чтобы сверить, тем ли секретом подписывает ваше
окружение.

Body:
```json
{ "state": "active", "tier": "business", "paid_through": "2026-09-12T18:50:12+05:00",
  "cause": "autoprolongation", "trial": false,
  "external_tariff_id": "23ca69d4-2657-40c4-8ba1-6ce24ddeac2e", "partner_context": {} }
```

| Поле | Тип | Обязательное | Описание |
|---|---|---|---|
| `state` | string | да | `active` \| `suspended` \| `cancelled` — состояние подписки на вашей стороне |
| `tier` | string | при `active` | Идентификатор тарифа ApiPay |
| `paid_through` | string | при `active` | Дата, до которой подписка оплачена |
| `cause` | string | нет | Повод изменения на вашей стороне — только для журнала |
| `trial` | boolean | нет | Пробный период у вас. Информационный: наш собственный пробный период он не расходует |
| `external_tariff_id` | string | нет | Ваш устойчивый идентификатор тарифа. Настоятельно рекомендуется: названия тарифов меняются, идентификаторы — нет |
| `partner_context` | object | нет | Произвольный контекст для журнала. Персональных данных здесь быть не должно |

Ответ `200`:
```json
{ "success": true, "sync_id": 8123, "result": "applied",
  "organization_id": 829, "external_id": "crm-client-42",
  "applied": { "tier": "business", "tier_label": "Бизнес", "daily_limit": 100,
               "limits_source": "partner_grid",
               "expires_at": "2026-09-12T23:50:12+05:00", "status": "active" },
  "ignored": { "daily_limit": { "sent": 999, "applied": 100 } } }
```

`result`: `applied` — тариф изменён; `unchanged` — уже соответствует (в том числе если ваша
дата не дальше текущей); `noted` — приостановка или отмена зафиксирована, тариф не тронут.
`applied` — что реально действует у мерчанта после синхронизации.

**Инварианты — тариф двигается только вперёд и только вверх:**

- более ранняя `paid_through` **не укорачивает** оплаченный срок → `200`, `result: unchanged`;
- понижение тира отклоняется (`409 tier_downgrade_not_allowed`) — понижает человек;
- дальше чем на 12 месяцев вперёд назначить нельзя (`422 assignment_horizon_exceeded`);
- ⛔ **отзыва тарифа через этот эндпоинт нет**: `suspended` и `cancelled` фиксируются в журнале,
  срок не трогают, выданный период истечёт сам. **Поэтому синхронизируйте помесячно** — тогда
  приостановка у вас становится приостановкой у нас максимум через месяц.

⚠️ **Суточный лимит счетов и название тарифа определяются вашими договорными условиями, а не
запросом.** Прислали своё — вернём в блоке `ignored` рядом с тем, что реально применено, чтобы
расхождение было видно сразу, а не выяснялось из жалобы мерчанта на лимит.

Скоуп организаций здесь шире, чем у остальных машинных ручек: доступны и созданные вами, и
заклеймленные. ⛔ Исключение — заклеймленная организация, которая оплачивает тариф
самостоятельно: `409 claimed_paying_organization`. Такой перевод оформляет ApiPay, потому что
владение при claim не менялось.

```bash
SECRET='<секрет приёма>'
KEY='<X-Partner-Key>'
ORG=829
API_PATH="/api/partner/organizations/$ORG/subscription"
BODY='{"state":"active","tier":"business","paid_through":"2026-09-12T18:50:12+05:00","cause":"autoprolongation"}'
TS=$(date +%s)
SIG=$(printf '%s.PUT.%s.%s' "$TS" "$API_PATH" "$BODY" \
      | openssl dgst -sha256 -hmac "$SECRET" -r | cut -d' ' -f1)

curl -X PUT "https://api.apipay.kz$API_PATH" \
  -H "X-Partner-Key: $KEY" \
  -H "X-ApiPay-Timestamp: $TS" \
  -H "X-ApiPay-Signature: sha256=$SIG" \
  -H 'Content-Type: application/json' \
  --data-raw "$BODY"
```

⛔ `--data-raw "$BODY"` — не `--data @file` с последующим форматированием и не пересборка JSON
библиотекой. Подпись считается по **сырым байтам**: одна лишняя пробельная позиция —
`401 invalid_signature`. Держите ту же строку, которую подписали.

**Коды ответа**

| HTTP | `error_code` | Что значит |
|---|---|---|
| 200 | — | `result`: `applied` / `unchanged` / `noted` |
| 401 | `invalid_signature`, `signature_expired`, `inbound_not_configured` | подпись, свежесть метки, приём не настроен |
| 403 | `inbound_sync_disabled`, `assignment_not_enabled`, `partner_not_active` | приём выключен / режим оплаты / аккаунт неактивен |
| 404 | `organization_not_found` | организация не ваша либо не существует |
| 409 | `test_organization`, `organization_deleted`, `claimed_paying_organization`, `custom_tariff_locked`, `hard_limited_org`, `tier_downgrade_not_allowed`, `tariff_payment_pending` | условия организации |
| 413 | `payload_too_large` | тело больше допустимого |
| 422 | `unsupported_state`, `subscription_terms_required`, `invalid_paid_through`, `tier_not_in_partner_grid`, `assignment_horizon_exceeded` | форма или условия |
| 429 | `rate_limited`, `tier_switch_rate_limited` | частота вызовов (у этой ручки собственный бакет) / слишком частая смена тарифа этой организации |

### `GET /api/partner/subscription-syncs` и `/{sync}`

Журнал: что мы приняли и почему отказали — по каждому вашему вызову, включая отказы. Карточка
одной записи дополнительно отдаёт исходное тело запроса (`payload`); в списке его нет.

**Query:** `organization_id`, `result` (`applied|unchanged|noted|rejected|failed`), `state`
(`active|suspended|cancelled`), окно `from`/`to` по времени приёма (голая дата трактуется в
Asia/Almaty; `to` не раньше `from`, иначе `422`), `per_page` (1–100, def 25), `page` (def 1).

⚠️ Неизвестное значение фильтра — `422`, а не пустая страница: молчаливый ноль неотличим от
«вы нам ничего не присылали». Чужой `organization_id`, наоборот, даёт пустую страницу —
существование чужих записей мы не подтверждаем.

```json
{ "success": true, "data": [
  { "id": 8123, "organization_id": 829, "state": "active", "cause": "autoprolongation",
    "requested_tier": "business", "requested_paid_through": "2026-09-12T18:50:12+05:00",
    "applied_tier": "business", "applied_expires_at": "2026-09-12T23:50:12+05:00",
    "result": "applied", "outcome_code": "applied", "http_status": 200,
    "external_tariff_id": "23ca69d4-2657-40c4-8ba1-6ce24ddeac2e",
    "received_at": "2026-08-12T18:50:13+05:00" } ],
  "current_page": 1, "per_page": 25, "total": 1, "last_page": 1 }
```

`outcome_code` — точная причина отказа или успеха: слаг ошибки либо
`applied`/`unchanged`/`noted`. Неизвестная или чужая запись в карточке → `404 sync_not_found`.

⚠️ Журнал хранится **90 дней**; более старые записи подрезаются. Если он нужен вам дольше —
забирайте страницы к себе: восстановить подрезанное мы не сможем.

```bash
curl "https://api.apipay.kz/api/partner/subscription-syncs?result=rejected&from=2026-08-01" \
  -H "X-Partner-Key: $KEY"
```

---

## 8. Ошибки и форматы (сквозное)

- **Конверт ошибок.** Основная форма: `{ "success": false, "error": "<code>", "error_code": "<code>", "message": "<ru>", "errors"?: {...} }` (`error` дублирует `error_code` для back-compat; `errors` приходит только на `422`). Ошибки аутентификации, доступа к организации и production-гейта приходят в сокращённой форме `{ "success": false, "error": "<code>" }`. Ошибки валидации полей — в форме `{ "message", "errors" }` без `success`/`error_code`. Коды `error_code` стабильны — используйте для локализации.
- **Даты** — ISO-8601 Asia/Almaty (`+05:00`). Исключение: `timestamp` внутри webhook-payload — UTC (`+00:00`).
- **429** — общий throttle отдаёт Laravel-форму `{ "message": "Too Many Attempts." }` + `Retry-After`. Тарифные лимиты (`tariff/pay`) кладут в тело `retry_after_seconds` (1800/30). Суточный лимит подключения кассира на `send-phone` — `error_code: "rate_limited"` + `retry_after_seconds`; различайте по наличию `error_code`, иначе суточная пауза применится к обычному поминутному throttle.

---

## 9. Sandbox — отладка привязки кассира

Песочница партнёра детерминированно эмулирует **привязку кассира**: ни одного реального
вызова Kaspi, ни одной SMS. Магические номера на `send-phone` подменяют ответ Kaspi на
фиксированный; любой другой валидный номер `7XXXXXXXXXX` трактуется как «обычный»
(success). Тестовая организация архитектурно не может стать боевой.

> **Не путайте песочницу партнёра и песочницу мерчанта.** Здесь — только онбординг и
> привязка кассира. Тестирование счетов, возвратов и webhook-событий (например, через
> `simulate-status`) — это песочница мерчанта, отдельная система: см. документацию для
> мерчантов [`/docs`](https://apipay.kz/docs).

**Магические значения (только привязка кассира):**

| Шаг / магическое значение | Результат | HTTP |
|---|---|---|
| `kaspi-auth/init` | `process_id = "SANDBOX-<uuid>"` | 200 |
| `send-phone` `77770000010` | success (касса привязывается) | 200 |
| `send-phone` `77770000011` | `not_cashier` — номер не кассир Kaspi | 422 |
| `send-phone` `77770000012` | `sms_failed` — Kaspi не смог отправить SMS | 502 |
| `send-phone` `77770000013` | `not_registered` — номер не зарегистрирован кассиром в Kaspi | 422 |
| `send-phone` `77770000014` | `context_expired` — Kaspi-контекст протух (повторите init) | 409 |
| `send-phone` `77770000015` | `kaspi_busy` — Kaspi временно недоступен | 503 |
| `send-phone` прочий валидный `7…` | success | 200 |
| `verify-otp` `0000` | success → org `status: verified` (`sandbox_mode` остаётся `true`) | 200 |
| `verify-otp` любой другой код | `invalid_otp` (повторяемо, сессия жива) | 200 |

### Сценарии тестирования

**Сценарий 1 — успешная привязка (happy path)**
- `POST /organizations {"external_id":"test-1"}` → `org.id`
- `POST .../{id}/kaspi-auth/init` → `process_id`
- `POST .../send-phone {"cashier_phone":"77770000010"}` → success
- `POST .../verify-otp {"otp":"0000"}` → org `status: verified`
- `POST .../{id}/api-key` → `X-API-Key` мерчанта (дальше — по `/docs`)

**Сценарий 2 — номер не кассир**
- `POST .../send-phone {"cashier_phone":"77770000011"}` → `422 not_cashier`
- Покажите клиенту инструкцию по добавлению роли «Кассир» в Kaspi.

**Сценарий 3 — сбой отправки SMS**
- `POST .../send-phone {"cashier_phone":"77770000012"}` → `502 sms_failed`
- Обработайте как временную ошибку — предложите повтор.

**Сценарий 3a — номер не зарегистрирован кассиром**
- `POST .../send-phone {"cashier_phone":"77770000013"}` → `422 not_registered`
- Попросите клиента ввести номер реального кассира, добавленного в Kaspi Pay.

**Сценарий 3b — Kaspi-контекст протух**
- `POST .../send-phone {"cashier_phone":"77770000014"}` → `409 context_expired`
- Вызовите `init` заново и повторите `send-phone` (один раз) — либо начните привязку с шага ввода номера.

**Сценарий 3c — Kaspi временно недоступен**
- `POST .../send-phone {"cashier_phone":"77770000015"}` → `503 kaspi_busy`
- Заблокируйте кнопку повтора примерно на минуту и покажите «Kaspi временно недоступен, повторите через минуту».
  Фиксированную паузу берите из своего конфига: окно в ответе на `503` не приходит.

**Сценарий 4 — неверный код из SMS**
- `POST .../send-phone {"cashier_phone":"77770000010"}` → success
- `POST .../verify-otp {"otp":"1234"}` → `{ "success": false, "error": "invalid_otp" }` — повторяемо
- Повторите verify-otp с кодом `0000` — сессия остаётся живой.

---

## 10. Лимиты

| Лимит | Значение | Превышение |
|---|---|---|
| Тестовых организаций на партнёра | 20 | `429 test_org_limit` |
| Новые номера кассиров на владельца | суточное окно | `429 rate_limited` (`Retry-After` + `retry_after_seconds`) |
| Создание организаций | 10 req/min | throttle |
| kaspi-auth (тестовая org) | 60 req/min | throttle (на партнёра+org) |
| kaspi-auth (боевая org) | 10 req/min | throttle (на партнёра+org) |
| Вся группа Partner API | 120 req/min | throttle (на партнёра) |

---

## 11. Полный E2E (sandbox)

Партнёрский онбординг до выдачи `X-API-Key`:

1. Получите `X-Partner-Key` в партнёрском кабинете (фронт).
2. `POST /api/partner/organizations {external_id}` → `org.id`
3. `POST .../{id}/kaspi-auth/init` → `process_id`
4. `POST .../{id}/kaspi-auth/send-phone {"cashier_phone":"77770000010"}` → success
5. `POST .../{id}/kaspi-auth/verify-otp {"otp":"0000"}` → org `status: verified`
6. `POST .../{id}/api-key` → `X-API-Key` мерчанта + `webhook_secret`
7. Мерчант создаёт счета этим `X-API-Key` — по документации для мерчантов ([`/docs`](https://apipay.kz/docs)).
8. Мониторьте свои организации: `GET /api/partner/organizations`.

---

## 12. Webhooks и HMAC

Партнёр задаёт `webhook_url` при выдаче `X-API-Key`. Формат событий и проверка HMAC-подписи
описаны в документации для мерчантов [`/docs`](https://apipay.kz/docs) — здесь не дублируются.

---

## 13. FAQ

**Как получить доступ?** `X-Partner-Key` выдаётся в партнёрском кабинете (фронт). Sandbox —
сразу self-service. Production (реальный Kaspi-auth и `mode=production`) — после ручного
одобрения админом; переключение режима тоже в кабинете.

**Что покрывает песочница партнёра?** Онбординг мерчанта и привязку кассира —
детерминированно, без реального Kaspi и SMS (магические номера). Тестирование
счетов/возвратов/webhooks — это отдельная песочница мерчанта, см. [`/docs`](https://apipay.kz/docs).

**Где платёжные операции и webhooks?** В документации для мерчантов
[`/docs`](https://apipay.kz/docs). Партнёр задаёт `webhook_url` при выдаче `X-API-Key`, а
формат событий и проверку HMAC мерчант (и ваш сервер) берёт из `/docs`.
