> Источник: https://apipay.kz/guides/partner-connect-organization · Обновлено: 2026-07-06 · apipay.kz
> ApiPay — независимый сервис приёма платежей поверх вашего Kaspi Pay.

# Как партнёру подключить организацию мерчанта к ApiPay

**TL;DR.** Один партнёрский ключ `X-Partner-Key` онбордит сколько угодно мерчантов через server-to-server Partner API на хосте `https://api.apipay.kz/api/partner`. Путь одного мерчанта — 7 шагов: создать организацию → начать авторизацию кассира (`init`) → отправить номер кассира (`send-phone`) → подтвердить код из SMS (`verify-otp`) → дождаться готовности (`status`) → выдать мерчанту его персональный `X-API-Key` → выставить первый счёт. Сначала весь путь прогоняется в **sandbox** — без единого реального Kaspi-вызова и без SMS, на магических значениях (OTP `0000`, кассир-номера `77770000010…015`). Тот же код работает в production, отличаются только магические значения на реальные.

## Коротко

| Параметр | Значение |
|---|---|
| Хост Partner API (S2S) | `https://api.apipay.kz/api/partner` |
| Хост мерчантского API | `https://api.apipay.kz/api/v1` |
| Заголовок партнёра | `X-Partner-Key` (только у operating-партнёра) |
| Заголовок мерчанта | `X-API-Key` (выдаётся на каждую организацию) |
| Онбординг мерчанта | 7 шагов: организация → init → send-phone → verify-otp → status → api-key → счёт |
| Окно авторизации | `process_id` живёт ~10 минут — уложите `send-phone` + `verify-otp` в него |
| Sandbox | На уровне партнёра; OTP `0000`, кассир `77770000010` = успех |
| Идемпотентность организации | По `external_id` — повтор вернёт ту же организацию |

## Что вы построите

Партнёр-интегратор (CRM, платформа, SaaS) одним ключом `X-Partner-Key` подключает N мерчантов: у каждого — своя организация в ApiPay и свой персональный `X-API-Key`, которым партнёр выставляет счета от имени этого мерчанта. Граница доверия — Kaspi-SMS кассиру: мерчант подтверждает подключение кодом, приходящим на его номер кассира. Деньги идут напрямую на Kaspi-счёт мерчанта. Начните с песочницы: весь онбординг там детерминирован и не трогает реальный Kaspi.

## Предусловия

Что нужно до старта:

- **Партнёрский `X-Partner-Key`.** Выпускается в веб-кабинете партнёра. Sandbox-ключ доступен сразу, self-service; production — после ручного одобрения администратором (это коммерческий договор). Ключ показывается один раз, в БД хранится только его sha256-хеш. Доступен только **operating**-партнёру; у `referral`-партнёра S2S закрыт.
- **От мерчанта — телефон кассира Kaspi** в формате `7XXXXXXXXXX`.

> **Два разных телефона в двух форматах — не перепутайте.** Телефон **кассира** (авторизация Kaspi) — `7XXXXXXXXXX`, ведущая 7, regex `^7\d{10}$`, идёт в `send-phone → cashier_phone`. Телефон **плательщика** (кому выставляете счёт, lookup) — `8XXXXXXXXXX`, ведущая 8, regex `^8\d{10}$`, идёт в `/api/v1/invoices → phone_number`, `clients/check → phone`. Кассир — тот, чья Kaspi-касса принимает оплату; плательщик — клиент, который платит.

## Базовый URL и заголовок

Каждый S2S-запрос идёт на `https://api.apipay.kz/api/partner` с заголовком `X-Partner-Key: <ключ>`. Это **не** `apipay.kz` — там живёт сайт и SPA-кабинет, а не S2S-хост.

Конверт ответа: успех — `{ "success": true, ... }`; ошибка контроллера/сервиса — `{ "success": false, "error": "<code>", "error_code": "<code>", "message": "<ru>", "errors"?: {...} }` (`error` дублирует `error_code` для обратной совместимости; `errors` — только на 422 из контроллерной проверки). Ошибки middleware (аутентификация/владение/production-гейт) — сокращённые: `{ "success": false, "error": "<code>" }`. Ошибки валидации FormRequest — стандартная Laravel-форма `{ "message", "errors" }` без `success`. Машинные коды в `error`/`error_code` стабильны — используйте их для локализации на стороне CRM.

**Кросс-слойные ошибки (общие для всех шагов):**

