Предусловия
Что нужно до старта:
- Партнёрский
X-Partner-Key. Выпускается в веб-кабинете партнёра. Sandbox-ключ доступен сразу, self-service; production — после ручного одобрения администратором (это коммерческий договор). Ключ показывается один раз, в БД хранится только его sha256-хеш. Доступен только operating-партнёру; уreferral-партнёра S2S закрыт. - От мерчанта — телефон кассира Kaspi в формате
7XXXXXXXXXX.
Два разных телефона в двух форматах — не перепутайте. Телефон кассира (чья Kaspi-касса принимает оплату) —
7XXXXXXXXXX, regex^7\d{10}$, идёт вsend-phone → cashier_phone. Телефон плательщика (кому выставляете счёт, lookup) —8XXXXXXXXXX, regex^8\d{10}$, идёт в/api/v1/invoices → phone_number,clients/check → phone.
Базовый 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) |
Сначала — прогон в sandbox
Песочница детерминирована, не делает реальных Kaspi-вызовов и не шлёт SMS — её можно покрыть автотестами CRM. Sandbox — на уровне партнёра: один X-Partner-Key и тумблер sandbox ⟷ production. Тестовая организация остаётся sandbox_mode: true даже после verify-otp, Kaspi-подключение у неё не создаётся.
Шаг 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.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 Pay → Настройки → Сотрудники с ролью «Кассир», затем начинать заново с init. Повторный send-phone вернёт 409 no_process |
| 409 | no_process |
авторизация не начата | вызвать init |
| 409 | context_expired |
Kaspi-контекст протух (process_id ~10 мин) |
вызвать init заново, повторить send-phone |
| 409 | cashier_unavailable |
кассира сейчас нельзя подключить; причина не раскрывается | повтор не поможет — направьте мерчанта в поддержку ApiPay |
| 429 | rate_limited |
за сутки с аккаунта пробовали слишком много разных номеров кассиров | окно суточное: выждите время из Retry-After / retry_after_seconds — это реальное время до обнуления счётчика, обычно часы, а не привычная минута. Уже подключённые кассиры этого владельца в счётчик не входят, переавторизация рабочей точки лимитом не блокируется |
| 502 | sms_failed |
Kaspi не вернул экран ввода OTP | повторить позже |
| 503 | kaspi_busy |
анти-абуз Kaspi | сессия закрыта: пауза ~60 секунд собственным бэкоффом, затем новый init |
Лимит на шаги авторизации кассира — 10 req/min (боевая организация) / 60 req/min (тестовая — мок не шлёт SMS) на партнёра и организацию.
Терминальные исходы. После not_registered, context_expired и kaspi_busy сессия авторизации закрыта: повторный send-phone вернёт 409 no_process, единственный путь дальше — новый init. Заголовок Retry-After приходит только с 429 rate_limited и тарифными лимитами; на kaspi_busy и прочих его нет — используйте собственный бэкофф с ориентиром 60 секунд.
Два разных 429 на этой ручке различаются по телу: поминутный лимит группы отдаёт {"message": "Too Many Attempts."} без error_code, суточный — error_code: "rate_limited" и retry_after_seconds. Суточную паузу применяйте только ко второму.
Исходы cashier_unavailable и rate_limited в sandbox не воспроизводятся: тестовая организация идёт мок-путём, и сессию мок не ведёт — после not_registered в песочнице следующий send-phone ответит успехом, а на боевой организации тот же цикл упрётся в 409 no_process. Заложите обработку заранее.
Шаг 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 cashier_unavailable— то же, что наsend-phone: повтор не поможет, мерчанта в поддержку ApiPay.409 no_process— авторизация не начата или истекла (вернитесь кinit).502— Kaspi-сессия не финализировалась (ответ Kaspi не дошёл или пришёл неполным); повторить позже, при повторе — в поддержку сorganization_idи временем запроса.
Шаг 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. Срок жизни берите изqr_expires_at, а не из константы в коде — разбор окна и лимитов в статье QR-счёт: окно и лимиты. В 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).
Переход в production
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 за подписку подключённого мерчанта — это подписочная плата мерчант→ApiPay, а не оборот мерчанта. Разбор тарифных эндпоинтов — в статье Partner API white-label, сетка тарифов — в Тарифы и комиссия ApiPay.
Мониторинг: нужно ли переподключение кассира
GET /api/partner/health возвращает агрегат по всем организациям партнёра (разбор полей — в статье Partner API white-label). Если 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.