Что вы построите
Партнёр-интегратор (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:
{
"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-ключ и вебхук-секрет.
Шаг 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, а как создавать счета по номеру — в разборе счёт Kaspi по номеру.
Полный sandbox-прогон (E2E)
Связный copy-paste сценарий от создания организации до симуляции оплаты. Плейсхолдеры — YOUR_PARTNER_KEY и YOUR_API_KEY:
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"}'
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 '{}'
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"}'
curl -X POST .../kaspi-auth/verify-otp -H 'X-Partner-Key: YOUR_PARTNER_KEY' \
-H 'Content-Type: application/json' -d '{"otp":"1234"}'
curl -X POST .../kaspi-auth/verify-otp -H 'X-Partner-Key: YOUR_PARTNER_KEY' \
-H 'Content-Type: application/json' -d '{"otp":"0000"}'
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"}'
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"}'
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"}'
Магические значения 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. Про несколько организаций на аккаунт и тарифы — на странице для партнёров.
Тариф мерчанта (кратко)
Партнёр сам платит ApiPay за подписку подключённого мерчанта (start/business/pro) — это подписочная плата мерчант→ApiPay, а не оборот мерчанта. Оплата идёт счётом через Kaspi (телефон плательщика в теле, активация асинхронная после оплаты); триал 3 дня сохраняется, оплата продлевает от конца триала или периода. Полный разбор тарифных эндпоинтов и кодов — в парной статье про встраивание Partner API и на странице apipay.kz/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.