Как сделать возврат: кабинет и API
Из кабинета (без кода): apipay.kz → раздел «Счета» → найдите оплаченный счёт → кнопка возврата справа → укажите сумму (по умолчанию — вся доступная) → подтвердите. Дальше всё автоматически.
Через API:
curl -X POST "https://api.apipay.kz/api/v1/invoices/{id}/refund" \
-H "X-API-Key: ВАШ_КЛЮЧ" \
-H "Content-Type: application/json"
curl -X POST "https://api.apipay.kz/api/v1/invoices/{id}/refund" \
-H "X-API-Key: ВАШ_КЛЮЧ" \
-H "Content-Type: application/json" \
-d '{"amount": 1500}'
Ответ — 201 со статусом возврата pending. Это не «деньги вернулись», это «заявка принята»: дальше система асинхронно проводит возврат в Kaspi (до 3 попыток) и присылает вебхук invoice.refunded с итоговым статусом completed или failed. Жизненный цикл возврата: pending → processing → completed | failed.
Если счёт вернуть нельзя, API откажет сразу синхронно: 400 Invoice is not refundable — возвращаются только оплаченные и не полностью возвращённые счета. Список возвратов: GET /api/v1/invoices/{id}/refunds и общий GET /api/v1/refunds.
Частичный возврат: по сумме или по штукам?
Три способа, выбирайте один:
- Просто сумма —
{"amount": 1500}: вернуть часть денег без привязки к товарам. Подходит счетам без корзины. - По позициям штуками —
return_items: [{"catalog_item_id": 11, "count": 2}]: вернуть 2 штуки конкретного товара из корзины. - По позиции суммой —
return_items: [{"catalog_item_id": 15, "amount": 900}]: частичный возврат неделимой позиции (например, вернуть 900 ₸ из услуги за 5 000 ₸).
Правило API: в каждой позиции return_items[] указывается ровно одно из count (целые штуки) или amount (сумма). В вебхуке частичного возврата по сумме refund.items[].count может быть 0 — это нормально, значит возврат был по сумме, а не по штукам.
Практический совет для счетов с корзиной: если вы пробили весь заказ одной позицией («Услуги», 10 штук), Kaspi сможет вернуть только кратно цене штуки — для произвольной суммы внутри позиции используйте amount у этой позиции.
Сколько ещё можно вернуть по счёту, видно в GET /invoices/{id}: поля total_refunded, is_fully_refunded и available_for_refund (число).
«Возврат создан, но не проходит» — что происходит?
Классическая картина: нажали возврат, статус «создан», деньги покупателю не пришли, статус счёта не изменился. В 9 случаях из 10 причина — на Kaspi-счёте недостаточно средств. Откуда нехватка, если оплата только что пришла?
Считаем на пальцах. Покупатель заплатил 2 000 ₸. Kaspi сразу удержал свою комиссию ~0,95% — на счёт зачислилось примерно 1 981 ₸. Вы делаете возврат на полные 2 000 ₸ — а их на счёте нет, если он был пустой. Kaspi отвечает: «Недостаточно денег на счёте. Пополните счёт или сделайте возврат наличными».
Что делает система в этот момент: автоматически повторяет возврат до 3 раз, после чего фиксирует failed и присылает вебхук. Дальше система сама не повторяет — и это правильно: без пополнения счёта четвёртая попытка кончится так же.
Что делать вам:
- Пополните Kaspi-счёт организации (или дождитесь следующих оплат — комиссия «размажется» по обороту).
- Создайте возврат заново — той же кнопкой или тем же запросом. Сумма после
failedне блокируется, новый возврат создаётся свободно.
Комиссия 0,95% — это условия Kaspi, ApiPay на них не влияет; подробнее о том, кто и за что платит, — «Тарифы и комиссия ApiPay».
Почему статус счёта не меняется на «refunded»?
Потому что такого статуса нет — и это не баг, а модель данных. Счёт и возврат — разные объекты:
- Счёт фиксирует факт продажи: он был оплачен и остаётся
paid. При частичном возврате счёт переходит вpartially_refunded. - Возврат — отдельная операция со своими статусами (
pending → processing → completed | failed). - Полный возврат ставит счёту флаг
is_fully_refunded: true, но статус остаётсяpaid(илиpartially_refunded).
Для интеграции это значит: не ждите вебхук invoice.status_changed со статусом «refunded» — его не будет никогда. Итог возврата приносит событие invoice.refunded (оно приходит и на успех, и на отказ). Учтите два нюанса: у invoice.refunded нет защиты от дублей — дедуплицируйте на своей стороне по паре (refund.id, refund.status); а refund.error_message в вебхуке отсутствует намеренно — детали смотрите в кабинете или через GET /invoices/{id}/refunds. Настройка вебхуков — «Настройка вебхуков ApiPay».
Сроки: окно возврата и зачисление покупателю
- Окно возврата — около 14 дней с момента оплаты. Точный срок контролирует Kaspi; после окна возврат откажет с кодом
refund_window_expired(«Возможно, истёк срок возврата (~14 дн) или возврат уже сделан»). Если окно истекло, деньги покупателю возвращают вне ApiPay — переводом или наличными по вашим правилам торговли. - Зачисление покупателю после успешного возврата происходит на стороне Kaspi.
Ещё один полезный факт: возвраты, сделанные кассиром вручную в приложении Kaspi Pay, ApiPay подтягивает автоматически — они появятся в кабинете и придут тем же вебхуком invoice.refunded. Двойного учёта не будет.
Частые ошибки
- Возвращать полную сумму с пустого Kaspi-счёта — упрётся в нехватку средств из-за комиссии ~0,95%. Пополните счёт и повторите.
- Ждать статус счёта
refunded— его не существует. Смотритеis_fully_refundedи событиеinvoice.refunded. - Повторять возврат сразу после
failedбез пополнения — результат не изменится: причина не в ApiPay, а в балансе. - Пытаться вернуть неоплаченный счёт — API откажет (
400 Invoice is not refundable); неоплаченный счёт нужно отменять, а не возвращать. - Не дедуплицировать
invoice.refunded— событие может прийти повторно; ключ дедупликации:(refund.id, refund.status). - Тянуть с возвратом «до выходных» — окно ~14 дней; чем ближе к краю, тем выше риск
refund_window_expired.
Вопросы и ответы
Как сделать возврат без программиста?
В кабинете apipay.kz: раздел «Счета» → кнопка возврата справа у оплаченного счёта → сумма → подтвердить. Итог придёт вебхуком и будет виден в кабинете.
Можно ли вернуть больше, чем осталось по счёту?
Нет. Доступный остаток показывает поле available_for_refund; попытка вернуть больше отклоняется на валидации.
Возврат прошёл частично — что со счётом?
Счёт переходит в partially_refunded, в GET /invoices/{id} растёт total_refunded. Можно делать следующие частичные возвраты, пока не исчерпан остаток.
Почему я не вижу причину отказа в вебхуке?
refund.error_message в вебхук не включается намеренно. Причина отказа видна в кабинете и в GET /invoices/{id}/refunds; самая частая — недостаточно средств на счёте.
Можно ли запретить возвраты совсем?
Серверного «запрета возвратов» не существует: возврат — штатная функция Kaspi. Контролируйте это на своей стороне — просто не вызывайте возврат из своей интеграции; в кабинете операция доступна владельцу.
Кассир сделал возврат в приложении Kaspi — ApiPay узнает?
Да: такие возвраты импортируются автоматически и приходят тем же вебхуком invoice.refunded.