Сколько на самом деле живёт QR
Окно на скан задаёт Kaspi — меньше трёх минут. Точный момент приходит в qr_expires_at, оставшееся время считайте как qr_expires_at − now и не зашивайте длительность константой.
Окно ограничивает только скан: оплата, начатая под конец окна, завершится уже после qr_expires_at.
Терминальный статус (paid/cancelled/expired) ставит Kaspi и приносит вебхуком — ориентируйтесь на него, а не на локальный отсчёт. Продления нет: нужен новый QR — создавайте новый счёт.
Показывайте покупателю таймер до qr_expires_at, а по его истечении — кнопку «Создать новый QR».
Когда покупатель сканирует истёкший QR, приложение Kaspi показывает «Попробуйте позже»: ждать бесполезно, нужен новый QR. Разбор остальных причин этого экрана — «QR показывает «Попробуйте позже»».
Как создать QR-счёт
curl -X POST https://api.apipay.kz/api/v1/invoices/qr \
-H "X-API-Key: ВАШ_КЛЮЧ" \
-H "Content-Type: application/json" \
-d '{
"amount": 5000,
"description": "Заказ №123"
}'
Ответ — сразу 201 со статусом pending (QR-создание синхронное, в отличие от счёта по номеру):
{
"id": 1,
"amount": "5000.00",
"status": "pending",
"qr_token_url": "https://qr.kaspi.kz/...",
"qr_image_url": "https://.../storage/qr/abc.png",
"qr_expires_at": "2026-07-02T12:05:00+05:00"
}
qr_image_url— готовая PNG-картинка QR на хранилище ApiPay: показывайте её как есть, рендерить QR самим не нужно. Картинка живёт доqr_expires_at + 60 секунд, дальше отдаёт404— это ожидаемо, перевыпустите QR, а не перезагружайте картинку.qr_token_url— платёжная ссылка Kaspi: её можно открыть на телефоне напрямую (без сканирования).- Телефон покупателя не нужен — в этом смысл QR-формата.
- В песочнице доступно поле
simulate: paid | cancelled | expired— протестировать все исходы без реальной оплаты.
Ограничения QR-счёта, о которых надо знать заранее
- Описание — до 100 символов. Больше — ошибка «Описание QR-счёта не должно превышать 100 символов». У счёта по номеру лимит 500.
- Частота. Потолок — 60 QR в минуту на организацию, превышение даёт
429 qr_rate_limit. ЗаголовкаRetry-Afterв этом ответе нет — повторите запрос примерно через минуту. Лимит общий на организацию (не на ключ и не на кассира) и действует в том числе в песочнице. Отдельно действует суточный лимит тарифа — «Дневной лимит счетов по тарифу». - Ошибки создания:
503с телом{"error": "kaspi_session_invalid"}— авторизация кассира оборвана, QR не создан и в кабинете не появится, повтор запроса не поможет: переподключите кассира в «Настройки → Авторизация Kaspi». Поляerror_codeв этом теле нет — разбирайтеerror. Дальше:403 tariff_inactive— подписка ApiPay неактивна, льготного периода нет (в песочнице этот гейт не действует);502 kaspi_error— ошибка на стороне Kaspi;500 qr_render_failed— не удалось сформировать картинку, повторите создание. - Возврат. Обычный
POST /invoices/{id}/refundприменим и к оплате по QR; если Kaspi отказал — «Возврат по QR через API». - Pending-вебхука нет: статус
pendingвы уже получили синхронно в201; следующий вебхук будетpaid/cancelled/expired. - Организация с каталогом. QR-счёт такой организации создаётся только с корзиной: запрос с одним
amountвернёт422 This organization requires cart items. Передавайтеcart_items— так же, как в счёте с корзиной.
Можно ли держать несколько активных QR одновременно?
Да. QR-счета сосуществуют: создание нового QR не отменяет предыдущие, каждый созданный QR оплачиваем до своего истечения. Два параллельных запроса на создание оба получают 201. Реагируйте на paid/cancelled/expired по каждому invoice.id отдельно: при оплате нескольких QR придёт несколько paid. cancelled по QR-счёту означает реальную отмену покупателем — он закрыл оплату или свернул приложение, не подтвердив.
Практическое следствие: для нескольких покупателей в очереди можно спокойно создавать по QR каждому.
Можно ли отменить QR-счёт?
Нет. POST /invoices/{id}/cancel для QR-счёта (is_qr_token: true) отвечает 409 qr_cancel_unsupported: статус счёта не меняется, в Kaspi ничего не уходит. В теле ответа приходит expires_at — момент, после которого QR перестанет быть оплачиваемым.
Делать при этом ничего не нужно: QR гаснет сам по истечении окна на скан и уезжает в expired. Нужен другой счёт — просто выставьте новый, старый не мешает.
Пока счёт не перешёл в expired, считайте QR активным: оплатить его могут до конца окна на скан, даже если ваш код уже пометил заказ отменённым.
В песочнице поведение другое: тестовый QR-счёт отменяется как обычный — ответ 200 и статус cancelled. Не калибруйте боевую логику по песочнице, в рабочем режиме придёт 409 qr_cancel_unsupported.
Отмена по-прежнему работает для счетов на номер телефона — там ответ 202 и статус cancelling.
Как узнать, что покупатель отсканировал QR?
Событие invoice.qr_scanned приходит вебхуком один раз на QR: статус счёта остаётся pending, добавляется маркер qr_substate: "scanned". Это удобно для UI кассы («Клиент сканирует…»), но помните: скан — не оплата. После скана возможны и paid, и cancelled (покупатель закрыл или свернул приложение) — интерфейс должен уметь вернуться из «Ожидается подтверждение» в исходное состояние. Настройка вебхуков — «Как настроить вебхуки ApiPay».
QR или счёт по номеру: как выбрать?
| Критерий | QR-счёт | Счёт по номеру |
|---|---|---|
| Окно на скан | Меньше трёх минут (точный момент — qr_expires_at); оплата, начатая до конца окна, завершается позже |
24 часа |
| Покупатель | Рядом, платит сейчас (касса, витрина, самовывоз, оплата на сайте «здесь и сейчас») | Удалённо, оплатит когда увидит push |
| Нужен ли номер телефона | Нет | Да (формат 8XXXXXXXXXX, номер должен быть в Kaspi) |
| Описание | ≤100 символов | ≤60 символов |
| Создание | Синхронное: 201 pending + QR сразу |
Асинхронное: 201 processing, затем вебхук |
| Канал доставки | Экран/картинка/ссылка | Push в приложении Kaspi |
Простое правило: покупатель перед вами (или перед экраном) — QR; покупатель «где-то там» — счёт по номеру.
Вопросы и ответы
Можно ли продлить время жизни QR-счёта?
Нет. Окно на скан задаёт Kaspi — меньше трёх минут, точный момент в qr_expires_at. Нужно дольше — счёт по номеру телефона (24 часа) или печатный QR под сделку.
Как создать QR-счёт, если у организации подключён каталог?
Только с корзиной: передавайте cart_items вместо одного amount.