> Источник: https://apipay.kz/guides/vozvrat-po-qr-cherez-api · Обновлено: 2026-10-23 · apipay.kz
> ApiPay — независимый сервис приёма платежей поверх вашего Kaspi Pay.

# Возврат по QR: покупатель подтверждает возврат в Kaspi

**TL;DR.** Если покупатель платил удалённо или обычный [возврат по счёту](/guides/vozvraty-kaspi-cherez-api) не проходит, в ApiPay есть возврат с подтверждением покупателя: вы выпускаете одноразовую ссылку «Возврат ApiPay» (`POST /api/v1/qr-refunds/links` или раздел **«Возврат с подтверждением»** в кабинете) и отправляете её покупателю. Ссылка ждёт его **24 часа** (`link_expires_at`); когда он откроет её и нажмёт кнопку, появится возвратный QR, и отсканировать его тем Kaspi, которым он платил, нужно **не более чем за 90 секунд** (`expires_at`). Ссылка под конкретный счёт (`invoice_id`) проводит возврат сама, без ссылки на счёт вы после подтверждения выбираете покупку и вызываете `execute`.

## Коротко

| Вопрос | Ответ |
|---|---|
| Когда нужен | Покупатель платил удалённо, обычный возврат по счёту не проходит или оплата шла мимо ваших счетов |
| Как попадает к покупателю | Одноразовой ссылкой `POST /qr-refunds/links`: отправьте её в мессенджере |
| Кто подтверждает | Покупатель: открывает ссылку, нажимает кнопку и сканирует возвратный QR в приложении Kaspi |
| Чем сканировать | Тем же Kaspi, которым оплачивал: с другого аккаунта покупка не найдётся. Ссылку он открывает на компьютере или другом телефоне |
| Сколько ждёт ссылка | 24 часа с выпуска (`link_expires_at`), не продлевается |
| Сколько живёт QR | Не более 90 секунд после нажатия покупателя (`expires_at`, `scan_wait_timeout_seconds`). Если Kaspi подтвердит, что скана не было, та же ссылка покажет новый код; окон не больше трёх |
| После подтверждения | Время на возврат короткое, числа в ответе нет: действуйте сразу, дальше `409 qr_refund_expired` |
| Ссылка под счёт | С `invoice_id` возврат проводится сам после подтверждения, итог — вебхуком |
| Частичный возврат | Да: суммой или по товарам — взаимоисключимо |
| Как узнать итог | `200` у `execute` — доказанный успех. `202` — исход не доказан, повторять нельзя. Плюс вебхуки `qr_refund.*` |

## Как сделать возврат по ссылке через API?

Со стороны продавца это три шага: выпустить ссылку «Возврат ApiPay» и отправить её покупателю → дождаться, пока он откроет ссылку и подтвердит возврат в Kaspi → выбрать его покупку и вернуть деньги. Если ссылка выпущена под счёт, третий шаг выполняется сам — см. раздел ниже.

Все запросы — с вашим ключом в заголовке `X-API-Key`.

### 1. Выпустить ссылку покупателю

```bash
curl -X POST https://api.apipay.kz/api/v1/qr-refunds/links \
  -H "X-API-Key: ВАШ_КЛЮЧ" \
  -H "Content-Type: application/json" \
  -d '{}'
```

В ответе — одноразовая ссылка «Возврат ApiPay» и её срок:

```json
{
  "id": 42,
  "status": "awaiting_customer",
  "customer_url": "https://qr.apipay.kz/refund/ОДНОРАЗОВЫЙ_ТОКЕН",
  "link_expires_at": "2026-07-28T17:27:09+05:00",
  "invoice_id": null,
  "refund_amount": null
}
```

⛔ **`customer_url` отдаётся ровно один раз — сохраните сразу.** Получить адрес повторно нельзя ни через `GET /qr-refunds/{id}`, ни как-либо ещё. Ссылка предъявительская: кто её открыл, тот и подтверждает возврат — не публикуйте её и не пишите в логи и аналитику.

Кассир выбирается так: явный `kaspi_connection_id` → основная касса организации → единственная активная. Выбор фиксируется при выпуске.

