Коротко
Если запрос на возврат вернул refund_unknown (502) или refund_pending_unknown (409), это не значит «возврат не прошёл» — это значит результат пока неизвестен. Если в этот момент повторить запрос, а первый возврат на самом деле прошёл, покупатель получит деньги дважды. Правильное действие одно: не повторяйте, прочитайте статус счёта через GET /api/v1/invoices/{id} и узнайте результат оттуда.
Почему вообще бывает ответ «неизвестно»
Возврат проходит через несколько систем. Запрос может дойти до Kaspi, тот может его выполнить, а ответ до нас не дойти. В такой момент мы отвечаем вам честно: результат неизвестен.
| Код | HTTP | Что это значит |
|---|---|---|
refund_unknown | 502 | Kaspi не ответил, прошёл возврат или нет — неизвестно |
refund_pending_unknown | 409 | Судьба предыдущего возврата ещё не ясна, новый не принимаем |
refund_failed | 502 | Kaspi возврат не выполнил. Это как раз «не прошёл» |
refund_state_conflict | 409 | Состояние возврата отличается от ожидаемого, перечитайте статус |
Разница между refund_failed и refund_unknown — суть всей этой статьи. Первое — определённая неудача, её можно повторить. Второе — неопределённость, и повторять её нельзя.
refund_pending_unknown — это наша защита: пока судьба предыдущего возврата не выяснена, новый мы не пропускаем. Увидев его, считайте, что система прямо сейчас уберегла вас от ошибки.
Правильный порядок действий
- Не повторяйте запрос. Если у вас есть автоматический ретрай, уберите из него возвраты.
- Подождите несколько секунд.
- Отправьте
GET /api/v1/invoices/{id}. В ответе будет статус счёта и список возвратов по нему. - Решайте по статусу:
| Статус счёта | Что это значит | Что делать |
|---|---|---|
refunded | Возвращён полностью | Всё, готово. Не повторяйте |
partially_refunded | Часть возвращена | Посчитайте возвращённую сумму и досылайте только разницу, если её не хватает |
paid | Возврат не прошёл | Только теперь можно повторить |
- Запишите результат в свой журнал, чтобы в следующий раз не выяснять заново.
Если даже после нескольких проверок статус не проясняется, подождите и напишите в поддержку. Мысль «попробую повторить всего один разок» именно здесь самая опасная.
Узнать результат можно и через вебхук
Результат операции приходит и событиями: refund.done, refund.failed, refund.unknown, а при смене статуса счёта — invoice.refunded или invoice.partially_refunded.
То есть после refund_unknown у вас два пути: спросить статус самому или дождаться события. Надёжнее вести оба канала, но результат обоих обрабатывать в одном месте и идемпотентно.
Идемпотентность
Главная защита от двойного возврата — гарантия неповторяемости на вашей стороне.
- Одна «задача на возврат» на один счёт. Храните её записью в своей базе:
id счёта,сумма,состояние(отправлено / подтверждено / неизвестно / не прошло). - Блокируйте запись. Вторая задача на возврат по тому же счёту создаваться не должна. Два параллельных процесса не должны одновременно отправлять возврат по одному счёту.
- Если задачи из очереди повторяются автоматически, пометьте состояние «неизвестно» как неповторяемое. Такая задача должна уходить на проверку, а не на повтор.
- Обработка вебхуков тоже должна быть идемпотентной. Если пара
(id счёта, статус)уже обработана, не делайте ничего. При ответе, отличном от 2xx, доставка повторяется до 11 раз, так что одно событие вполне может прийти к вам несколько раз. - Ручные возвраты тоже фиксируйте в журнале. Если возврат сделали руками из кабинета, а потом ваша система отправила свой, итог будет двойным.
Учёт частичных возвратов
В частичных возвратах сбиться со счёта легче всего. Правило простое: сумма всех возвратов не может превышать оплаченную сумму. Если отправите больше, получите invalid_refund_amount (422) — это хорошо, но полагаться на это не стоит, считайте сами.
Пример: счёт на 10 000 ₸ оплачен. Вы вернули 3 000 ₸ — счёт становится partially_refunded, остаток 7 000 ₸. Если отправить ещё 3 000 ₸, это будет не повтор первого возврата, а новый возврат: всего вернётся 6 000 ₸. Поэтому мысль «просто отправлю ещё раз» в частичных возвратах опаснее всего.
Считайте всегда по списку возвратов из ответа GET /api/v1/invoices/{id}, а не по своим предположениям.
Что должен содержать журнал
Именно он спасает в спорной ситуации.
- Время отправки запроса на возврат, id счёта, сумма
- HTTP-статус ответа и код в поле
error - Последующий ответ
GET /invoices/{id}: статус и список возвратов - Пришедшие события вебхука вместе с заголовком
X-Webhook-Delivery - Кто инициировал возврат: система или человек вручную
Сам ключ в журнал не пишите. При логировании заголовков вырезайте значение X-API-Key.
Вопросы и ответы
Пришёл refund_unknown, а статус счёта paid. Можно повторять? Да. Если статус paid, возврат не прошёл, повторять можно.
Сколько держится refund_pending_unknown? Пока не прояснится судьба предыдущего возврата. Подождите несколько секунд и перечитайте статус счёта.
Если я всё-таки вернул дважды, можно отменить? Нет, операции «отменить возврат» не существует. Придётся связаться с покупателем и выставить новый счёт на излишек.
Есть ли ключ идемпотентности для возвратов? При создании счёта есть заголовок Idempotency-Key. В возвратах защита устроена иначе: пока результат предыдущего возврата неизвестен, новый не принимается (refund_pending_unknown). Собственный журнал и блокировка на вашей стороне всё равно обязательны.
Откуда берутся деньги на возврат? С вашего счёта Kaspi. У нас деньги не хранятся: Когда и куда приходят деньги.