Payments — payment intent¶
The only way to take money in a mini app. Outside payment instruments (card forms, QR codes, bank transfers) are FORBIDDEN inside a mini app. The money moves in the Gerege Wallet ledger (core banking); the person pays from their own wallet account.
1. Flow¶
- The person taps «Төлөх» ("Pay") in the mini app → frontend → your own backend.
- Backend →
POST /api/v1/miniapp/payment-intents(Bearer the person's access token, scopepayments,Idempotency-Key) → an intent (requires_confirmation). The backend computes the amount from its own catalogue. - Frontend → bridge
requestPayment({intentId})→ the host confirms it in its own UI (§3). - The backend hands over the goods/service only after it has checked the payment through the webhook
payment_intent.succeededand/orGET /payment-intents/{id}. Handing over on the bridge'ssucceededis FORBIDDEN.
2. API (https://geregeapp.mn/api/v1/miniapp)¶
Auth. Creating a mini app intent — only with the person's Bearer access token (scope payments): an intent
always belongs to someone, and the token says whose it is. (External systems that are not mini apps — web shops,
kiosks, vending machines — create presentment: "qr" | "redirect" | "terminal" intents with client credentials:
pay-with-gerege.md.) Reading, listing, capturing, canceling, refunding — Bearer or the
app's client credentials (Authorization: Basic base64(client_id:client_secret)) — for server-side actions with no
person present, such as a webhook handler, a reconciler, or a refund after a supplier failure. Either way you reach
only intents of your own app and of the credential's environment (test/live). Rate limit: 600 per minute,
burst 120.
Create — POST /payment-intents¶
The Idempotency-Key header is REQUIRED: 1–128 printable ASCII characters, no spaces. A key is unique for good
within one app × environment: the same key + the same body (and the same person) → that intent with 200; the same
key + a different body/person → 409 idempotency_conflict.
{ "amount": 45000, "currency": "MNT", "description": "Japan 5GB 15 хоног", "reference": "order_8812",
"expires_in": 600, "capture_method": "automatic", "metadata": { "package": "JC071" } }
| Field | Rule |
|---|---|
amount |
An MNT integer, 100 ≤ amount ≤ 50 000 000, and no more than the app's max_amount (below) |
currency |
MNT (default) |
description |
1–80 characters, shown to the person |
reference |
≤ 64, no line breaks; your order id — RECOMMENDED to be unique within the app |
expires_in |
60–1800 seconds (default 600) |
capture_method |
automatic (default) or manual (§4) |
metadata |
≤ 20 keys; key ≤ 40, value ≤ 500 characters, strings |
presentment |
app (default — the only value allowed with a person's token). qr/redirect/terminal — pay-with-gerege.md |
min_age |
13–21, not lower than the app's — the person's age is checked at confirmation (403 age_restricted) |
capture_within |
manual only: 60–604 800 seconds (default 7 days) — how long the hold lasts (§4) |
max_amount — the operator sets the maximum amount of a single payment per app (PUT /api/v1/miniapp/review/apps/{id}/limits
{"max_amount": 500000}, 100–50 000 000, null = clear it); the developer cannot change it, and it shows as
max_amount in the app's details in the console. Exceeding it → 400 validation_error (details[0].path = "amount").
When it is not set, the upper limit is 50 000 000.
An unknown field → 400. A live intent is refused at creation when: core banking is not configured
(503 payments_unavailable), or the operator has not configured a payout account (409 payouts_not_configured).
Response 201 (new) / 200 (idempotent replay):
{ "id": "pi_4fQ2mX8kLp0aZr7TbN3cVw9E", "object": "payment_intent", "status": "requires_confirmation",
"amount": 45000, "currency": "MNT", "description": "Japan 5GB 15 хоног", "reference": "order_8812",
"user_ref": "u_9f2c4e7a1b3d5f60718293a4b5c6d7e8", "livemode": true, "capture_method": "automatic",
"refunded_amount": 0, "fee": null, "net": null, "failure_code": null, "expires_at": "2026-10-22T09:10:00Z",
"capture_before": null, "confirmed_at": null, "created_at": "2026-10-22T09:00:00Z", "metadata": { "package": "JC071" },
"presentment": "app", "terminal": null, "sub_merchant": null, "return_url": null, "claimed_at": null,
"min_age": null, "capture_within": null }
Fields added in 1.7.0 (on every intent): presentment, terminal (the terminal's id), sub_merchant (the
sub-merchant's id), return_url, claimed_at, min_age, capture_within (a number for manual, null otherwise) —
on an app intent presentment: "app" and the others null (or the value given, if min_age / capture_within
were set). On a merchant's claimless intent user_ref is null until it is claimed
(pay-with-gerege.md).
fee, net (OMAS) — when the operator has switched settlements on for the app, the fee and amount − fee of a
captured live payment; null on the old path (settlements off), in test mode, and before capture (§7). Added over
OMAS: object, capture_method, capture_before.
Other routes¶
| Method | Path | Description |
|---|---|---|
| GET | /payment-intents/{id} |
Status. If it has expired, it becomes expired on the spot and answers so |
| GET | /payment-intents?reference=&status=&limit=&after= |
Newest first. limit 1–100 (default 20); next page after=<last id of the previous page>. Response {data: [...], has_more} |
| POST | /payment-intents/{id}/capture |
Manual capture (§4). One already captured answers 200 as it is |
| POST | /payment-intents/{id}/cancel |
Cancel an unpaid one (requires_confirmation, failed), or release a manual hold (requires_capture). Already canceled → 200 |
| POST | /payment-intents/{id}/refunds |
{amount?, reason?} + Idempotency-Key (§5) |
Statuses¶
requires_confirmation ──person confirms─────▶ processing ──▶ succeeded ──▶ partially_refunded ──▶ refunded
│ ▲ │
│ └──────── failed ◀── declined ────────┤ (manual) ──▶ requires_capture ──capture──▶ succeeded
├──cancel──▶ canceled └──cancel / capture_before──▶ canceled / expired
└──expires_at──▶ expired
processing— the ledger's answer is pending. Neither an error nor a success: do not charge again; read it again later (the platform itself reconciles and finishes it with the same idempotency key).failed— can be paid again (the person may choose another account);failure_codegives the reason.requires_capture— the money of a manual intent is held on the person's account.
3. Confirmation (host)¶
- The host fetches the intent with its own session and shows the mini app's name/icon (from the registry), the amount and the description in its own UI; the person chooses their wallet account. Confirmation: Face ID / passcode.
- If the balance is insufficient (
insufficient_funds), the host offers another account or a top-up. - An intent with
livemode: falsecarries a «ТЕСТ» ("TEST") label. - The person's account KYC limits (per payment / daily / monthly, by the person's KYC tier) apply to the hold: over
one, the confirmation answers
403 limit_exceededand the intent isfailed(failure_code: limit_exceeded,payment_intent.failed) — still payable (another account, or later). A hold counts once; a released or expired hold frees the room. Per-app daily limits — «coming soon».
4. Manual capture¶
capture_method: "manual" — take the money only once it is clear the goods/service can be delivered (an eSIM
activates, a seat is confirmed). When the person confirms, the money is held → requires_capture + a
payment_intent.requires_capture webhook. Then the backend:
POST …/capture→succeeded(on202it isprocessing; read it again);- or
POST …/cancel→ the hold is released (canceled; not a refund). - Once
capture_before(7 days after confirmation, orcapture_withinseconds) passes, the hold is released automatically →expired(failure_code: "authorization_expired"). A capture after the deadline →409 expired. - Capture is always for the full amount. Partial capture with an amount (
{amount}) — «coming soon»: capture in full and refund the rest (§5).
5. Refunds — POST /payment-intents/{id}/refunds¶
{"amount": 10000, "reason": "supplier_failed"} + Idempotency-Key (unique per intent).
- Leave out
amountto refund everything that remains; the total ≤ the amount paid.reason≤ 255. - Only
succeeded/partially_refundedintents, within 90 days of the payment. - Response:
201(new),200(replay),202(pending— the ledger's answer is pending; arefund.succeeded/refund.failedwebhook follows).
{ "id": "re_2kLm9Pq4Rs6Tu8Vw0Xy1Za3B", "object": "refund", "intent_id": "pi_…", "amount": 10000,
"status": "succeeded", "reason": "supplier_failed", "failure_code": null, "livemode": true,
"created_at": "2026-10-22T09:00:00Z" }
Errors: 409 not_refundable, 409 refund_window_expired, 400 validation_error (amount exceeded),
409 idempotency_conflict.
6. Test mode¶
- Credentials come in two pairs:
test(mapp_test_…) andlive(mapp_…). An intent created with the test credential islivemode:false— no money moves, the ledger is not touched. - The outcome of a test intent is decided by the last two digits of
amount(after the person confirms in the host):
amount ends in |
Outcome |
|---|---|
…01 (e.g. 10001) |
failed, failure_code: "test_declined", payment_intent.failed |
…02 (e.g. 10002) |
canceled, payment_intent.canceled |
anything else, automatic |
succeeded, payment_intent.succeeded |
anything else, manual |
requires_capture, then capture → succeeded |
- Webhooks are sent in test mode too (
livemode:false). - The test credential works only for launches by registered members and test users (auth.md §2).
- Without opening the app at all: create a test intent from the console and play the host's confirmation
(
/dev/apps/{id}/test-intents,…/{pi}/confirm— console.md §3); the amount rules and webhooks above are the same.
7. Settlements¶
The developer cannot change the payout account — the operator configures it (PUT /api/v1/miniapp/review/apps/{id}/payout);
the developer sees only the masked number. Money takes one of two paths:
- Without settlements (default): as soon as it is captured, the full amount goes straight to the payout
account; refunds come out of that account;
fee/netarenull. - With settlements (the operator switched the app on): captures go to the app's clearing account (a GL account in core banking) and, on a schedule, are moved to the payout account by settlement, less the fee. Payments captured before the switch stay on the old path.
Fee and schedule — set per app by the operator (a payment rule, versioned; a rule that has taken effect is never
edited, a new version is created). Read it in the console with GET /api/v1/miniapp/dev/apps/{id}/payment-rule.
- Each payment's fee comes from the rule in effect at the moment of capture:
round_half_up(amount × fee_bps / 10000) + fee_fixed, then no less thanfee_min, no more thanfee_max, and no more than the payment. Halves round up: 1.5% × 10 034 = 150.51 → 151, 1.5% × 100 = 1.5 → 2. - A refund does not return the fee; if the rule says
refund_fee: true, it returns it in proportion to the refund (the sum over partial refunds equals the fee of a full refund exactly). - Schedule:
daily/weekdays/weekly:<mon…sun>/manual, at UlaanbaatarHH:MM, T+N: payments of Ulaanbaatar day d go in the run on or after day d+N. With no rule: fee 0, weekdays at 10:00, T+1. - There is no holdback (reserve).
A refund of a settled payment comes out of clearing (that app's money not yet settled). If clearing cannot cover
it, the refund is failed with failure_code: "settlement_shortfall" (refund.failed webhook) — try again later or
contact the operator. Refunds beyond a settlement's amount and fees that could not be collected carry over to the next
settlement (clearing_carried_*, fee_carried_*).
API (OMAS 1.0.2) — Bearer access_token or client Basic, your own app only, live only (a test credential gets an
empty list / 404):
| Method | Path | Response |
|---|---|---|
| GET | /settlements?status=pending\|paid\|failed&limit=1–100&cursor= |
{items: Settlement[], next_cursor, has_more}, newest first |
| GET | /settlements/{id} |
Settlement + intents[] + refund_items[] + truncated (> 1000 rows) |
{ "id": "st_7QwErTy2UiOp4AsDf6GhJk8L", "object": "settlement", "status": "paid", "cutoff": "2026-10-21T16:00:00Z",
"gross": 100000, "refunds": 5000, "reserve_refunds": 0, "fee": 2000, "net": 93000, "holdback": 0, "released": 0,
"recovered": 0, "paid": 93000, "intent_count": 3, "destination_masked": "******0042", "holdback_release_at": null,
"livemode": true, "created_at": "2026-10-22T08:58:00Z", "paid_at": "2026-10-22T08:59:00Z", "refund_count": 1,
"fee_assessed": 2000, "fee_returned": 0, "fee_carried_in": 0, "fee_carried_out": 0,
"clearing_carried_in": 0, "clearing_carried_out": 0, "failure_code": null }
Also "sub_merchant": "<id>" | null — for a merchant app with sub-merchants, settlements are separate per (app,
sub-merchant) (pay-with-gerege.md §8); null = the app's own flow.
net = gross − refunds − fee, paid = net − recovered (no holdback). fee — the fee actually taken in this
settlement (negative if more was returned); recovered — refunds that exceeded an earlier settlement, deducted now
(negative means carried to the next one). As soon as a settlement is paid, a settlement.paid webhook
(webhooks.md); paid may be 0.