Как это работает у вас
CRM: сделка перешла в «Выставить счёт»
│
POST /invoices { phone_number, amount, external_order_id_idempotency: deal_id }
│ 201, status: processing → счёт уходит покупателю push'ем в Kaspi
│
Покупатель оплачивает (у него 24 часа)
│
Вебхук invoice.status_changed { status: paid, external_order_id: ... }
│
CRM: находит сделку по external_order_id → двигает карточку в «Оплачено»
Никакого «входа в Kaspi по номеру телефона» из кода — CRM ходит только в REST ApiPay с заголовком X-API-Key; сессию с Kaspi держит ApiPay.
Минимальный контракт интеграции
Весь Kaspi API для CRM сводится к трём вызовам:
| # | Вызов | Направление | Зачем |
|---|---|---|---|
| 1 | POST /invoices |
CRM → ApiPay | Создать счёт: phone_number (формат 8XXXXXXXXXX), amount, description ≤500, external_order_id, external_order_id_idempotency |
| 2 | POST {ваш webhook} — invoice.status_changed |
ApiPay → CRM | Единственный триггер движения сделки: paid / cancelled / expired / error |
| 3 | GET /invoices/{id} |
CRM → ApiPay | Сверка/восстановление: если вебхук потерялся или нужен ручной рефреш карточки |
Этого достаточно для «карточка двигается по оплате». Возвраты (POST /invoices/{id}/refund — см. возвраты) и корзина cart_items (при Kaspi ОФД) добавляются потом, по мере надобности.
Пошаговый сетап
- Ключи: в кабинете apipay.kz создайте API-ключ и вебхук-секрет. Это разные вещи: ключ — для ваших запросов, секрет — для проверки подписи входящих вебхуков (разница).
- Песочница: прогоните весь цикл на sandbox-организации — там есть simulate-оплата. Внимание: при переходе в рабочий режим тестовые организации и их ключи удаляются — ключи прода будут новые.
- Создание счёта из CRM:
curl -X POST https://api.apipay.kz/api/v1/invoices \
-H "X-API-Key: ВАШ_КЛЮЧ" \
-H "Content-Type: application/json" \
-d '{
"phone_number": "8701XXXXXXX",
"amount": 45000,
"description": "Сделка #4812, консультация",
"external_order_id": "deal-4812",
"external_order_id_idempotency": "deal-4812"
}'
Ответ — 201 со status: "processing": счёт создаётся асинхронно, Kaspi ещё не вызван. Не пересоздавайте счёт, пока он в processing, — получите два живых счёта.
- Приём вебхука: эндпоинт в CRM, проверка подписи
X-Webhook-Signature: sha256=<hex>— HMAC-SHA256 по сырому телу запроса (пошагово). Отвечайте2xxбыстро, обработку — в очередь. - Движение сделки: по
external_order_idиз payload находите сделку;paid→ «Оплачено»,expired→ «Просрочен, перевыставить»,error→ на разбор менеджеру. - Обработчик должен быть идемпотентным: ApiPay ретраит недоставленные вебхуки (до 11 попыток с нарастающим интервалом) — повторная доставка
paidне должна двигать сделку дважды.
Идемпотентность: главная страховка CRM
CRM-системы ретраят HTTP-запросы — так рождаются «лавины дублей»: один клиент, пять одинаковых счетов, паника. Защита встроена:
external_order_id_idempotencyуникален в пределах организации (до 191 символа). Повтор →409 duplicate_idempotency_keyсinvoice_idи статусом уже существующего счёта — просто используйте его.- Исключение by design: если прежний счёт уже мёртв (
expired,cancelled,error), повторный POST с тем же ключом создаст новый счёт — это штатное перевыставление, 409 не будет. - Лучший ключ — ID сделки/заказа в вашей CRM:
deal-4812. Ключ на попытку запроса (deal-4812-retry2) — антипаттерн, он отключает защиту.
Несколько CRM или отделов — несколько токенов
Если счета от одного юрлица выставляют две системы (например, CRM продаж и учётная система — для 1С есть отдельная страница), не делите один ключ — создайте отдельный API-ключ на каждую. У каждого ключа свой вебхук; в payload вебхука поле source содержит имя ключа-создателя, так что «чей счёт» всегда видно. Оплата тарифа при этом одна — тариф считается по организации, а не по числу ключей.
Грабли этого бизнеса
- Спам-цикл ретраев. No-code/ИИ-CRM без идемпотентности умеют выставить сотни счетов за минуты. Аварийный kill-switch — удалить API-ключи в кабинете, затем чинить логику ретраев и включать
external_order_id_idempotency. - Двойное движение сделки. Вебхуки доставляются повторно при ретраях — дедупите по
invoice.id+statusна своей стороне. processing— не зависание. При высокой нагрузке счёт легитимно может висеть вprocessingдольше 60 минут; система сама ретраит. Не пересоздавайте — смотритеlast_kaspi_error_*вGET /invoices/{id}.- Каталог и скидки. Если у вас Kaspi ОФД, без
cart_itemsбудет 422; при своих скидках надёжнее пересчитать цену на своей стороне и передатьpriceпозиции явно — см. cart_items и 422. - Ключи после прода. После перехода из песочницы ключи другие; «вчера работало, сегодня 401» — почти всегда старый ключ в конфиге CRM.
Типовой сценарий: карточка двигается сама
Обобщённый пример. У компании самописная CRM без собственного платёжного API, а вход в Kaspi возможен только по номеру телефона. Задача сводится к простому: выставлять счёт покупателю-физлицу и автоматически двигать карточку сделки, когда оплата прошла. Интеграция строится так: кнопка «Выставить счёт Kaspi» в карточке вызывает POST /invoices, где ключом идемпотентности служит deal_id сделки; вебхук-эндпоинт проверяет подпись и ставит фоновую задачу передвинуть сделку в нужный статус. Когда со временем счета начинает выставлять и вторая система (например, учётная), ей заводят отдельный токен — счета обеих систем различают по полю source в вебхуке. Ручная сверка по утрам отпадает: GET /invoices/{id} дёргается только если карточка «застряла».
Рекурренты: абонементы из CRM
Если CRM ведёт абонементы (спортзал, подписка на сервис, рассрочка), не пишите свой крон — используйте подписки ApiPay. Подписка = авто-выставление счетов (не автосписание с карты — в Kaspi его нет): система сама создаёт счёт в срок, клиент подтверждает оплату push'ем.
POST /subscriptions: phone_number, billing_period (daily/weekly/biweekly/monthly/quarterly/yearly), billing_day (1–28), сумма — amount (min 100) либо cart_items (для каталог-организации), external_subscriber_id = ID клиента в CRM, параметры ретраев (max_retry_attempts 1–10, retry_interval_hours 1–168, grace_period_days 1–30), bill_immediately (выставить первый счёт сразу).
curl -X POST https://api.apipay.kz/api/v1/subscriptions \
-H "X-API-Key: ВАШ_КЛЮЧ" -H "Content-Type: application/json" \
-d '{ "phone_number": "8XXXXXXXXXX", "amount": 15000,
"billing_period": "monthly", "billing_day": 5,
"subscriber_name": "Клиент CRM #88",
"external_subscriber_id": "crm-client-88",
"description": "Абонемент, ежемесячно" }'
Что система делает сама (CRM ничего не пересоздаёт): в срок выставляет счёт → по нему идут обычные invoice-вебхуки; при неоплате шлёт subscription.payment_failed и перевыставляет счёт, пока не исчерпаны попытки; затем subscription.grace_period_started; после grace — subscription.expired, и биллинг останавливается навсегда (реактивации нет — заводите новую подписку). Управление: pause/resume/cancel (resume пересчитывает next_billing_at от текущего момента, пропущенные периоды не доначисляются).
Важно: два нюанса для CRM. События subscription.* не пишутся в webhook-логи и не имеют ручного retry — дедупьте по (событие, subscription.id) и не теряйте. И известный gap: счёт подписки со status=error (например client_not_found) провалом платежа не считается — payment_failed не приходит, период «пропущен»; CRM узнаёт об этом только из invoice-вебхука error. Полный жизненный цикл, кейсы и цены — на витрине Рекуррентные платежи Kaspi и в статье Подписки ApiPay.
Филиалы и кассиры
Одна организация может иметь несколько касс/торговых точек. Кассу для счёта выбираете полем kaspi_connection_id в POST /invoices (по умолчанию — основная касса). Если активных касс больше одной, основная не назначена и параметр не передан — вернётся 422 connection_ambiguous: передайте явный kaspi_connection_id.
Управлять кассирами прямо из CRM можно через /connections* — но только если у ключа включён флаг can_manage_cashiers (включает владелец в кабинете; без флага — 403 cashier_management_disabled). Переавторизация «касса слетела» — три шага: auth/init → auth/send-phone (Kaspi шлёт SMS кассиру на 7XXXXXXXXXX) → auth/verify-otp. Разделение отчётности и счетов по точкам — Раздельная отчётность по точкам.
Мониторинг: «касса слетела» и дашборд
Фоновый мониторинг CRM строится на GET /account/health: поле connection.session_status — так CRM детектит «слетела Kaspi-сессия» (вебхука на это нет); при плохом статусе — алерт менеджеру и запуск переавторизации. Там же tariff (дни до конца) и invoicing.accumulating. Для дашборда — GET /invoices/stats (period today/week/month/year или start_date+end_date) → виджеты «оплачено за период», «конверсия» (conversion_rate), «в ожидании» (включает processing). GET /status — liveness без авторизации.
Важно: тайм-зоны. Счета, возвраты и подписки в ответах — UTC +00:00, но GET /tariff и GET /account/health отдают +05:00 (Asia/Almaty). Частый баг — не учесть эту разницу в дашборде.
Частые вопросы
У нас нет своего API — CRM только «умеет ходить наружу». Хватит?
Да: наружу нужен один POST (создать счёт), внутрь — один URL для вебхука. Если CRM не может принять вебхук, остаётся поллинг GET /invoices/{id} — или сборка связки без своего сервера через n8n; но вебхук надёжнее и «мгновеннее».
Что писать в external_order_id и чем он отличается от ..._idempotency?
external_order_id — просто ваша метка, вернётся в вебхуке для поиска сделки. external_order_id_idempotency — защитный ключ от дублей (повтор → 409). Обычно оба равны ID сделки.
Можно ли с двух CRM-аккаунтов выставлять счета от одного Kaspi?
Да — заведите каждому отдельный API-токен, так удобнее различать источники; тариф от числа токенов не меняется.
Как протестировать без реальных оплат?
В песочнице: тот же API, simulate-оплата, webhook.test для проверки подписи. Подписки прогоняются симуляциями simulate-invoice/start-simulation/stop-simulation. Перед продом перечитайте песочница и рабочий режим — ключи в проде будут новые.
Можно ли делать регулярные списания/абонементы из CRM?
Не автосписание с карты (в Kaspi его нет), а авто-выставление счетов: подписка сама создаёт счёт в срок, клиент подтверждает push'ем. Заводится через POST /subscriptions, привязка к клиенту CRM — external_subscriber_id. Истёкшую подписку не реактивировать — создавать новую. Подробно — Подписки ApiPay.
Как выставлять счета с разных касс или филиалов одной организации?
Передавайте kaspi_connection_id при создании счёта. Если активных касс больше одной и основная не назначена — без параметра вернётся 422 connection_ambiguous. Разделение отчётности по точкам — в статье Раздельная отчётность по точкам.