Skip to content

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_url is 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 a GET, 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 extra payment_intent field — 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
  • failed is payable again (OMAS): the person may pick another account and confirm again until expires_at. Do not close the order on failed — wait for expired or canceled.
  • In test mode (livemode: false) no ledger is called: a confirmation is decided at once by the last two digits of the amount (…01 → failed test_declined, …02 → canceled, anything else → succeeded or requires_capture) — Test amounts.
  • Final statuses: canceled, expired, refunded. From succeeded / partially_refunded only 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 capture and refunds answer 202 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 with GET /payment-intents/{id}.
  • cancel while processing → 409 in_progress.

Expiry

  • An intent in requires_confirmation or failed becomes expired at expires_at. The job runs every minute, and every read (GET) also finishes an intent past its time and answers expired — 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 sends payment_intent.expired when it runs out of time, claimed or not. A mini app (app) intent, as OMAS has it, gets payment_intent.canceled with status: "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_before the 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_unavailable for 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 the user_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.