Коротко
Счёт проходит такой путь:
new ──▶ pending ──┬──▶ paid ──┬──▶ refunded
│ └──▶ partially_refunded
├──▶ cancelled
└──▶ expired
В коде различайте две группы: открытые счета — new и pending; считающиеся оплаченными — paid, refunded, partially_refunded. Возвращённый счёт остаётся оплаченным: деньги приходили, потом были возвращены.
Самый важный пограничный случай — поздняя оплата: деньги могут прийти уже по закрытому счёту.
Таблица статусов
| Статус | Значение | Открыт | Считается оплаченным |
|---|---|---|---|
new | Счёт создан, ещё регистрируется в Kaspi | Да | Нет |
pending | Ждёт оплаты покупателем | Да | Нет |
paid | Оплачен | Нет | Да |
cancelled | Вы его отменили | Нет | Нет |
expired | Истёк срок, оплаты не было | Нет | Нет |
refunded | Возвращён полностью | Нет | Да |
partially_refunded | Возвращён частично | Нет | Да |
При фильтрации списка и в отчётах опирайтесь именно на эти две группы, а не перечисляйте каждый статус по отдельности — тогда добавление нового статуса не сломает ваш код.
Переходы
| Откуда | Куда | Что произошло |
|---|---|---|
new | pending | Счёт зарегистрирован в Kaspi, покупатель может платить |
pending | paid | Покупатель оплатил |
pending | cancelled | Вы отправили POST /invoices/{id}/cancel |
pending | expired | Прошёл expiresAt, никто не оплатил |
paid | refunded | Возвращена вся сумма |
paid | partially_refunded | Возвращена часть суммы |
cancelled / expired | paid | Поздняя оплата. Деньги пришли с опозданием |
Обратных переходов нет: оплаченный счёт не станет снова pending, а истёкший сам собой не оживёт.
Какое событие приходит когда
| Событие | Когда | Особенность тела |
|---|---|---|
invoice.created | При создании счёта | id, externalOrderId, amount |
invoice.pending | Когда счёт готов к оплате | — |
invoice.paid | Когда покупатель оплатил | paidAt, receiptUrl |
invoice.cancelled | При отмене | — |
invoice.expired | По истечении срока | — |
invoice.failed | Когда счёт не прошёл | — |
invoice.refunded | При полном возврате | — |
invoice.partially_refunded | При частичном возврате | — |
invoice.status | Общее событие на любую смену статуса | Если нужен один канал на все переходы |
invoice.lost | Если счёт потерян на стороне Kaspi | — |
Список событий выбирается в разделе Интеграции кабинета. Включать все не нужно: чаще всего достаточно invoice.paid и invoice.refunded. Настройка: Настройка вебхуков.
Тайминги
| Что | Сколько |
|---|---|
| Окно сканирования QR | Примерно 3 минуты, точное время в поле expiresAt |
| Цикл проверки оплаты | Каждые 3 секунды |
| Счёт моложе 3 минут | Проверяется на каждом круге |
| Счёт до 30 минут | Каждые 20 секунд |
| Более старый счёт | Каждые 90 секунд |
| От оплаты покупателем до вебхука | Обычно в пределах 5 секунд |
Очередь начинается с самых свежих счетов, поэтому только что созданный проверяется первым.
Kaspi не передаёт время оплаты: в данных нет точного момента платежа, поэтому измерить интервал «от подтверждения покупателем до того, как мы узнали» по данным Kaspi невозможно.
Подробнее про сроки: Срок жизни счёта.
Поздняя оплата: главный пограничный случай
По счёту, который уже закрылся как cancelled или expired, деньги могут прийти с опозданием. В этом случае событие invoice.paid придёт позже с пометкой late: true.
Это не ошибка, и случается это редко, но случается. Игнорировать такое событие нельзя — деньги действительно поступили.
Что делать:
- Примите событие и верните 200.
- Найдите заказ и посмотрите его текущее состояние.
- Выберите одно из двух: оказать услугу (товар есть, заказ не аннулирован) или вернуть деньги (API возвратов).
- Сообщите покупателю. Молчание — худший вариант.
if (event === 'invoice.paid') {
const order = await findOrder(invoice.externalOrderId);
if (invoice.late && order.status === 'closed') {
await notifyStaff(order, invoice); // нужно решение человека
} else {
await fulfil(order, invoice);
}
}
Подробнее: Поздняя оплата.
Как читать статус
Каналов два, и они не заменяют друг друга:
| Канал | Когда использовать |
|---|---|
| Вебхук | Основной канал. Приходит сам при смене статуса |
GET /api/v1/invoices/{id} | Когда нужен текущий статус конкретного счёта |
В сценариях, где покупатель стоит у устройства и ждёт результата (вендинг, турникет, шлагбаум), используйте оба сразу: первые 30 секунд опрашивайте статус примерно раз в секунду и принимайте то, что придёт раньше. Поэтому обработка обязана быть идемпотентной. Подробнее: Вебхук или опрос статуса.
В ответе GET /api/v1/invoices/{id} приходят также события и возвраты по счёту — удобно как журнал.
Идемпотентная обработка
Одно и то же событие по счёту может прийти несколько раз: оборвалась сеть, вы не успели ответить 200, мы повторили. Поэтому выполняйте обработку ровно один раз по паре (invoice.id, status).
На практике это выглядит так: записываете эту пару в свою базу с уникальным индексом, и если запись не прошла (значит, уже было) — ничего не делаете. Подробнее: Идемпотентность.
Вопросы и ответы
Считается ли refunded оплаченным? Да. Деньги приходили, потом были возвращены. Не исключайте такие счета из группы оплаченных — возврат это отдельный показатель.
Счёт долго висит в pending? Это нормально: покупатель ещё не оплатил. После expiresAt счёт станет expired. Подробнее: Счёт завис в статусе pending.
Я не вижу статуса new, сразу приходит pending. Это обычное дело: счёт регистрируется в Kaspi быстро, поэтому new часто проскакивает незаметно.
Можно ли снова открыть отменённый счёт? Нет. Создайте новый.
А если я пропустил смену статуса? Журнал вебхуков хранится в разделе Интеграции кабинета, а текущий статус счёта всегда можно прочитать запросом GET /api/v1/invoices/{id}.