Skip to content

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

  1. The person taps «Төлөх» ("Pay") in the mini app → frontend → your own backend.
  2. Backend → POST /api/v1/miniapp/payment-intents (Bearer the person's access token, scope payments, Idempotency-Key) → an intent (requires_confirmation). The backend computes the amount from its own catalogue.
  3. Frontend → bridge requestPayment({intentId}) → the host confirms it in its own UI (§3).
  4. The backend hands over the goods/service only after it has checked the payment through the webhook payment_intent.succeeded and/or GET /payment-intents/{id}. Handing over on the bridge's succeeded is 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_code gives 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: false carries 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_exceeded and the intent is failed (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 (on 202 it is processing; read it again);
  • or POST …/cancel → the hold is released (canceled; not a refund).
  • Once capture_before (7 days after confirmation, or capture_within seconds) 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 amount to refund everything that remains; the total ≤ the amount paid. reason ≤ 255.
  • Only succeeded / partially_refunded intents, within 90 days of the payment.
  • Response: 201 (new), 200 (replay), 202 (pending — the ledger's answer is pending; a refund.succeeded / refund.failed webhook 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_…) and live (mapp_…). An intent created with the test credential is livemode: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/net are null.
  • 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 than fee_min, no more than fee_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 Ulaanbaatar HH: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.