Три модели, и это разные интеграции
ApiPay сосуществует в трёх режимах, и первое, что стоит сделать, — определить свой.
| Модель | Кто платит ApiPay | Признак «это ваш случай» |
|---|---|---|
| Реферальная | Клиент сам | Вы приводите клиентов ссылкой и не управляете их аккаунтами |
| Операционная | Клиент, но счета выставляете вы | Вы ведёте онбординг, но подписку клиент оплачивает сам |
| Договорная (эта статья) | Платформа по договору | Клиент платит вам, у вас свой биллинг, ApiPay остаётся на стороне платформы |
В договорной модели вам включают режим назначения тарифа: тариф клиенту вы не оплачиваете
поштучно, а объявляете. Включает его ApiPay по договору — самостоятельной ручки нет.
Проверить, включён ли режим, можно в GET /api/partner/health: account.tariff_billing_mode
должен быть assignment.
Границы ответственности
- Ваша сторона: кто из клиентов подписан, до какой даты, на каком плане; продление, приостановка, возвраты, любые скидки и промо.
- Наша сторона: работает ли приём Kaspi у клиента, каков его суточный лимит счетов, одобрена ли анкета, доставлены ли вебхуки.
⚠️ Суточный лимит и название тарифа мы берём из договорных условий, а не из вашего
запроса. Прислали своё значение — вернём его в блоке ignored рядом с тем, что реально
применено. Так расхождение видно сразу, а не выясняется из жалобы клиента на лимит.
Шаг 1. Организация клиента и приём Kaspi
Заведение организации, подключение кассира по SMS-коду и выпуск per-org ключа описаны отдельно — повторять не будем: «Полностью автоматический приём Kaspi для клиентов» и «Подключение организации к партнёру».
Здесь важно одно: external_id — ваш идентификатор клиента в вашей системе. Он же ключ
идемпотентности: повторный POST с тем же external_id вернёт ту же организацию, а не
создаст вторую.
Шаг 2. Секрет подписи входящих
Синхронизация подписки — денежная дверь: через неё внешняя система включает платный продукт. Поэтому каждый запрос подписывается, и секрет для этого отдельный — не тот, которым мы подписываем исходящие вебхуки.
Секрет выдаёт ApiPay по запросу, показывается он один раз. Проверить, что дверь открыта и что ваше окружение подписывает тем же секретом:
curl https://api.apipay.kz/api/partner/health -H "X-Partner-Key: $KEY"
"account": {
"tariff_billing_mode": "assignment",
"inbound_sync": {"enabled": true, "secret_configured": true,
"secret_hint": "••••a1b2", "accepting": true}
}
Смотрите на accepting — это и есть «дверь открыта». secret_hint — последние 4 символа
выданного секрета: удобно сверить, тем ли подписывает стенд и тем ли прод.
⚠️ Проверяйте состояние здесь, а не боевым запросом: при закрытой двери запрос отвечает
401/403, и отличить «не тот секрет» от «приём выключен» по ответу нельзя.
Шаг 3. Синхронизация состояния
SECRET='<inbound_secret>'
ORG=829
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" "$PATH_" "$BODY" \
| openssl dgst -sha256 -hmac "$SECRET" -r | cut -d' ' -f1)
curl -X PUT "https://api.apipay.kz$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", а не пересборку JSON библиотекой и не «красивое» форматирование перед
отправкой. Одна лишняя пробельная позиция — 401 invalid_signature, и выглядит это
невоспроизводимой ошибкой «на стенде работает, в проде нет».
Почему состояние, а не события
У вашей платформы на входе уже лежит состояние: Vendor API МоегоСклада присылает PUT
со статусом и причиной, а не ленту событий. Событийный приём означал бы, что вы кодируете
состояние в события, а мы собираем его обратно, — с потерями на каждом шаге.
Декларативная форма даёт даром то, что в событийной строится механизмами:
- повтор безопасен — тот же
PUTдважды не удлиняет подписку; - пропущенная доставка чинится следующей — очередь ретраев не нужна;
- порядок доставки не важен — побеждает то, что вы прислали последним;
- ключ идемпотентности не нужен — повтор состояния ни на чём не «залипает».
Что происходит от того, что вы прислали
| Прислали | Что произошло | Ответ |
|---|---|---|
state: active, дата дальше текущей |
Срок продлён, тариф выставлен | 200, result: applied |
state: active, та же дата |
Ничего — состояние уже такое | 200, result: unchanged |
state: active, дата раньше текущей |
Ничего: срок не укорачивается | 200, result: unchanged |
state: active, тир ниже текущего |
Отказ — понижает человек | 409 tier_downgrade_not_allowed |
state: suspended / cancelled |
Записано в журнал, тариф не тронут | 200, result: noted |
⛔ Отзыва тарифа нет — это решение, а не пробел. Отключение живого мерчанта от приёма денег мы не делаем по машинному запросу. Выданный период истечёт сам, поэтому синхронизируйте помесячно: тогда приостановка у вас становится приостановкой у нас максимум через месяц, без единого отдельного вызова.
Ответ 200 всегда показывает, что реально действует:
{ "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 } } }
Шаг 4. Журнал — чтобы не писать в поддержку
Каждый вызов, включая отказы, попадает в журнал:
curl "https://api.apipay.kz/api/partner/subscription-syncs?result=rejected&from=2026-08-01" \
-H "X-Partner-Key: $KEY"
Фильтры: organization_id, result, state, окно from/to по времени приёма.
Карточка одной записи (/subscription-syncs/{id}) дополнительно отдаёт исходное тело — видно,
что именно ушло с вашей стороны.
⚠️ Неизвестное значение фильтра — 422 с именем поля, а не пустая страница: молчаливый ноль
неотличим от «мы вам ничего не присылали».
Шаг 5. Состояние клиентов одним запросом
Списки и агрегаты устроены так, чтобы не опрашивать каждого клиента по отдельности:
GET /api/partner/health→organizations.kyc— сколько клиентов в каком состоянии анкеты;GET /api/partner/organizations?kyc_status=required,needs_changes— кто именно;- карточка организации несёт
kyc_statusи блокtariff(tier,tier_label,daily_limit,limits_source) — по какому тарифу и с каким суточным лимитом клиент работает прямо сейчас.
Сумм в карточке нет: цена — в GET /api/partner/organizations/{id}/tariff.
Шаг 6. Анкета клиента
До одобрения анкеты у клиента действует суточный потолок счетов — это и есть барьер, о который спотыкаются интеграторы. Заполнить анкету за клиента нельзя: в ней есть юридическое подтверждение о неторговле запрещённым, а даёт его тот, у кого факты. Вы выдаёте клиенту ссылку, он заполняет анкету сам — подробно в статье «KYC клиента через Partner API».
Песочница
Онбординг клиента целиком (организация → кассир → счёт → вебхук → возврат) проходится в песочнице на мок-контуре: ни одного реального вызова Kaspi и ни одной SMS.
⚠️ Синхронизация подписки и выдача ссылки на анкету — исключение: это денежные двери, и на
тестовой организации они отвечают 409 test_organization. Отлаживайте их на одной боевой
организации — она доступна после перевода партнёра в production, до этого боевые вызовы
отбиваются 403 production_access_required. Подпись, коды отказов и журнал работают на ней
ровно так же, а state: suspended тариф не трогает вовсе. Проверить саму подпись, не задевая тариф, дешевле всего
заведомо неверным телом: 422 в ответе означает, что подпись уже сошлась.
Вебхуки
На ваш webhook_url приходят события по клиентам: tariff.activated (клиент оплатил тариф
по вашему счёту), kyc.status_changed (решение по анкете), invoice.status_changed и
invoice.refunded (движение по счетам клиента).
⛔ Секрет вебхуков — другой, не тот, которым вы подписываете входящую синхронизацию.
Исходящие подписываем мы (X-Webhook-Signature), входящие подписываете вы
(X-ApiPay-Signature). Это две разные роли и два разных ключа; путаница между ними —
самая частая причина «подпись не сходится».
Детали проверки подписи — «Настройка вебхуков».
Частые ошибки
| Ответ | Что не так | Что делать |
|---|---|---|
401 invalid_signature |
Тело пересобрано перед отправкой либо не тот секрет | Отправлять подписанную строку байт-в-байт; сверить secret_hint в /health |
401 signature_expired |
Разъехались часы отправителя | Синхронизировать время на сервере, повторить с новой меткой |
401 inbound_not_configured |
Секрет ещё не выдан | Запросить у ApiPay; состояние видно в /health |
403 inbound_sync_disabled |
Приём выключен тумблером | Подпись верна — вопрос к ApiPay, не к коду |
409 test_organization |
Пробуете на тестовой организации | Отлаживать на боевой (см. «Песочница») |
409 claimed_paying_organization |
Клиент платит ApiPay сам | Перевод на договорные условия оформляет ApiPay |
422 subscription_terms_required |
При state: active нет tier или paid_through |
Дослать оба поля |
413 payload_too_large |
Слишком большое тело | Не класть в partner_context всё подряд |
| Полный перечень кодов — на странице /errors, партнёрская дока — /partners. |