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

# Как платформе вроде МоегоСклада интегрироваться с ApiPay?

**Эта статья — для разработчика платформы**, а не для мерчанта. Если вы продавец и хотите
выставлять счета Kaspi из МоегоСклада, вам нужна другая страница —
«[МойСклад и Kaspi Pay](/moysklad)».

**TL;DR.** Платформа заводит клиенту организацию через Partner API, подключает кассира и
получает per-org ключ — дальше её собственный биллинг **объявляет состояние подписки** одним
`PUT .../subscription`, подписанным HMAC. Мы приводим тариф клиента в соответствие: срок
двигается **только вперёд**, тир — **только вверх**, отзыва нет. Анкету о бизнесе заполняет
сам клиент по ссылке, которую вы ему выдаёте.

## Коротко

| Вопрос | Ответ |
|---|---|
| Кому подходит | Платформе, которая сама продаёт подписку своим клиентам и рассчитывается с ApiPay по договору |
| Чем управляет платформа | Организации клиентов, кассир, ключи, вебхуки, состояние подписки |
| Чем не управляет | Ценой и суточным лимитом тарифа — они из договорных условий, а не из запроса |
| Форма синхронизации | `PUT` с текущим состоянием (`state`, `tier`, `paid_through`) — не лента событий |
| Подпись входящих | `X-ApiPay-Signature` + `X-ApiPay-Timestamp`, отдельный секрет |
| Отзыв тарифа | Нет. Приостановка у вас доезжает истечением срока — **синхронизируйте помесячно** |
| Анкета клиента | Только сам клиент, по одноразовой ссылке от вас |

## Три модели, и это разные интеграции

ApiPay сосуществует в трёх режимах, и первое, что стоит сделать, — определить свой.

| Модель | Кто платит ApiPay | Признак «это ваш случай» |
|---|---|---|
| **Реферальная** | Клиент сам | Вы приводите клиентов ссылкой и не управляете их аккаунтами |
| **Операционная** | Клиент, но счета выставляете вы | Вы ведёте онбординг, но подписку клиент оплачивает сам |
| **Договорная (эта статья)** | Платформа по договору | Клиент платит **вам**, у вас свой биллинг, ApiPay остаётся на стороне платформы |

В договорной модели вам включают режим назначения тарифа: тариф клиенту вы не оплачиваете
поштучно, а объявляете. Включает его ApiPay по договору — самостоятельной ручки нет.
Проверить, включён ли режим, можно в `GET /api/partner/health`: `account.tariff_billing_mode`
должен быть `assignment`.

## Границы ответственности

- **Ваша сторона:** кто из клиентов подписан, до какой даты, на каком плане; продление,
  приостановка, возвраты, любые скидки и промо.
- **Наша сторона:** работает ли приём Kaspi у клиента, каков его суточный лимит счетов,
  одобрена ли анкета, доставлены ли вебхуки.

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

## Шаг 1. Организация клиента и приём Kaspi

Заведение организации, подключение кассира по SMS-коду и выпуск per-org ключа описаны
отдельно — повторять не будем: «[Полностью автоматический приём Kaspi для
клиентов](/guides/besshovnyy-priyom-kaspi-dlya-klientov-partnyora)» и
«[Подключение организации к партнёру](/guides/partner-connect-organization)».

Здесь важно одно: `external_id` — ваш идентификатор клиента в вашей системе. Он же ключ
идемпотентности: повторный `POST` с тем же `external_id` вернёт ту же организацию, а не
создаст вторую.

## Шаг 2. Секрет подписи входящих

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

Секрет выдаёт ApiPay по запросу, показывается он один раз. Проверить, что дверь открыта и что
ваше окружение подписывает тем же секретом:

```bash
curl https://api.apipay.kz/api/partner/health -H "X-Partner-Key: $KEY"
```

```json
"account": {
  "tariff_billing_mode": "assignment",
  "inbound_sync": {"enabled": true, "secret_configured": true,
                   "secret_hint": "••••a1b2", "accepting": true}
}
```

Смотрите на `accepting` — это и есть «дверь открыта». `secret_hint` — последние 4 символа
выданного секрета: удобно сверить, тем ли подписывает стенд и тем ли прод.

⚠️ Проверяйте состояние здесь, а не боевым запросом: при закрытой двери запрос отвечает
`401`/`403`, и отличить «не тот секрет» от «приём выключен» по ответу нельзя.

## Шаг 3. Синхронизация состояния

```bash
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` всегда показывает, что реально действует:

```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 } } }
```

## Шаг 4. Журнал — чтобы не писать в поддержку

Каждый вызов, включая отказы, попадает в журнал:

```bash
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](/guides/kyc-klienta-cherez-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`). Это две разные роли и два разных ключа; путаница между ними —
самая частая причина «подпись не сходится».

Детали проверки подписи — «[Настройка вебхуков](/guides/nastroyka-webhookov-apipay)».

## Частые ошибки

| Ответ | Что не так | Что делать |
|---|---|---|
| `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](/errors), партнёрская дока — [/partners](/partners).

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

### Чем эта статья отличается от страницы про МойСклад?

Страница «[МойСклад и Kaspi Pay](/moysklad)» — для продавца, который работает в МоёмСкладе и
хочет выставлять счета Kaspi. Эта статья — для разработчика платформы, которая подключает
своих клиентов к ApiPay и ведёт их подписку у себя.

### Можно ли отозвать тариф у клиента, который перестал платить?

Машинного отзыва нет. Присылайте состояние помесячно: выданный период истечёт сам, и клиент
окажется без тарифа без отдельного действия. Немедленное отключение оформляет ApiPay.

### Что будет, если прислать одно и то же состояние дважды?

Ничего: ответ `200` с `result: unchanged`. Повтор безопасен по построению — это свойство
декларативной формы, а не отдельный механизм идемпотентности.

### Мы прислали свой суточный лимит, а применился другой

Так и задумано: лимит и название тарифа определяются договорными условиями. Присланное
значение вернётся в блоке `ignored` рядом с применённым.

### Нужен ли клиенту аккаунт в ApiPay?

В договорной модели — нет: организацию, кассира и ключи заводит платформа, анкету клиент
заполняет по ссылке без регистрации. В готовом решении для МоегоСклада продавец, наоборот,
заводит свой аккаунт ApiPay и заполняет анкету в кабинете — там другая модель.

Смотрите также: [KYC клиента через Partner API](/guides/kyc-klienta-cherez-partner-api) ·
[Автоматический приём Kaspi для клиентов](/guides/besshovnyy-priyom-kaspi-dlya-klientov-partnyora) ·
[Partner API и white label](/guides/partner-api-white-label) ·
[Песочница и рабочий режим](/guides/pesochnitsa-i-rabochiy-rezhim)

---

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