| HTTP | `error` | Когда |
|---|---|---|
| 401 | `partner_key_missing` | нет заголовка `X-Partner-Key` |
| 401 | `invalid_partner_key` | ключ неверный или партнёр отключён |
| 401 | `partner_user_missing` | у партнёра не привязан пользователь |
| 403 | `forbidden` | партнёр не operating (S2S закрыт для referral-типа) |
| 404 | `organization_not_found` | организация не принадлежит партнёру или не существует |
| 422 | `{ message, errors }` | ошибка валидации тела (дефолтная Laravel-форма) |
| 429 | `Too Many Attempts.` | превышен лимит группы (заголовок `Retry-After`) |

> **Главная ошибка читателя — перепутать ключи.** `X-Partner-Key` — партнёрский, S2S, для онбординга/тарифа/health на `/api/partner`. `X-API-Key` — персональный ключ мерчанта, для обычного платёжного API на `/api/v1`. Один вместо другого даёт `401`.

## Сначала — прогон в sandbox

Начинайте с песочницы: она детерминирована (одинаковый вход → одинаковый выход, можно покрыть автотестами CRM), не делает реальных Kaspi-вызовов и не шлёт SMS. Sandbox — на уровне партнёра: один `X-Partner-Key` и тумблер `sandbox ⟷ production`. Тестовая организация остаётся `sandbox_mode: true` даже после `verify-otp`, `KaspiConnection` у неё не создаётся. Тот же код пойдёт в production — отличаются только магические значения на реальные телефоны/SMS/OTP. Выпуск `X-Partner-Key` и переключение режима делаются в веб-кабинете партнёра.

## Шаг 1. Создать организацию

`POST /organizations`. Тело (все поля опциональны): `{ "has_catalog": false, "external_id": "crm-client-42", "name": "ТОО Example" }`.
- `external_id` — ваш референс клиента в CRM и **ключ идемпотентности**: повторный POST с тем же `external_id` вернёт уже существующую организацию (`200`), дубль не создастся.
- `name` (`max:255`) — если пустой, автогенерится и позже подменяется реальным именем из Kaspi на `verify-otp`.
- `has_catalog` — для большинства интеграций `false` (простые счета `amount` + `description`).

Ответ `201` (создана) / `200` (идемпотентный повтор): `{ "success": true, "organization": { "id": 501, "status": "pending", "sandbox_mode": true, "external_id": "crm-client-42", "origin": "created", ... } }`. Лимит `partner-org-create` — 10 req/min на партнёра; достигнут лимит тестовых организаций (20) → `429 test_org_limit`.

## Шаг 2. Начать авторизацию кассира (init)

`POST /organizations/{id}/kaspi-auth/init`. Тело: `{}` или `{ "force": true }` (переавторизация поверх активной сессии — смена кассира / переподключение). Ответ: `{ "success": true, "process_id": "...", "process_status": "phone_required" }`. `process_id` живёт ~10 минут — уложите следующие два шага в это окно.

Ошибки:
- `409 already_connected` — организация уже подключена к Kaspi. Чтобы переподключить, повторите `init` с `"force": true` (защита от того, чтобы случайный retry не уничтожил рабочую связку).
- `403 production_access_required` — боевая организация у sandbox-партнёра (см. «Переход в production»).

## Шаг 3. Отправить номер кассира (send-phone)

`POST /organizations/{id}/kaspi-auth/send-phone`. Тело: `{ "cashier_phone": "7XXXXXXXXXX" }` (формат `^7\d{10}$` — это телефон **кассира**, не плательщика). Успех: `{ "success": true, "process_status": "otp_required" }` — Kaspi отправляет SMS-код на номер кассира. Это самый «ошибкоёмкий» шаг, разберите каждую ветку:

| HTTP | `error` | Смысл | Что делать |
|---|---|---|---|
| 422 | `invalid_phone` | неверный формат номера | исправить формат на `7XXXXXXXXXX` |
| 422 | `not_cashier` | у номера нет роли «Кассир» в Kaspi | уточнить у мерчанта корректный номер кассира |
| 422 | `not_registered` | номер не зарегистрирован кассиром в Kaspi | мерчанту зарегистрировать кассу в Kaspi, затем повторить |
| 409 | `no_process` | авторизация не начата | вызвать `init` |
| 409 | `context_expired` | Kaspi-контекст протух (`process_id` ~10 мин) | вызвать `init` заново, повторить `send-phone` |
| 409 | `org_claim_conflict` | этот кассир уже привязан к другой организации в ApiPay | привязать нельзя; разобраться, где кассир уже подключён |
| 502 | `sms_failed` | Kaspi не вернул экран ввода OTP | повторить позже |
| 503 | `kaspi_busy` | Kaspi временно недоступен / троттлит | повторить примерно через минуту |