⚠️ **Отправляйте ссылку только тому покупателю, которому возвращаете деньги.** Сессия опознаёт того, кто открыл ссылку и подтвердил возврат в Kaspi, и покажет ЕГО покупки у вас.

### Какие сроки у ссылки и у QR?

Сроков два, и путать их нельзя:

- **`link_expires_at` — 24 часа ожидания покупателя.** Пока он не нажал кнопку, Kaspi не вызывается и таймер QR не идёт. Срок не продлевается ничем.
- **`expires_at` — окно скана, не более 90 секунд.** Поле появляется только после нажатия покупателя; до этого оно `null`. Действующее значение — в `scan_wait_timeout_seconds`, константу в код не зашивайте.

Если покупатель не успел отсканировать код и Kaspi это подтвердил, сессия возвращается в `awaiting_customer` (`error_code` = `qr_return_scan_timeout`, вебхука нет), и та же ссылка покажет новый код. Окон у одной ссылки не больше трёх, и все — в пределах `link_expires_at`.

Неоткрытую ссылку можно погасить: `DELETE https://api.apipay.kz/api/v1/qr-refunds/links/42` — сессия перейдёт в `expired` с `error_code` `qr_refund_link_revoked`. После подтверждения покупателем отозвать нельзя (`409 qr_refund_link_not_revocable`). Потеряли адрес — отзовите ссылку и выпустите новую; неотозванная действует до `link_expires_at`.

Немедленный старт `POST /qr-refunds` (QR у кассы) в API помечен как устаревший: окно скана не более 90 секунд, и покупатель должен стоять рядом. Новую интеграцию стройте на ссылке.

### 2. Дождаться подтверждения

```bash
curl https://api.apipay.kz/api/v1/qr-refunds/42 -H "X-API-Key: ВАШ_КЛЮЧ"
```

Опрашивайте с интервалом из `poll_interval_seconds`, пока статус не станет `customer_identified` (покупатель подтвердил) или `expired` (ссылка исчерпана: истёк срок, отозвана или пропущено последнее окно). Вместо опроса можно слушать вебхуки `qr_refund.*`.

⚠️ **После `customer_identified` не тяните.** Время на выбор покупки и возврат ограничено отдельно от окна скана, оно короткое, и числа в ответе нет. Если продавец задумался, сессия истечёт — и запрос на возврат вернёт `409 qr_refund_expired` уже после того, как человек выбрал покупку и ввёл сумму. Тогда нужна новая ссылка.

### 3. Посмотреть покупки

```bash
curl https://api.apipay.kz/api/v1/qr-refunds/42/operations -H "X-API-Key: ВАШ_КЛЮЧ"
```

```json
{
  "client_name": "Иван И.",
  "operations": [
    { "ref": "…", "amount": 500, "date": "2026-07-25T16:12:52+05:00", "returnable": "full" },
    { "ref": "…", "amount": 10,  "date": "2026-07-25T16:16:54+05:00", "returnable": "partial" },
    { "ref": "…", "amount": 10,  "date": "2026-06-30T20:19:31+05:00", "returnable": "none" }
  ],
  "has_more": false,
  "next_cursor": null,
  "remaining_count": 0
}
```

`returnable` говорит, что Kaspi разрешает по этой покупке: `full` — только целиком, `partial` — можно частично, `none` — возврат недоступен. Если покупок больше, передавайте `next_cursor` в `?cursor=`.

⚠️ **`ref` непрозрачен и привязан к сессии.** Не разбирайте его на части, не показывайте покупателю и не сохраняйте между сессиями — просто возвращайте обратно как есть.

### 4. Детали покупки

```bash
curl https://api.apipay.kz/api/v1/qr-refunds/42/operations/REF -H "X-API-Key: ВАШ_КЛЮЧ"
```

Приходят доступная к возврату сумма, уже возвращённое, ссылка на чек и позиции корзины — у каждой свой `ref` и свой остаток.

### 5. Вернуть деньги

