Коротко
Возврат денег — один метод:
POST /api/v1/invoices/{id}/refund
X-API-Key: qp_live_…
Content-Type: application/json
{ "amount": 1500, "reason": "Товар возвращён" }
Без поля amount счёт возвращается полностью. С amount возвращается только указанная сумма, а счёт переходит в статус partially_refunded.
Два правила, которые стоит запомнить сразу: ключу нужно право refunds:write, а при ошибках refund_unknown и refund_pending_unknown запрос повторять нельзя — вместо повтора вы читаете статус счёта.
Поля запроса
| Поле | Тип | Обязательное | Описание |
|---|---|---|---|
amount | number | нет | Сумма возврата в тенге. Без неё — полный возврат |
reason | string | нет | Причина. Видна в отчёте и в кабинете |
В пути {id} — идентификатор счёта (inv_…). Возврата по externalOrderId нет: сначала найдите счёт через GET /api/v1/invoices?externalOrderId=…, затем используйте его id.
По какому счёту возможен возврат
| Статус счёта | Возврат |
|---|---|
new, pending | Нет. Деньги ещё не пришли — счёт нужно отменить: POST /invoices/{id}/cancel |
paid | Да, полный и частичный |
partially_refunded | Да, в пределах остатка |
refunded | Нет, возвращено всё |
cancelled, expired | Нет |
Деньги уходят не с нашего счёта, а с вашего счёта Kaspi. Мы деньги никогда не держим, поэтому сам возврат выполняет Kaspi. Если остатка не хватает, операция не пройдёт.
Полный и частичный возврат
Полный возврат — запрос с пустым телом или только с reason:
curl -X POST https://api.qut.kz/api/v1/invoices/inv_7Kd2/refund \
-H "X-API-Key: $QUTPAY_API_KEY" \
-H "Content-Type: application/json" \
-d '{"reason":"Клиент отказался"}'
Частичный возврат — запрос с amount. Частичных возвратов может быть несколько, но правило жёсткое:
Сумма всех возвратов не может превышать оплаченную сумму.
Например, по счёту на 10 000 ₸ вы вернули 3 000 и 4 000 — третий раз можно вернуть не больше 3 000. Если запросить больше, придёт invalid_refund_amount. Когда вернёте последний остаток, счёт перейдёт в статус refunded.
Перед очередным возвратом полезно прочитать текущий остаток через GET /api/v1/invoices/{id}: в ответе есть список уже сделанных возвратов по этому счёту.
Коды ошибок
| Код | HTTP | Что случилось | Что делать |
|---|---|---|---|
invoice_not_refundable | 409 | По этому счёту возврат невозможен | Прочитайте статус: оплачен ли, не истёк ли срок |
invalid_refund_amount | 422 | Неверная сумма | Положительное число, не больше оплаченной суммы |
refund_failed | 502 | Kaspi не выполнил возврат | Проверьте остаток на счёте Kaspi, повтор допустим |
refund_unknown | 502 | Kaspi не ответил, результат неизвестен | Не повторять. Прочитайте статус счёта |
refund_pending_unknown | 409 | Результат предыдущего возврата ещё не ясен | Не повторять. Подождите |
refund_state_conflict | 409 | Состояние возврата не то, которого ждали | Перечитайте статус и решайте по нему |
insufficient_scope | 403 | У ключа нет права refunds:write | Добавьте право в кабинете |
invoice_not_found | 404 | Счёта нет или он не виден ключу | Проверьте идентификатор и кассира, к которому привязан ключ |
kaspi_session_expired | 409 | Привязка кассира оборвалась | Переподключите кассира |
Обрабатывайте ошибки по коду error, а не по тексту message: текст может измениться, код — нет.
Когда результат неизвестен
refund_unknown — Kaspi не ответил на запрос. Ушли деньги или нет, в этот момент не знаем и мы. refund_pending_unknown — предыдущий возврат ещё не определён, поэтому новый запрос не принимается.
В обоих случаях порядок одинаковый:
- Не повторяйте запрос. Гарантии идемпотентности у возврата нет — второй запрос может стать вторым возвратом.
- Подождите несколько секунд, при необходимости минуту.
- Прочитайте статус:
GET /api/v1/invoices/{id}. - Если статус стал
refundedилиpartially_refunded— возврат прошёл, повторять не нужно. - Если статус всё ещё
paid, а список возвратов пуст — только тогда повторяйте.
И не говорите покупателю «деньги вернули», пока не увидели это в статусе счёта.
async function refundOnce(id, amount) {
const r = await fetch(`${API}/invoices/${id}/refund`, {
method: 'POST',
headers: { 'X-API-Key': KEY, 'Content-Type': 'application/json' },
body: JSON.stringify({ amount, reason: 'Возврат' }),
});
if (r.ok) return 'done';
const { error } = await r.json();
if (error === 'refund_unknown' || error === 'refund_pending_unknown') {
// не повторяем — читаем статус и решаем по нему
return 'check_status';
}
if (error === 'refund_failed') return 'retry_later';
throw new Error(error);
}
События
После возврата приходит вебхук:
| Событие | Когда |
|---|---|
invoice.refunded | Счёт возвращён полностью |
invoice.partially_refunded | Возвращена часть суммы |
refund.done | Отдельная операция возврата выполнена |
refund.failed | Возврат не выполнен |
refund.unknown | Результат остался неизвестным |
Если ведёте автоматический учёт, опираться на эти события удобнее, чем опрашивать статус. Обработчик вебхуков делайте идемпотентным: два уведомления об одном возврате не должны превратиться в две проводки в бухгалтерии.
Права и кассир
- Ключу нужно право
refunds:write, иначе придётinsufficient_scope. Подробнее: Права доступа (scopes). - Возврат тоже идёт через роль «Кассир» в Kaspi, поэтому привязка кассира должна быть активной. Если она оборвалась, не пройдёт ни возврат, ни создание счёта.
- Если ключ привязан к конкретному кассиру, чужие счета он не видит — на попытку возврата по такому счёту придёт
invoice_not_found.
Срок
Возврат доступен не бессрочно: спустя некоторое время Kaspi перестаёт разрешать возврат по старой операции. Срок задаёт Kaspi, продлить его мы не можем. По просроченному счёту приходит invoice_not_refundable.
В песочнице весь цикл можно безопасно прогнать: создайте счёт, симулируйте оплату, затем отправьте возврат.
Вопросы и ответы
Можно ли отменить возврат? Нет. Прошедший возврат обратно не откатывается — придётся просить покупателя оплатить заново.
Возвращается ли комиссия? Свою комиссию Kaspi определяет сам, уточняйте в поддержке Kaspi. Мы процент с транзакции не берём, поэтому с нашей стороны возвращать нечего.
Когда деньги дойдут до покупателя? Это определяет Kaspi, на сроки мы повлиять не можем.
Можно ли сделать возврат из кабинета? Да, в разделе «Счета» откройте счёт и нажмите кнопку возврата. API и кабинет выполняют одну и ту же операцию.
Где полный список кодов ошибок? В статье Каталог ошибок. Разбор ситуации, когда возврат не проходит: Возврат не проходит.