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/WithBaseURLishttps://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}/credentialsreplaces it at once (no overlap). - A missing scope is
403 insufficient_scopewith{"required_scope": "refunds:write"}andWWW-Authenticate: Basic error="insufficient_scope", scope="refunds:write"(not a401— 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:writeandwebhooks:writeare never given to a test key: the registry is one for both environments, so a test key that writes gets403 live_only(checked before the scope).- From an address outside the key's IP allowlist —
403 ip_not_allowed(ipis the address seen). The platform decides the address (behind its trusted proxies) — never guess fromX-Forwarded-For. - Creating, rolling or re-addressing a live key needs
miniapp.keys.liveand a fresh sign-in (403 fresh_sign_in_required); when the installation cannot check one it answers503 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_clientfor every key. - An app sees only its own objects; another app's are
404. - Payment, registry, statement and
/meanswers carryCache-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 oldmapp_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/eventsread the key's environment.
Request format¶
- Bodies are JSON (
Content-Type: application/json); the token endpoint takesapplication/x-www-form-urlencodedor 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 isMNT. - 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 →
200and the object first made (as it is now; for a claimless intent the samepay_url). A new one is201. - Same key + different body →
409 idempotency_conflict. For anappintent 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 existingcode, answers200with 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 refundpending). The platform finishes it; read it again withGETor wait for the webhook.409 in_progress→ wait briefly and read again.- Do not repeat a
4xxunchanged — 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-Idheader, the SDKs put it inrequestId/RequestID— include it when you ask for help (Support). Never include a secret.