```bash
# целиком
curl -X POST https://api.apipay.kz/api/v1/qr-refunds/42/execute \
  -H "X-API-Key: ВАШ_КЛЮЧ" -H "Content-Type: application/json" \
  -d '{"operation_ref":"REF"}'

# частично, суммой
  -d '{"operation_ref":"REF","amount":7}'

# частично, по товарам
  -d '{"operation_ref":"REF","items":[{"ref":"ITEM_REF","amount":5}]}'
```

`amount` и `items` **взаимоисключимы** — вместе их присылать нельзя. У позиции `amount` необязателен: без него вернётся вся её доступная сумма.

Ответ `200`:

```json
{
  "id": 42,
  "status": "completed",
  "refunded_amount": "500.00",
  "receipt_url": "https://receipt.kaspi.kz/…",
  "client_name": "Иван И.",
  "completed_at": "2026-07-27T17:20:11+05:00"
}
```

## Что означает ответ execute и когда можно повторить?

Ответ приходит сразу, и классов у него три:

| Ответ | Что произошло | Повторять? |
|---|---|---|
| `200` | Деньги вернулись, это доказано. Придёт `qr_refund.completed` | Нет, возврат сделан |
| `409 qr_refund_not_identified`, `422 operation_not_returnable` / `refund_amount_exceeds_available` / `partial_refund_requires_return_items`, `503` | Денежный запрос не уходил, сессия жива | Да: после подтверждения, с другой покупкой или суммой; при `503` — после паузы из `Retry-After` |
| `422 refund_insufficient_funds`, `502 kaspi_error` | Kaspi отказал, деньги не двигались | Да, на той же сессии, пока не истёк срок после подтверждения. Пополнение счёта обычно не успевает — тогда новая ссылка |
| `202` (`qr_refund_execution_uncertain`, `qr_refund_execution_result_unavailable`) | Запрос ушёл, исход не доказан: деньги могли уйти | ⛔ **Нет** |

Остальные `409` (`qr_refund_expired`, `qr_refund_completed`, `qr_refund_execution_in_progress`, `qr_refund_execution_uncertain`, `qr_refund_execution_attempts_exhausted`) — не повторяйте `execute`: что делать, смотрите в таблице ошибок ниже.

⛔ **`202` — не успех и не «принято, ждите».** Возврат мог быть применён. Не повторяйте `execute`, не выпускайте новую ссылку, не делайте тот же возврат через `POST /invoices/{id}/refund` или вручную в приложении Kaspi Pay — второй запрос вернёт покупателю деньги ещё раз. Обратитесь в поддержку ApiPay. Заголовка `Retry-After` у `202` нет намеренно.

Число денежных попыток по одной сессии ограничено: исчерпав его, `execute` отвечает `409 qr_refund_execution_attempts_exhausted` и Kaspi не вызывает — нужна новая ссылка.

## Как вернуть деньги по конкретному счёту ссылкой?

Если покупатель платил по счёту ApiPay, выпускайте ссылку под этот счёт — тогда выбирать покупку и вызывать `execute` не нужно:

```bash
curl -X POST https://api.apipay.kz/api/v1/qr-refunds/links \
  -H "X-API-Key: ВАШ_КЛЮЧ" -H "Content-Type: application/json" \
  -d '{"invoice_id": 1234, "amount": "500.00", "reason": "Возврат товара"}'
```