Лимит `partner-kaspi-auth` — 10 req/min (боевая организация) / 60 req/min (тестовая — мок не шлёт SMS) на партнёра и организацию. На `503 kaspi_busy` просто выждите паузу перед повтором; не завязывайте жёсткую логику ровно на заголовок `Retry-After`.

## Шаг 4. Подтвердить код из SMS (verify-otp)

`POST /organizations/{id}/kaspi-auth/verify-otp`. Тело: `{ "otp": "1234" }` (4–6 цифр, `^\d{4,6}$`). Успех `200`: `{ "success": true, "mode": "self", "organization": <card>, "process_status": "active" }` — организация финализируется (`status: "verified"`).

> **Неверный код — это тоже HTTP 200, и он повторяем.** `{ "success": false, "error": "invalid_otp", "process_status": "otp_required" }`. Сессия НЕ сбрасывается — просто попросите код заново и повторите `verify-otp`. Не трактуйте `invalid_otp` как транспортную ошибку и не начинайте процесс заново.

Другие ошибки:
- `409 org_claim_conflict` — кассир/организация уже привязаны к другой организации в ApiPay.
- `409 no_process` — авторизация не начата или истекла (вернитесь к `init`).
- `502 finish_no_x509` / `finish_no_token_sn` / `http_error` — внутренняя ошибка финализации Kaspi-сессии; повторить позже, при повторе — в поддержку.

## Шаг 5. Дождаться готовности (status)

`GET /organizations/{id}/kaspi-auth/status`. Ответ: `{ "success": true, "status": "none|pending|active|expired", "process_status": "idle|phone_required|otp_required|active|failed", "kaspi_connected": bool, "expires_at": "...|null" }`. Используйте для отслеживания хода авторизации и чтобы понять, нужно ли переподключение кассира (`needs_reauth`).

## Шаг 6. Выдать мерчанту его X-API-Key

`POST /organizations/{id}/api-key`. Тело: `{ "name": "CRM key", "webhook_url": "https://...", "webhook_secret": "..." }`. `webhook_url` **обязателен** и проходит SSRF-валидацию (приватные/внутренние адреса → `422`); `name` и `webhook_secret` опциональны (`webhook_secret` сгенерируется автоматически). Вызов идемпотентен: повтор перегенерирует ключ той же записи (`regenerated: true`).

Ответ `200`:

```json
{
  "success": true,
  "key": "<X-API-Key в открытом виде — показывается ОДИН РАЗ>",
  "key_id": 200,
  "webhook_url": "https://crm.example.kz/sub/501/webhook",
  "webhook_secret": "<секрет в открытом виде — показывается ОДИН РАЗ>",
  "is_org_default": true,
  "regenerated": false
}
```

> **`key` и `webhook_secret` возвращаются в открытом виде ровно один раз** — сохраните их сразу на своей стороне. `is_org_default` = `true` только если у организации ещё не было дефолтного ключа. Как ключ соотносится с секретом подписи вебхука — в разборе [API-ключ и вебхук-секрет](/guides/api-klyuch-i-webhook-secret).

## Шаг 7. Первый счёт от имени мерчанта

Дальше работаете **выданным `X-API-Key`** (не партнёрским ключом) против `https://api.apipay.kz/api/v1`:
- Счёт по номеру — `POST /api/v1/invoices`, тело `{ "phone_number": "8XXXXXXXXXX", "amount": 5000, "description": "Заказ №123" }` → `201` (в sandbox — `status: pending`, `is_sandbox: true`).
- QR-счёт — `POST /api/v1/invoices/qr`, тело `{ "amount": 5000, "description": "..." }` → `201` + `qr_token_url`, `qr_image_url`, `qr_expires_at` (TTL 5 минут). В sandbox опциональное поле `"simulate": "paid|cancelled|expired"` сразу финализирует QR.

Это только «первый счёт, чтобы убедиться, что связка работает». Полный мерчантский платёжный API (все поля, статусы, отмены, возвраты, вебхуки) — в мерчантской документации [apipay.kz/docs](/docs), а как создавать счета по номеру — в разборе [счёт Kaspi по номеру](/guides/kak-sozdat-schet-kaspi-po-nomeru).

