Refunds¶
Give a paid intent's money back to the person, in full or in part — as many times as you like for one intent, never
more in total than was paid, within 90 days of the payment. About 5 minutes to read. You need an API key with the
refunds:write scope (or, for a mini app, the person's token) and a new Idempotency-Key for every refund.
Refunding — POST /payment-intents/{id}/refunds¶
curl -sS https://geregeapp.mn/api/v1/miniapp/payment-intents/pi_4fQ2mX8kLp0aZr7TbN3cVw9E/refunds \
-u "$GEREGE_CLIENT_ID:$GEREGE_CLIENT_SECRET" \
-H "Idempotency-Key: refund-order-8812-1" \
-H "Content-Type: application/json" \
-d '{"amount": 10000, "reason": "supplier_failed"}'
| Field | Rule |
|---|---|
amount |
optional. Omitted: everything not yet refunded (amount − refunded_amount). Given: ≥ 1; if, with the earlier refunds, it exceeds what was paid → 400 validation_error (path amount) |
reason |
optional, ≤ 255 characters. Visible in your API, webhooks and the console; never in the notification the person gets |
Idempotency-Keyis required (1–128 printable ASCII). The key is unique within the intent: the same key with the same body on the same intent → the first refund (200); the same key with another body →409 idempotency_conflict. A new refund is a new key.- Only a
succeededorpartially_refundedintent can be refunded (anything else →409 not_refundable). A hold inrequires_captureis released with cancel, not refunded. - More than 90 days after the payment succeeded (
confirmed_at) →409 refund_window_expired.
Answers and statuses¶
{
"id": "re_2kLm9Pq4Rs6Tu8Vw0Xy1Za3B",
"object": "refund",
"intent_id": "pi_4fQ2mX8kLp0aZr7TbN3cVw9E",
"amount": 10000,
"status": "succeeded",
"reason": "supplier_failed",
"failure_code": null,
"livemode": true,
"created_at": "2026-10-22T09:00:00Z"
}
| HTTP | Meaning |
|---|---|
201 |
a new refund with a final status — succeeded or failed (see failure_code) |
202 |
pending — the ledger's answer is not known; the platform finishes it with the same key by itself (the reconcile job, every minute), and the outcome arrives as a refund.succeeded / refund.failed webhook |
200 |
a replay (same key, same body) — that refund as it is now |
status |
Meaning |
|---|---|
pending |
the money is on its way back. The amount already counts in refunded_amount (a second refund cannot promise it twice) |
succeeded |
the money is back on the account the person paid from |
failed |
not refunded; the amount is taken off refunded_amount — fix the cause and refund again with a new key |
failure_code of a failed refund:
failure_code |
Meaning, what to do |
|---|---|
insufficient_funds |
The payout account that received the money has already spent it. Top the account up and refund again |
settlement_shortfall |
The payment was settled and the app's clearing account does not hold enough to refund it. Receivables (deducting it from later settlements) are not built yet — the operator sees it; contact the operator |
refund_rejected |
The ledger refused for another reason |
After a successful refund the intent's status becomes partially_refunded, or refunded once everything has come
back; refunded_amount grows. No separate payment_intent.* event is sent — only the refund.* events.
Where the money comes back from¶
A refund comes out of wherever the capture put the money (fixed when the refund is created):
- An app without settlements (every app today): the capture paid straight into the app's payout account (or the sub-merchant's approved payout account) — the refund comes out of that account. That is why the holder of a sub-merchant's payout agrees that "refunds are taken back from this account" when approving it (Payout accounts).
- An app with settlements on: the payment sits in the app's clearing — the refund comes out of the clearing. A
refund of a payment already settled to the payout account comes out of later payments' clearing, and the next
settlement deducts it (Settlements). Not enough in clearing →
settlement_shortfall.
Fees¶
The fee of a payment of an app with settlements on is not given back by a refund — only when the operator's rule
says refund_fee: true, and then in proportion to the amount refunded. The fee shares of partial refunds add up
exactly to the whole fee once everything is refunded. Details: Fees.
Webhooks and notifications¶
refund.succeeded/refund.failed—data.objectis the Refund above (Event reference).- The person gets a «refund» notification in Gerege App — the amount and the app's approved name only; never your
reason(an app's own words under the platform's name would be a phishing channel).
Test mode¶
A test (livemode: false) refund calls no ledger: it is succeeded at once (201) and sends refund.succeeded.
So full, partial and repeated refunds, replays and idempotency_conflict can all be tried without money
(Sandbox).
Refunding from the dashboard (console): POST /api/v1/miniapp/dev/payments/{pi}/refunds {amount?, reason} —
the same engine and rules, but reason is required, the Idempotency-Key is 1–100 characters and the permission
is miniapp.payments; a live refund of 1 000 000₮ or more needs a sign-in within the last 10 minutes
(403 fresh_sign_in_required). The console's keys never collide with your backend's (they carry their own prefix).
GET /api/v1/miniapp/dev/refunds shows every refund with who asked for it (actor: app or dashboard:<member>) —
the «From the dashboard» section of Payment intent.
Errors¶
| HTTP | code |
When |
|---|---|---|
| 400 | idempotency_key_required |
Idempotency-Key missing / invalid |
| 403 | insufficient_scope |
the key lacks refunds:write (required_scope) |
| 400 | validation_error |
amount < 1, or the total would exceed what was paid; reason > 255 |
| 400 | invalid_request |
the body could not be read (not JSON, an unknown field, over 4 KB) |
| 404 | — | no such intent, or another app's / environment's |
| 409 | idempotency_conflict |
the key was used on this intent with another body |
| 409 | not_refundable |
the intent is not succeeded / partially_refunded |
| 409 | refund_window_expired |
more than 90 days since the payment |
| 503 | payments_unavailable |
live: core banking is not enabled |
The complete list: Error codes.