Payment intent¶
This page is the payment intent reference: every field of the object, the limits of the create request, every
status with who moves it and which webhook follows, exactly what capture / cancel / unclaim do, and the errors
of each operation. About 15 minutes to read. You need one of the app's API keys (gk_test_… / gk_live_… with a gks_…
secret, or the older «default key» mapp_… + gma_cs_…) or, for a mini app, the person's access token. For a step-by-step integration start with
Pay with Gerege App and mini app payments.
Every path is under https://geregeapp.mn/api/v1/miniapp. Money is whole tögrög (₮), times are RFC 3339 UTC.
Two kinds of intent¶
| Who creates it | Authentication | presentment |
Whose it is | |
|---|---|---|---|---|
| Mini app intent | the mini app's backend | the person's access token (Bearer gma_at_…, scope payments) |
app (or omitted) |
the person's from the start |
| Claimless intent | the merchant's backend | client credentials (Basic) |
qr · redirect · terminal (required) |
nobody's until somebody claims it |
The two never cross: a person's token creating qr/redirect/terminal is 400 validation_error
(details[].path = presentment); client credentials creating an app intent (or one without presentment) is
400 presentment_required. Every other operation (read, list, capture, cancel, refund, unclaim) accepts either
credential. An app sees only its own intents in the environment of its credential (test / live) —
another app's intent, or one of the other environment, is 404.
Key scopes. Called with client credentials (Basic), the key must hold the operation's scope, or the answer is
403 insufficient_scope with "required_scope". A person's access token is not checked against key scopes — the
person having granted the app payments is enough. The «default key» holds every scope of its environment.
| Operation | Scope |
|---|---|
POST /payment-intents, …/{id}/capture, …/{id}/cancel, …/{id}/unclaim |
payments:write |
GET /payment-intents, GET /payment-intents/{id} |
payments:read |
POST /payment-intents/{id}/refunds |
refunds:write |
Keys, scopes and presets: the «API keys and scopes» section of Error codes and the security model.
Creating — POST /payment-intents¶
The Idempotency-Key header is required (1–128 printable ASCII characters, no spaces). Use your order or basket
id — one order, one intent.
curl -sS https://geregeapp.mn/api/v1/miniapp/payment-intents \
-u "$GEREGE_CLIENT_ID:$GEREGE_CLIENT_SECRET" \
-H "Idempotency-Key: order-8812" \
-H "Content-Type: application/json" \
-d '{
"amount": 45000,
"description": "Order #8812",
"reference": "order-8812",
"presentment": "redirect",
"return_url": "https://shop.mn/orders/8812/done",
"expires_in": 900
}'
| Field | Type | Rule |
|---|---|---|
amount |
integer | required, 100 – 50 000 000 ₮, whole. If the operator set a max_amount for the app, at most that (validation_error, path amount) |
currency |
string | MNT only (default MNT) |
description |
string | required, 1–80 characters. Shown to the person on the payment sheet — a short order summary, not a product category. No web address, e-mail address or phone number (400 description_not_allowed, phishing) — your own number goes in reference |
reference |
string | ≤ 64 characters, no tabs or newlines. Your order number — for finding it in the list |
expires_in |
integer | 60 – 1800 seconds, default 600; up to 604 800 (7 days — an invoice) for qr and redirect |
capture_method |
string | automatic (default) or manual |
capture_within |
integer | manual only: 60 – 604 800 seconds, default 604 800 (7 days) — the time from confirmation to capture |
metadata |
object | ≤ 20 keys, keys 1–40 bytes, values ≤ 500 characters, values are strings. Never shown to the person |
presentment |
string | app · qr · redirect · terminal (table above) |
terminal_id |
UUID | required for terminal; optional for qr (a kiosk or cash register drawing the QR on its own screen — the payment counts in that terminal's numbers and goes to its sub-merchant; the one-open-basket rule is the terminal presentment's alone, and tapping the sticker never finds a QR intent); forbidden otherwise. An active terminal of the app |
sub_merchant_id |
UUID | optional, an active sub-merchant of the app. A terminal's own sub-merchant is used without it; a different value is a validation_error |
return_url |
string | required for redirect, optional for qr/terminal, forbidden for app. ≤ 2048, its host one of the app's registered return_hosts |
min_age |
integer | 13 – 21, not below the app's min_age. Checked at the claim and at confirmation |
A body with an unknown field, not JSON, or too large (over 16 KB) → 400 invalid_request.
Answer. 201 — a new intent; 200 — the same key with the same body (a replay): the intent the first attempt
made. A claimless intent's answer carries an extra pay_url:
{
"id": "pi_4fQ2mX8kLp0aZr7TbN3cVw9E",
"object": "payment_intent",
"status": "requires_confirmation",
"amount": 45000,
"currency": "MNT",
"description": "Order #8812",
"reference": "order-8812",
"user_ref": null,
"livemode": false,
"capture_method": "automatic",
"refunded_amount": 0,
"fee": null,
"net": null,
"failure_code": null,
"expires_at": "2026-10-27T09:15:00Z",
"capture_before": null,
"confirmed_at": null,
"created_at": "2026-10-27T09:00:00Z",
"metadata": {},
"presentment": "redirect",
"terminal": null,
"sub_merchant": null,
"return_url": "https://shop.mn/orders/8812/done",
"claimed_at": null,
"min_age": null,
"capture_within": null,
"pay_url": "https://geregeapp.mn/pay/Qk3vR8sT2uW5xY7zA1bC4dE6"
}
pay_urlis the one-time secret that claims the intent: it is in the create answer only (and in a replay of the same key — the same link), never in aGET, a list or a webhook. The server keeps only its hash. The URL carries no amount and no payee; the person sees the amount from the server on the payment sheet.- Idempotency comes first: a replay of a claimless create is looked up before the terminal, the
sub-merchant and the return host are checked — even if the terminal was disabled meanwhile, the replay answers
the same intent and the same
pay_url. The same key with another body →409 idempotency_conflict. - A live intent is refused at creation when there is nowhere for the money to go: the app has no payout
account, or the sub-merchant's holder has not approved one →
409 payouts_not_configured(Payout accounts). - A terminal has one open unclaimed intent per environment: creating another is
409 terminal_busy, with the open intent's id in an extrapayment_intentfield — cancel that one first. An intent past its time that has not been closed yet is finished by the server, which then tries again.
The PaymentIntent object¶
API answers and a webhook's data.object have the same shape.
| Field | Type | Meaning |
|---|---|---|
id |
string | pi_ + 24 characters |
object |
string | always payment_intent |
status |
string | one of the 9 statuses below |
amount |
integer | ₮ |
currency |
string | MNT |
description |
string | the description the person saw |
reference |
string | null | your reference |
user_ref |
string | null | a name for the person (u_ + 32 hex). On a mini app intent the stable pairwise name for the app (sub of /me). On a claimless intent null until claimed, then a random name for that payment alone (a merchant gets no stable name); null again after an unclaim |
livemode |
boolean | true = live, false = test |
capture_method |
string | automatic | manual |
refunded_amount |
integer | the sum of refunds made (and in flight) — a refund that fails is taken off again |
fee · net |
integer | null | fee and net amount. Set only for a payment captured while settlements are on for the app; null otherwise and in test (Fees) |
failure_code |
string | null | why the last attempt failed (Error codes) |
expires_at |
timestamp | the last moment it can be confirmed |
capture_before |
timestamp | null | manual: once the hold is placed, the last moment to capture (confirmation time + the capture window) |
confirmed_at |
timestamp | null | when the payment succeeded (the money moved); the 90 days for refunds count from it |
created_at |
timestamp | |
metadata |
object | your metadata, {} when none |
presentment |
string | app · qr · redirect · terminal |
terminal |
string | null | the terminal's id |
sub_merchant |
string | null | the sub-merchant's id (given, or the terminal's) |
return_url |
string | null | your return_url |
claimed_at |
timestamp | null | when a person claimed it (claimless) |
min_age |
integer | null | the intent's own minimum age |
capture_within |
integer | null | a manual intent's capture window in seconds (604 800 when not given); null for automatic |
A later version may add fields — ignore the ones you do not know.
Statuses and transitions¶
┌────────── cancel (app, person) ───────▶ canceled
│ ┌─── expires_at ─────────────────▶ expired
│ │
create ─▶ requires_confirmation ◀──── account_unavailable ──┐
│ ▲ (claim / unclaim keep the status) │
person │ └──── failed ◀── refused ────┐ │
confirms ▼ │ │
processing ──────────────────────────┴──────────────┘
│ automatic: hold + capture
├──────────────────────────────────────────────▶ succeeded
│ manual: hold
▼
requires_capture ── capture ─▶ processing ─▶ succeeded
│ ├── cancel (app) ─────────────────▶ canceled (hold released)
│ └── capture_before ───────────────▶ expired (authorization_expired)
│
succeeded ── refund ─▶ partially_refunded ── refund ─▶ refunded
| Transition | Who / what | Webhook |
|---|---|---|
→ requires_confirmation |
create | — |
claim (status unchanged, user_ref and claimed_at set) |
a person, through pay_url, a QR or a terminal |
payment_intent.claimed (on every claim — again when it is claimed again after 90 s) |
requires_confirmation / failed → processing |
the person confirmed in Gerege App (live) | — |
processing → succeeded |
automatic: hold and capture succeeded |
payment_intent.succeeded |
processing → requires_capture |
manual: the hold is in place |
payment_intent.requires_capture |
processing → failed |
the ledger refused (insufficient_funds, limit_exceeded, hold_mismatch, payment_failed); or after the hold the app was suspended / lost its payout (merchant_unavailable, hold released); or the capture was refused (capture_failed) |
payment_intent.failed |
processing → requires_confirmation |
the chosen account cannot pay (account_unavailable) — the person picks another |
— (no webhook) |
requires_confirmation / failed → canceled |
the app's cancel, or the person declined on the payment sheet |
payment_intent.canceled |
requires_capture → processing → succeeded |
the app's capture |
payment_intent.succeeded |
requires_capture → canceled |
the app's cancel (the hold goes back to the person) |
payment_intent.canceled |
→ expired |
expires_at passed; a manual hold's capture_before passed (authorization_expired) |
claimless: payment_intent.expired; app: payment_intent.canceled (status: "expired") |
succeeded → partially_refunded → refunded |
a successful refund | refund.succeeded (not an intent event) — Refunds |
failedis payable again (OMAS): the person may pick another account and confirm again untilexpires_at. Do not close the order onfailed— wait forexpiredorcanceled.- In test mode (
livemode: false) no ledger is called: a confirmation is decided at once by the last two digits of the amount (…01→failedtest_declined,…02→canceled, anything else →succeededorrequires_capture) — Test amounts. - Final statuses:
canceled,expired,refunded. Fromsucceeded/partially_refundedonly refunds.
processing — the outcome is not known yet¶
processing means a ledger (core banking) call is in flight and its outcome is not known yet — neither a
success nor a failure. A timeout is not a refusal: the hold may have been placed, so the platform asks again with the
same idempotency key (the request itself, or the reconcile job every minute) and finishes it exactly once.
- In that case
captureandrefundsanswer202 Accepted(the body is the intent / refund as it is now). - Do not show the customer «failed», and do not hand anything over. Wait for the webhook (
succeeded/failed) or read it again a little later withGET /payment-intents/{id}. cancelwhileprocessing→409 in_progress.
Expiry¶
- An intent in
requires_confirmationorfailedbecomesexpiredatexpires_at. The job runs every minute, and every read (GET) also finishes an intent past its time and answersexpired— you never see a stale «still payable». - If time runs out while a confirmation is under way, the platform never places a new hold; one already placed
is released (
failure_code: "expired"). - A claimless (
qr/redirect/terminal) intent sendspayment_intent.expiredwhen it runs out of time, claimed or not. A mini app (app) intent, as OMAS has it, getspayment_intent.canceledwithstatus: "expired"— so backends written to OMAS keep working.
Manual capture — POST /payment-intents/{id}/capture¶
When the person confirms a capture_method: "manual" intent the money is held on their account
(requires_capture, payment_intent.requires_capture), not moved yet. You decide before capture_before:
- capture → takes the money, all of it →
succeeded. Partial capture ({amount}) is coming soon — for now capture in full and refund what you did not deliver. - cancel → the hold is released to the person →
canceled. - do nothing, and at
capture_beforethe hold is released by itself:expired(failure_code: "authorization_expired").
Answers:
| HTTP | When |
|---|---|
200 |
captured; or the intent is already succeeded / partially_refunded / refunded (a retry after a timeout is the ordinary case — nothing is captured twice) |
202 |
processing — the ledger's answer is not known, read it again later |
409 not_capturable |
not manual, or not in requires_capture (extra payment_intent) |
409 expired |
the capture window has passed — the money went back to the person (extra payment_intent) |
409 payouts_not_configured |
live: the account that receives the money is gone |
422 payment_failed |
the ledger refused the capture → the intent is failed (capture_failed), the hold released (extra payment_intent) |
503 payments_unavailable |
live: core banking is not enabled |
A capture needs no Idempotency-Key — the intent itself is idempotent (one intent is captured at most once).
Canceling — POST /payment-intents/{id}/cancel¶
| Current status | Result |
|---|---|
requires_confirmation, failed |
canceled, payment_intent.canceled |
requires_capture |
canceled, the hold released to the person (if the ledger's release is delayed the platform keeps retrying — the cancellation stands), payment_intent.canceled |
canceled |
200, as it is (a replay) |
processing |
409 in_progress — wait a moment and read it again |
succeeded, partially_refunded, refunded, expired |
409 not_cancelable (extra status) — if it was paid, refund it |
A person can cancel only an intent they have not paid; releasing a hold is the app's.
Releasing a claim — POST /payment-intents/{id}/unclaim¶
When a person claimed a claimless intent and walked away without confirming (or the wrong person scanned it), release it so the next person can pay.
| Condition | Answer |
|---|---|
| claimed, not confirmed | 200 — the intent is nobody's again, user_ref: null, claimed_at: null |
not claimed (requires_confirmation) |
200, as it is |
| a confirmation under way, or any other status | 409 not_unclaimable (extra payment_intent) |
| the terminal opened another basket meanwhile | 409 terminal_busy — cancel this intent |
Even without an unclaim, an unconfirmed claim lapses after 90 seconds — the next person to claim takes the
intent and payment_intent.claimed comes again with a new user_ref. One person may hold at most 3 unconfirmed
claims at a time.
Reading and listing¶
GET /payment-intents/{id} — an intent with its refunds (refunds[], oldest first; 1.13.0). GET /payment-intents — the app's intents, newest first, in the
credential's environment.
| Parameter | Meaning |
|---|---|
reference |
the intents with this reference (every attempt of one order) |
status |
one of the 9 statuses; anything else → 400 invalid_status |
after |
the id of the last intent of the previous page; invalid → 400 invalid_cursor |
limit |
1 – 100, default 20; anything else → 400 invalid_limit |
curl -sS "https://geregeapp.mn/api/v1/miniapp/payment-intents?reference=order-8812&limit=20" \
-u "$GEREGE_CLIENT_ID:$GEREGE_CLIENT_SECRET"
# → {"data": [PaymentIntent, …], "has_more": false}
With has_more: true, fetch the next page with after=<the last id of data>. The general paging rules:
API conventions.
From the dashboard — the console¶
The developer console lists every payment of the organisation's apps in one place and lets a support desk take exactly the actions the app's backend takes (same guards, same ledger keys, same webhooks):
| Path (console, signed-in session) | What | Permission |
|---|---|---|
GET /api/v1/miniapp/dev/payments |
the organisation's payments, newest first, {data, next_cursor}; filters app_id, environment (default live), status (several, comma-separated), presentment, terminal_id, sub_merchant_id, created_gte/created_lt (RFC 3339), amount_gte/amount_lte, q (exact pi_…, reference or user_ref), cursor, limit 1–100 (default 25) |
miniapp.read |
GET /api/v1/miniapp/dev/payments?format=csv |
the same filters as CSV (≤ 100 000 rows, UTF-8 BOM, Ulaanbaatar times) | miniapp.finance |
GET /api/v1/miniapp/dev/payments/{pi} |
the intent + refunds[] + timeline[] (created, claimed, unclaimed, held, captured, canceled, expired, failed, refund_*, settled; actor app, person, job or dashboard:<member>, the member's name in actor_name — from the organisation's directory) + events[] (each webhook's delivery state) |
miniapp.read |
POST …/dev/payments/{pi}/capture · /cancel · /unclaim |
the same outcome as from the app's backend | miniapp.payments |
POST …/dev/payments/{pi}/refunds {amount?, reason} |
a refund; reason is required |
miniapp.payments |
GET /api/v1/miniapp/dev/refunds |
the organisation's refunds (status, app_id, environment, cursor, limit), with who asked (actor) |
miniapp.read |
- The POST actions also need the console's general
miniapp.manage. - Every action requires an
Idempotency-Key(1–100 characters) — a double click is one capture, one refund. - The actions that move money (capture, refund) answer
409 merchant_unavailablefor a suspended or disabled app, or (live) one not yet approved; cancelling and releasing a claim give a person their money back and stay open. - A live refund of 1 000 000₮ or more needs a sign-in within the last 10 minutes (
403 fresh_sign_in_required{within_seconds: 600}— sign in again and repeat). - Every action is written to the app's history (
/dev/audit) under the member's name. A person is never named — only theuser_ref.
Errors¶
| Operation | Possible code |
|---|---|
| All | 401 invalid_client / invalid_token, 403 insufficient_scope (required_scope) / ip_not_allowed, 404 (no such intent, another app's, or the other environment's), 429, 500 server_error |
| Create | 400 idempotency_key_required, invalid_request, validation_error, presentment_required; 409 idempotency_conflict, terminal_busy, terminal_disabled, sub_merchant_disabled, payouts_not_configured, agreement_required, business_profile_required, business_profile_rejected, business_restricted, business_profile_changed, business_limit_exceeded; 503 payments_unavailable, pay_url_unavailable |
| List | 400 invalid_status, invalid_cursor, invalid_limit |
| Capture | 409 not_capturable, expired, payouts_not_configured; 422 payment_failed; 503 payments_unavailable |
| Cancel | 409 in_progress, not_cancelable |
| Unclaim | 409 not_unclaimable, terminal_busy |
What each code means, what to do, and whether to retry — Error codes. After a network
error, 429 or 5xx, sending again with the same Idempotency-Key is safe.