Skip to content

API conventions

Use this page to read, once, the rules every server API shares — base URL, authentication, environments, formats, ids, idempotency, retries, pagination — so you do not look for them on each endpoint's page. 10 minutes. You need: a test API key from the console (gk_test_… / gks_test_…).

Base URL and versioning

  • Every server route is under https://geregeapp.mn/api/v1/miniapp (the default of the SDKs' baseUrl / WithBaseURL is https://geregeapp.mn).
  • The major version is in the path (/api/v1). A minor version (the module's, e.g. 1.7.0) only adds: new fields, new values, new events, new error codes. So your code must ignore fields, events and codes it does not know — do not parse JSON strictly (additionalProperties: false, DisallowUnknownFields).
  • What changed in each version: the changelog (in the docs' «Reference» section).

Authentication

Two kinds of credential. The table below says which route takes which.

Credential Header Who, when
The app's API key (client credential) Authorization: Basic base64(client_id:client_secret) your server acting for itself: a merchant's claimless intents, intent reads/actions, settlements, statements, terminals, sub-merchants, webhook endpoints, events, the token endpoint
A person's access token Authorization: Bearer gma_at_… (payments consent scope) a mini app's backend acting for the person: creating an app intent, GET /me; also intent reads/actions and settlements
Route Basic (the key's scope) Bearer
POST /oauth/token ✓ oauth:token (or client_id/client_secret in the body — never both) —
GET /me — ✓
POST /payment-intents ✓ payments:write — qr · redirect · terminal only ✓ — app only
GET /payment-intents[/{id}] ✓ payments:read ✓
…/{id}/capture, …/{id}/cancel, …/{id}/unclaim ✓ payments:write ✓
…/{id}/refunds ✓ refunds:write ✓
GET /settlements[/{id}] ✓ settlements:read ✓
GET /statements ✓ settlements:read (Basic only) —
GET /terminals[/{id}], POST /terminals/print-sheet ✓ terminals:read (Basic only) —
POST /terminals, PATCH /terminals/{id}, POST /terminals/batch ✓ terminals:write (live key) —
GET /sub-merchants[/{id}], …/{id}/payout-links ✓ sub_merchants:read —
POST /sub-merchants, PATCH /sub-merchants/{id} ✓ sub_merchants:write (live key) —
POST /sub-merchants/{id}/payout-link ✓ payout_links:write (live key) —
/webhook-endpoints… ✓ webhooks:write (live key) —
GET /events[/{id}] ✓ events:read —
GET /pay/{token}, GET /pay/t/{code} no authentication (public)

API keys (1.8.0). An app has many named keys per environment (≤ 20 active), each with its own scopes and an optional IP allowlist and expiry. You create, roll (the old key keeps working through an overlap of 0 – 7 days, a day by default) and revoke them under the app's «Keys» in the console (/dev/apps/{id}/keys).

  • Client id: gk_live_ / gk_test_ + 20 base62 (not a secret, fine in a log). Secret: gks_live_ / gks_test_ + 43 characters of base64url + _ + a 6-character base62 checksum (the CRC32 of everything before it) — shown once in the console; the platform keeps only its SHA-256. The checksum is not secret: it lets a secret scanner recognise a real key's shape without a database (Security checklist).
  • The old pair (mapp_… / mapp_test_… + gma_cs_…) keeps working as the «default key», holding every scope of its environment. POST /dev/apps/{id}/credentials replaces it at once (no overlap).
  • A missing scope is 403 insufficient_scope with {"required_scope": "refunds:write"} and WWW-Authenticate: Basic error="insufficient_scope", scope="refunds:write" (not a 401 — the secret is right). A key's scopes never change — create a new key with other scopes. Presets: «Full access», «Checkout server», «Vending / kiosk backend», «Terminal installer», «Read only», «Mini app backend».
  • terminals:write, sub_merchants:write, payout_links:write and webhooks:write are never given to a test key: the registry is one for both environments, so a test key that writes gets 403 live_only (checked before the scope).
  • From an address outside the key's IP allowlist — 403 ip_not_allowed (ip is the address seen). The platform decides the address (behind its trusted proxies) — never guess from X-Forwarded-For.
  • Creating, rolling or re-addressing a live key needs miniapp.keys.live and a fresh sign-in (403 fresh_sign_in_required); when the installation cannot check one it answers 503 fresh_sign_in_unavailable.
  • A revoked key, an expired one, or a rolled one past its overlap is 401 invalid_client, indistinguishable from a wrong secret.
  • A live key works only after the app is approved; a suspended or disabled app gets 401 invalid_client for every key.
  • An app sees only its own objects; another app's are 404.
  • Payment, registry, statement and /me answers carry Cache-Control: no-store — no intermediate cache keeps them.
  • Secrets and tokens live only on servers — Security checklist.
curl -s https://geregeapp.mn/api/v1/miniapp/payment-intents?limit=5 \
  -u "$GEREGE_CLIENT_ID:$GEREGE_CLIENT_SECRET"

Environments

  • The credential decides the environment: gk_test_… (the old mapp_test_…) → test, gk_live_… (mapp_…) → live; a person's token carries the environment of its launch.
  • Every object has livemode (true = live). A test credential never sees a live object, nor the other way round (404).
  • No money moves in test mode; test amounts and test users — Sandbox.
  • Terminals and sub-merchants are one registry for both environments: a test credential reads it; writing takes the live credential or the console (403 live_only). Webhook endpoints are per environment, but only a live key manages them through the server API (/webhook-endpoints) — test endpoints are the console's. Settlements are live only (empty with a test credential); a statement (/statements) and /events read the key's environment.

Request format

  • Bodies are JSON (Content-Type: application/json); the token endpoint takes application/x-www-form-urlencoded or JSON.
  • A field the route does not take, a body that is not JSON, or one that is too large → 400 invalid_request (sizes: Limits). This is on purpose: a misspelt field is not silently ignored.
  • Leaving out an optional field takes its default (currency: "MNT", expires_in: 600, capture_method: "automatic").
  • Answers are JSON; a field documented as nullable is always present (Objects).

Money and time

  • Money is whole ₮ (3500 = 3 500 ₮), no fractions; the only currency today is MNT.
  • Times are RFC 3339 in UTC (2026-10-22T09:00:00Z). Settlement schedules and «days» are in Ulaanbaatar time (Settlements).
  • Durations are given in seconds (expires_in, capture_within).

Ids and prefixes

Object Form
Payment intent · Refund · Event · Settlement · Webhook endpoint pi_ · re_ · evt_ · st_ · we_ + 24 base62
Terminal, sub-merchant, payout link, API key (the console's id) UUID (lower case)
A terminal's code 10 characters of 23456789abcdefghjkmnpqrstuvwxyz (no 0/o, 1/l/i — read by eye)
Person user_ref / sub (u_ + 32 hex): for a mini app a stable pairwise value per app; for a claimless payment random per payment
Credentials gk_live_, gk_test_ (client id), gks_live_, gks_test_ (secret); the old mapp_, mapp_test_, gma_cs_; gml_ (launch code), gma_at_, gma_rt_, whsec_

An id of the wrong form is a 404 (not a 400). The prefixes help secret scanners recognise a credential and let you refuse the wrong kind of value early.

Idempotency

Requests that move money require an Idempotency-Key header:

Request Key is unique within
POST /payment-intents the app and environment
POST /payment-intents/{id}/refunds the intent
A person's POST /payments/{id}/confirm (Gerege App) the intent
  • Value: 1 – 128 printable ASCII characters, no spaces. Missing/invalid → 400 idempotency_key_required.
  • Same key + same body → 200 and the object first made (as it is now; for a claimless intent the same pay_url). A new one is 201.
  • Same key + different body → 409 idempotency_conflict. For an app intent the person is part of the «body» — the same key with another person's token is a conflict.
  • Advice: one order = one key — use your order/basket number as the key (and the reference) (order:8812). A retry after a timeout then never makes a second payment.
  • The registry is idempotent its own way: creating a terminal with an existing reference, or a sub-merchant with an existing code, answers 200 with the row that is there.
  • Capture, cancel and unclaim take no key but are safe to call again: capturing a captured intent answers it as it is, canceling a canceled one too.
curl -s 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": "Захиалга #8812", "reference": "order:8812", "presentment": "redirect", "return_url": "https://shop.mn/done"}'

Retries

  • Network errors, timeouts, 429, 5xx → again with the same key (exponential backoff). err.retryable / apiErr.Retryable() in the SDKs.
  • 202 Accepted → the ledger's answer is not known yet (status: "processing", or a refund pending). The platform finishes it; read it again with GET or wait for the webhook.
  • 409 in_progress → wait briefly and read again.
  • Do not repeat a 4xx unchanged — see the «Retry» column of Error codes.
  • The SDKs do not retry by themselves — the retry policy is yours.

Pagination

List Shape Next page
GET /payment-intents?reference=&status=&limit=&after= (newest first) {"data": [...], "has_more": bool} after = this page's last id
GET /settlements?status=&limit=&cursor= (newest first) {"items": [...], "next_cursor": string\|null, "has_more": bool} cursor = next_cursor
GET /events?type=&created_gte=&limit=&after= (oldest first, 30 days) {"data": [...], "next_cursor": "evt_…"\|null} after = next_cursor; null — nothing newer yet, ask again later from your last id
GET /terminals, GET /sub-merchants, …/payout-links, GET /webhook-endpoints {"data": [...]} unpaginated
GET /statements?from=&to=&format= one statement (data rows) unpaginated — split by date range (≤ 100 000 rows)

limit is 1 – 100 (default 20; 50 for /events). A wrong value → 400 invalid_limit / invalid_cursor / invalid_status. In Node, listAll() follows every page.

let after;
do {
  const page = await gerege.paymentIntents.list({ status: "succeeded", limit: 100, after });
  for (const pi of page.data) reconcile(pi);
  after = page.has_more ? page.data.at(-1).id : undefined;
} while (after);

Errors

  • Two shapes (OAuth and the module's); branch on code — Error codes.
  • When an answer carries an X-Request-Id header, the SDKs put it in requestId / RequestID — include it when you ask for help (Support). Never include a secret.