- **`invoice_id`** — `id` оплаченного счёта вашей организации (не номер Kaspi). Покупатель на странице ссылки видит продавца, сумму, дату и состав покупки. Чужой, неоплаченный или уже возвращённый целиком счёт → `422 invoice_not_refundable`.
- **Что вернуть:** без суммы — весь остаток счёта; `amount` — часть плоской суммой; `return_items` (`catalog_item_id` и `count` или `amount`) — выбранные товары. Они взаимоисключимы и требуют `invoice_id`. У мерчанта с каталогом частичная сумма отклоняется, если остаток к возврату есть больше чем у одной позиции счёта, (`422 partial_refund_requires_return_items`) — присылайте `return_items`.
- **`source_refund_id`** — если обычный возврат по счёту отказан с `refund_requires_buyer_confirmation` или `refund_rejected_by_kaspi`, передайте его id: сумма, товары и причина восстановятся из отказанного возврата. `amount` и `return_items` вместе с ним не передаются.
- **Автопроведение.** Как только покупатель подтвердил себя, возврат проводится сам — по операции именно этого счёта. Итог приходит вебхуком: `qr_refund.completed`, `qr_refund.failed` с причиной в `error_code` (деньги не двигались, повтор — новой ссылкой) или `qr_refund.execution_uncertain` (⛔ деньги могли уйти — не повторять, в поддержку). Ручной `execute` по такой сессии второго возврата не создаёт: денежный вызов по сессии один. Если возврат не успел пройти за время подтверждения, придёт `qr_refund.expired`: возврат не проведён, нужна новая ссылка.
- Пока по счёту идёт незавершённый обычный или QR-возврат, включая неизвестный исход, новый создать нельзя: `409 qr_refund_link_invoice_busy`. Дождитесь итога.

Окончательно доступную к возврату сумму определяет Kaspi в момент возврата.

## Частые ошибки

| Код | Что означает | Что делать |
|---|---|---|
| `qr_refund_not_identified` (409) | Покупатель ещё не подтвердил возврат | Дождитесь `customer_identified` |
| `qr_refund_expired` (409) | Срок сессии истёк | Выпустите новую ссылку и отправьте её покупателю |
| `qr_refund_completed` (409) | Возврат по этой сессии уже выполнен | Не повторяйте: запросите статус и покажите результат оттуда |
| `qr_refund_execution_uncertain` (202, 409), `qr_refund_execution_result_unavailable` (202) | Исход возврата не доказан, деньги могли уйти | Не повторять ни `execute`, ни новой ссылкой, ни вручную. Обратиться в поддержку |
| `qr_refund_execution_in_progress` (409) | Возврат по сессии уже идёт | Не повторять, дождаться терминального статуса |
| `operation_not_returnable` (422) | Kaspi не разрешает возврат по этой покупке | Выберите другую покупку |
| `refund_amount_exceeds_available` (422) | Сумма больше доступной к возврату | Проверьте `available_for_refund` в деталях покупки |
| `partial_refund_requires_return_items` (422) | Частичную сумму по этой покупке принять нельзя — возвращайте по товарам | В `execute` пришлите `items` вместо `amount`, в ссылке под счёт — `return_items` |
| `refund_insufficient_funds` (422) | Kaspi отказал: на счёте Kaspi Pay не хватает денег | Пополнить счёт; если срок сессии уже вышел — новая ссылка |
| `invoice_not_refundable` (422) | Счёт под ссылку нельзя вернуть | Проверьте, что счёт ваш, оплачен и не возвращён целиком |
| `qr_refund_link_invoice_busy` (409) | По счёту уже идёт другой возврат | Дождитесь его итога |
| `qr_refund_link_limit_reached` (429) | У организации уже максимум неиспользованных ссылок — до 20 одновременно | Отзовите лишние или дождитесь, пока покупатели их откроют |
| `connection_ambiguous` (422) | Активных касс несколько, основная не назначена | Передайте `kaspi_connection_id` при выпуске ссылки |
| `kaspi_session_invalid` (503) | Сессия кассира недоступна | Переподключите кассира, затем начните возврат заново |
| `organization_not_verified` (400) | Организация не верифицирована | Пройдите верификацию в кабинете |
| `tariff_inactive` (403) | Подписка ApiPay неактивна | Оплатите тариф. Выпуск ссылки и `execute` закрыты, чтение сессии и отзыв ссылки работают |
| `not_sandbox` (403) | Поле `simulate` в `execute` или вызов `POST /qr-refunds/{id}/simulate` на боевой сессии | Уберите `simulate` и песочные вызовы из боевого кода или работайте в песочнице |
| `429` | Превышены **30 запросов в минуту** на ветку `/qr-refunds` — лимит держат в том числе `operations` и `operations/{ref}` | Опрашивайте по `poll_interval_seconds` и не перебирайте операции циклом |

## Как проверить без реального покупателя?