## Полный sandbox-прогон (E2E)

Связный copy-paste сценарий от создания организации до симуляции оплаты. Плейсхолдеры — `YOUR_PARTNER_KEY` и `YOUR_API_KEY`:

```bash
# 1. Создать тестовую организацию
curl -X POST https://api.apipay.kz/api/partner/organizations \
  -H 'X-Partner-Key: YOUR_PARTNER_KEY' -H 'Content-Type: application/json' \
  -d '{"has_catalog":false,"external_id":"crm-client-42"}'
# 201 {"success":true,"organization":{"id":501,"status":"pending","sandbox_mode":true,...}}

# 2. init — начать авторизацию кассира
curl -X POST https://api.apipay.kz/api/partner/organizations/501/kaspi-auth/init \
  -H 'X-Partner-Key: YOUR_PARTNER_KEY' -H 'Content-Type: application/json' -d '{}'
# 200 {"success":true,"process_id":"SANDBOX-...","process_status":"phone_required"}

# 3. send-phone — магический номер кассира = успех
curl -X POST https://api.apipay.kz/api/partner/organizations/501/kaspi-auth/send-phone \
  -H 'X-Partner-Key: YOUR_PARTNER_KEY' -H 'Content-Type: application/json' \
  -d '{"cashier_phone":"77770000010"}'
# 200 {"success":true,"process_status":"otp_required"}

# 4. verify-otp — сначала неверный код (повторяемо), потом магический 0000
curl -X POST .../kaspi-auth/verify-otp -H 'X-Partner-Key: YOUR_PARTNER_KEY' \
  -H 'Content-Type: application/json' -d '{"otp":"1234"}'
# 200 {"success":false,"error":"invalid_otp","process_status":"otp_required"}
curl -X POST .../kaspi-auth/verify-otp -H 'X-Partner-Key: YOUR_PARTNER_KEY' \
  -H 'Content-Type: application/json' -d '{"otp":"0000"}'
# 200 {"success":true,"organization":{"status":"verified",...}}

# 5. Выдать мерчанту X-API-Key
curl -X POST https://api.apipay.kz/api/partner/organizations/501/api-key \
  -H 'X-Partner-Key: YOUR_PARTNER_KEY' -H 'Content-Type: application/json' \
  -d '{"name":"CRM key","webhook_url":"https://crm.example.kz/sub/501/webhook"}'
# 200 {"success":true,"key":"<X-API-Key один раз>","webhook_secret":"<один раз>","is_org_default":true,...}

# 6. Первый счёт — уже выданным X-API-Key, плательщик = 8XXXXXXXXXX
curl -X POST https://api.apipay.kz/api/v1/invoices \
  -H 'X-API-Key: YOUR_API_KEY' -H 'Content-Type: application/json' \
  -d '{"phone_number":"87770001122","amount":5000,"description":"Заказ №123"}'
# 201 status=pending, is_sandbox=true

# 7. Симулировать оплату → на webhook_url прилетит invoice.status_changed
curl -X POST https://api.apipay.kz/api/v1/invoices/1001/simulate-status \
  -H 'X-API-Key: YOUR_API_KEY' -H 'Content-Type: application/json' -d '{"status":"paid"}'
# 200 payload вебхука идентичен боевому
```

Магические значения sandbox: OTP `0000` (успех); кассир-телефоны `77770000010` (успех), `…011` (`not_cashier`), `…012` (`sms_failed`), `…013` (`not_registered`), `…014` (`context_expired`), `…015` (`kaspi_busy`); lookup-номера `87770000001` (есть Kaspi, «Иван И.»), `87770000002` (нет Kaspi).

## Справочник ошибок по шагам

Якорная сводка «шаг → код → HTTP → что делать»:

| Шаг | `error` | HTTP | Что делать |
|---|---|---|---|
| init | `already_connected` | 409 | повторить `init` с `"force": true` |
| init/send-phone/verify-otp | `production_access_required` | 403 | нужен production-режим (см. ниже) |
| send-phone | `invalid_phone` | 422 | исправить формат `7XXXXXXXXXX` |
| send-phone | `not_cashier` / `not_registered` | 422 | проблема регистрации кассира в Kaspi — к мерчанту |
| send-phone | `context_expired` | 409 | `init` заново, повторить `send-phone` |
| send-phone / verify-otp | `org_claim_conflict` | 409 | кассир занят другой организацией — разобраться |
| send-phone | `sms_failed` / `kaspi_busy` | 502 / 503 | повторить позже (примерно через минуту) |
| verify-otp | `invalid_otp` | **200** | код неверный — процесс жив, повторить `verify-otp` |
| verify-otp | `finish_no_x509` / `finish_no_token_sn` / `http_error` | 502 | повторить; при повторе — в поддержку |

