Skip to content

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-Key is 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 succeeded or partially_refunded intent can be refunded (anything else → 409 not_refundable). A hold in requires_capture is 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.object is 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.