В песочнице реальный QR никому не уходит, а шаги покупателя двигаются вручную через `POST /qr-refunds/{id}/simulate`. Ссылка рождается в состоянии `awaiting_customer` — ждёт покупателя. Первым шагом отправьте `activated` — это покупатель нажал «вернуть» на странице ссылки, и сессия переходит в `awaiting_scan`. Тот же переход даёт нажатие кнопки на странице `customer_url`. Событие, которое из текущего состояния не исполняется (например, `identified` до `activated`), отвечает `409 qr_refund_simulation_not_applicable` и ничего не меняет.

```bash
# покупатель нажал «вернуть»
curl -X POST https://api.apipay.kz/api/v1/qr-refunds/42/simulate \
  -H "X-API-Key: ВАШ_КЛЮЧ" -H "Content-Type: application/json" \
  -d '{"event":"activated"}'

# покупатель подтвердил возврат в своём Kaspi
  -d '{"event":"identified"}'

# окно скана пропущено
  -d '{"event":"expired"}'
```

Отказ возврата по ссылке под счёт проверяется так: `activated`, затем `failed` с кодом (например, `{"event":"failed","error_code":"refund_insufficient_funds"}` — так задаётся отказ для следующей попытки провести возврат), затем `identified`. Ссылку целиком можно закрыть событием `link_expired`, не дожидаясь суток.

После `expired` ссылка, у которой ещё остались окна, возвращается в `awaiting_customer`, как в бою. Терминальный `expired` и вебхук `qr_refund.expired` приходят, когда окна ссылки исчерпаны или истёк `link_expires_at`.

После `identified` отдаются три покупки: 500 ₸ с `returnable: full`, 10 ₸ с `partial` и 10 ₸ с `none`. Неуспех возврата форсируется полем `simulate` в теле `execute`:

```json
{ "operation_ref": "REF", "simulate": { "status": "failed", "error_code": "refund_amount_exceeds_available" } }
```

Вне песочницы поле `simulate` в `execute` даёт `403 not_sandbox`. А в `POST /qr-refunds/links` у боевой организации `simulate` молча не действует: ссылка выпускается настоящая, и после подтверждения покупателя уходят настоящие деньги. Убирайте `simulate` из боевого кода.

## Где подключить и что прочитать дальше?

Возврат с подтверждением работает в том же кабинете и по тому же тарифу, что счета, — отдельно подключать ничего не нужно. Все эндпоинты и поля — в [документации API](/docs), обзор приёма оплаты — «[Как принимать оплату Kaspi через API](/kaspi-api)». Код интеграции может написать ваш ИИ-агент — дайте ему [apipay.kz/for-ai](/for-ai).

## Частые вопросы

**Как в Kaspi сделать возврат, если покупатель платил удалённо?**
Выпустите ссылку «Возврат ApiPay» (`POST /api/v1/qr-refunds/links` или кнопкой в кабинете) и отправьте её покупателю. Ссылка ждёт его 24 часа; он открывает её, нажимает кнопку и сканирует возвратный QR в своём Kaspi не более чем за 90 секунд.

**Можно вернуть несколько покупок за одну сессию?**
Нет. Одна сессия — один возврат. Для второй покупки выпустите новую ссылку.

**Что делать, если `execute` ответил `202`?**
Ничего не повторять: исход не доказан, и деньги могли уйти. Не выпускайте новую ссылку и не делайте возврат вручную — обратитесь в поддержку ApiPay.

**Приходят ли вебхуки?**
Да: `qr_refund.identified` при подтверждении покупателем, `qr_refund.completed` при успешном возврате, `qr_refund.failed`, `qr_refund.execution_uncertain` и `qr_refund.expired`, когда ссылка исчерпана. Ретраи возможны — дедуплицируйте по паре `(event, qr_refund.id)`. Статус в payload соответствует событию, а не текущему состоянию сессии.

---

ApiPay — независимый сервис и не аффилирован с АО «Kaspi Bank». Kaspi и Kaspi Pay — товарные знаки их правообладателя.
База знаний: https://apipay.kz/guides