## Переход в production

Sandbox доступен сразу и self-service. Production-операции (реальный Kaspi-auth и боевые счета) — после ручного одобрения администратором (`api_access_status = granted`), то есть коммерческого договора. Пока партнёр в sandbox-режиме, любой S2S-вызов по **боевой** организации (`init`/`send-phone`/`verify-otp`/`status`/`tariff/pay`) отбивается `403 production_access_required`.

Переключение режима `sandbox ⟷ production` делается в веб-кабинете партнёра; переход в production хард-удаляет все тестовые организации партнёра. В production отличается только «магия»: реальные телефоны/SMS/OTP вместо `777…`/`0000`. Сам флоу и коды ошибок идентичны sandbox. Про несколько организаций на аккаунт и тарифы — на странице [для партнёров](/partners).

## Тариф мерчанта (кратко)

Партнёр сам платит ApiPay за подписку подключённого мерчанта (`start`/`business`/`pro`) — это подписочная плата мерчант→ApiPay, а не оборот мерчанта. Оплата идёт счётом через Kaspi (телефон плательщика в теле, активация асинхронная после оплаты); триал 3 дня сохраняется, оплата продлевает от конца триала или периода. Полный разбор тарифных эндпоинтов и кодов — в парной статье про встраивание Partner API и на странице [apipay.kz/partner-api](/partner-api).

## Мониторинг: нужно ли переподключение кассира

`GET /api/partner/health` возвращает агрегат по всем организациям партнёра (кэш ~30 секунд): блок `organizations` с `total`/`kaspi_connected`/`needs_reauth`/`tariff_active`/`tariff_expired`/`on_trial`, блок `webhooks` (`delivered_24h`/`failed_24h`/`success_rate`) и `rate_limits`. Если `needs_reauth > 0` — требуется переподключение кассира: сверьтесь по конкретной организации через `GET /organizations/{id}/kaspi-auth/status` (`kaspi_connected`/`status`).

Переподключение кассира = повтор онбординг-шагов с `init` + `"force": true` → `send-phone` → `verify-otp`. Отдельного вебхука об этом нет — детектите поллингом `/health` и `/status`.

## Частые вопросы

**Чем `X-Partner-Key` отличается от `X-API-Key`?**
`X-Partner-Key` — партнёрский ключ для S2S Partner API (`/api/partner`): онбординг организаций, тариф, health. `X-API-Key` — персональный ключ каждой организации мерчанта для обычного платёжного API (`/api/v1`): счета, вебхуки, возвраты. Перепутать их — главная причина `401`.

**Неверный код из SMS вернул HTTP 200 — это баг?**
Нет. `invalid_otp` приходит именно с HTTP 200 и `{"success":false}`; сессия авторизации не сбрасывается. Запросите код заново и повторите `verify-otp` — процесс жив. Не трактуйте это как транспортную ошибку.

**Почему хост — `api.apipay.kz`, а не `apipay.kz`?**
`apipay.kz` — это сайт и SPA-кабинет. Весь S2S Partner API по `X-Partner-Key` живёт на `https://api.apipay.kz/api/partner`, а мерчантский платёжный — на `https://api.apipay.kz/api/v1`.

**Что делать при `403 production_access_required`?**
Вы пытаетесь работать с боевой организацией, будучи в sandbox-режиме. Production открывается после ручного одобрения администратором (коммерческий договор); переключение режима — в веб-кабинете партнёра. В песочнице тот же флоу доступен сразу.

**Повторный `POST /organizations` создаст дубликат?**
Нет, если передать тот же `external_id`: вызов идемпотентен и вернёт уже существующую организацию с HTTP 200. `external_id` — ваш ключ идемпотентности и референс клиента в CRM.

**Как понять, что мерчанту нужно переподключить кассира?**
По `GET /api/partner/health`: если `needs_reauth > 0`, сверьтесь по организации через `kaspi-auth/status`. Переподключение — `init` с `"force": true`, затем `send-phone` и `verify-otp`.

---

ApiPay — независимый сервис и не аффилирован с АО «Kaspi Bank». Kaspi и Kaspi Pay — товарные знаки их правообладателя.
База знаний: https://apipay.kz/guides
