Skip to content

Error codes

Use this page to read a refusal, decide what to do about each code, and know which requests may simply be sent again. It is a reference — search it by code (Ctrl+F) rather than reading it top to bottom. You need: your backend's HTTP client.

Every code comes from the module's own code (refuse, oauthError, the intent/refund/settlement failure_codes). Codes are only ever added — decide an unknown one by its HTTP status (4xx — fix the request, 5xx — retry).

Response shapes

The platform refuses in two shapes; both SDKs (Node GeregeError, Go *miniapp.APIError) read both.

Shape Where Example
OAuth (RFC 6749 / 6750) client and token authentication (invalid_client, invalid_token, insufficient_scope) and every refusal of POST /oauth/token {"error": "invalid_grant", "error_description": "…"}
Module every other refusal {"error": "<Mongolian message>", "code": "<code>", …extras}
Plain 404 (not found, another app's, or the other environment's), 429 (rate limit), an unexpected 500 {"error": "not found"} — no code
{
  "error": "Терминалд өөр нээлттэй төлбөр байна",
  "code": "terminal_busy",
  "payment_intent": "pi_T3rm1nalVend0000000000Ab"
}
  • Branch on code in your program. error is a Mongolian message that is safe to show a person — never branch on it (the wording may change).
  • Extra fields: details (validation_error's [{path, message}]), payment_intent (the intent as it is now, or, for terminal_busy, the open intent's id), status (not_cancelable, not_claimable), retry_after (already_claimed), scopes, app (consent_required).
  • The SDKs give a response without code the code http_<status> (http_404, http_429, http_500).
  • An OAuth-shaped 401/403 carries a WWW-Authenticate header (Bearer error="invalid_token", Bearer error="insufficient_scope", scope="payments").

Should I retry? Network errors, 429, 5xx and 202 (still in progress — read it again) are safe to send again with the same Idempotency-Key (err.retryable in Node, apiErr.Retryable() in Go). Repeating a 4xx unchanged gives the same answer — fix the cause first. The «Retry» column below says so for each code.

Authentication (OAuth)

HTTP code When What to do Retry
401 invalid_client wrong client id/secret; the key was revoked, has expired, or was rolled and its overlap is over (indistinguishable); no Basic credential on a route that needs one (terminals, sub-merchants, statements, endpoints, events); the app is suspended/disabled; the live key of an app not yet approved check the credential and its environment; the key's and the app's state in the console no
401 invalid_token the person's access token is missing, wrong, expired or revoked refresh, or have the person open the app again after refreshing
403 insufficient_scope the person's token or the app lacks the payments consent scope (WWW-Authenticate: Bearer …); or the API key lacks the route's scope — required_scope names it (WWW-Authenticate: Basic …). Since 1.13.0 the module's code: "insufficient_scope" comes beside OAuth's fields consent: add payments to the manifest and submit for review; key: a new key with that scope no
403 ip_not_allowed the caller's address is not in the key's IP allowlist (ip) — OAuth shape fix the allowlist no
400 invalid_request token: the body could not be read, no grant_type, credentials both in Basic and in the body fix the request no
400 unsupported_grant_type grant_type is neither authorization_code (launch_code) nor refresh_token no
400 invalid_grant the launch code / refresh token is wrong, expired, used or another app's. Presenting a used refresh token again revokes the whole chain a new launch no
500 server_error a platform fault (token endpoint, token lookup) wait and retry yes

More: Mini app launch & tokens; the console's key codes — «API keys and scopes» below.

Requests and idempotency

HTTP code When What to do Retry
400 invalid_request the body could not be read — not JSON, too large, or naming a field the route does not take fix the request no
400 validation_error a field is wrong; details[].path names it (amount, description, expires_in, return_url, terminal_id, min_age, capture_within, account …). An amount above the operator's max_amount for the app is one too (path: "amount") read details and fix no
400 idempotency_key_required Idempotency-Key is missing or not 1–128 printable ASCII characters without spaces add a key no
409 idempotency_conflict the key was used before with a different body (for a person, a different account) a new key for a new operation; for a retry, the exact same body no
400 invalid_status · invalid_cursor · invalid_limit a list's status, after/cursor or limit (1–100) is wrong fix the parameter no
404 — (http_404) no such object, or it is another app's / the other environment's (a live intent read with a test key) check the id and the key's environment no
429 — (http_429) rate limit (Limits) back off (e.g. 1, 2, 4 … seconds) yes
500 — (http_500) an unexpected platform fault with the same Idempotency-Key yes

The idempotency rules: API conventions.

Payment intents

POST /payment-intents, GET …, …/capture, …/cancel, …/unclaim. Statuses and transitions: Payment intent.

HTTP code When What to do Retry
400 presentment_required client credentials (Basic) without presentment, or with app qr · redirect · terminal; an app intent is created with the person's token no
400 description_not_allowed the description holds a web address, an e-mail address or a phone number (details says which) — the person reads it in the payment sheet just before Face ID (1.13.0) write the order's summary; your own number goes in reference no
400 validation_error (path: "presentment") a person's token creating qr/redirect/terminal claimless intents take client credentials no
503 payments_unavailable live, and core banking is not enabled on this installation later yes
409 payouts_not_configured live, and the app has no payout account, or the sub-merchant's holder has not approved one the operator / a payout link no
503 pay_url_unavailable the installation cannot make pay links or terminals (no public origin configured) the operator yes (later)
409 terminal_busy the terminal already has an open, unclaimed intent in this environment — payment_intent is its id. On unclaim: the terminal opened a new basket meanwhile cancel that intent and try again after canceling
409 terminal_disabled · sub_merchant_disabled the terminal / sub-merchant is disabled PATCH … {status: "active"} no
409 agreement_required live, with the go-live rule on (1.14.0): the organisation has not signed the merchant agreement in force with eID console → Agreements once signed
409 business_profile_required · business_profile_rejected · business_profile_changed live, rule on: the business profile was never approved, was rejected, or the registered legal name/register differs from the one approved console → Business profile once approved
409 business_restricted live: the operator paused the organisation's new live payments (refunds, captures and settlements go on) the operator once resumed
409 business_limit_exceeded live: the tier's limit — limit is per_payment (one intent) or daily (live intents created today in Ulaanbaatar, open ones included), with max and used a smaller amount / tomorrow / ask for a higher limit in the console once raised
409 not_capturable only a manual intent in requires_capture can be captured (payment_intent attached) read payment_intent.status and decide no
409 expired the capture window (capture_before) has passed — the money went back to the person (payment_intent attached) close the order no
422 payment_failed the ledger refused the capture; the hold is released (payment_intent.failure_code is capture_failed) close the order no
409 in_progress a ledger call for this intent is in flight wait briefly and GET yes
409 not_cancelable the intent's state cannot be canceled (status attached) if paid, refund no
409 not_unclaimable only a claimed, unconfirmed claimless intent can be released (payment_intent attached) wait, or cancel no
500 server_error the terminal / sub-merchant could not be read retry yes

202 Accepted is not an error: the ledger's answer to the capture is not known yet (status: "processing"). Read it with GET a little later or wait for the webhook — there is no need to capture again (doing so is still safe).

Refunds

POST /payment-intents/{id}/refunds (Refunds).

HTTP code When What to do Retry
409 not_refundable only a succeeded or partially_refunded intent can be refunded no
409 refund_window_expired more than 90 days have passed since the payment settle with the person yourself no
400 validation_error amount < 1, or refunds would total more than was paid; reason > 255 no
400 · 409 idempotency_key_required · idempotency_conflict as above; the key is unique per intent no
503 payments_unavailable live, core banking not enabled yes

A refund the ledger refuses is not an HTTP error: the answer is 201 with the refund's status: "failed", a failure_code from the table below, and a refund.failed webhook. When the answer is not known yet it is 202 with status: "pending" — the platform finishes it and sends the webhook. The same key again answers 200 with that refund.

/terminals…, /sub-merchants… — client credentials (Basic) only. Batch and sticker-PDF codes — «Terminal tools» below.

HTTP code When What to do Retry
403 live_only a test key creating or changing a terminal / sub-merchant, or requesting a payout link (test keys only read — the registry is one for both environments) the live key or the console no
409 reference_taken another terminal of the app has this reference another reference no
400 invalid_sub_merchant sub_merchant_id is not this app's no
400 code_immutable an attempt to change a sub-merchant's code register a new sub-merchant no
409 sub_merchant_disabled a payout link for a disabled sub-merchant enable it first no
400 validation_error (path: "account") the payout account was not found, is inactive or is not MNT no
422 account_unsupported the account's holder is neither a Gerege App person nor an organisation another account no
422 no_admin an organisation's account with no admin to ask the organisation appoints an admin no
503 directory_unavailable the organisation's admins could not be read just now — "could not tell", never "none" retry yes
502 core_unavailable core banking did not answer retry yes
503 payments_unavailable · pay_url_unavailable core banking / the merchant registry is not enabled on the installation yes (later)

More: Pay with Gerege App, Payout accounts.

Settlements

GET /settlements, GET /settlements/{id} (Settlements).

HTTP code When What to do Retry
503 settlements_unavailable the installation has no settlement store yes (later)
400 invalid_status · invalid_cursor · invalid_limit status is not pending/paid/failed, a bad cursor, limit not 1–100 no
404 — (http_404) no such settlement, or read with a test key (test mode never has settlements; the list is empty) the live key no

failure_code

Not a refusal — a field on the object that says what happened.

Intent (payment_intent.failure_code)

failure_code Meaning Intent
insufficient_funds the balance is short failed — the person may pay again from another account
limit_exceeded a transaction limit of the person's account was exceeded failed, payable again
account_unavailable the chosen account cannot pay (closed, not the person's) stays requires_confirmation, no webhook
authorization_expired a manual intent's hold window (capture_before) ran out expired
expired the intent ran out of time during confirmation expired
merchant_unavailable the app stopped taking payments (suspended, no payout) — the hold was released failed
capture_failed the ledger refused the capture — the hold was released failed
hold_mismatch the ledger's hold did not match the intent and was not used failed
captured_after_release the hold had already been captured when it was released — the platform reconciles by hand (status unchanged)
test_declined test mode, an amount ending …01 (Test amounts) failed
payment_failed any other refusal failed

A failed intent is payable again, as in OMAS (a new attempt places a new hold).

Refund (refund.failure_code)

failure_code Meaning What to do
insufficient_funds the payout account has already spent the payment top the account up and refund again with a new key
settlement_shortfall the app's clearing account cannot cover it (the payment was already settled) contact the operator
refund_rejected any other refusal contact the operator

Settlement (settlement.failure_code)

failure_code Meaning
idempotency_conflict the ledger saw the same key with other lines — the operator investigates
insufficient_funds the clearing balance disagreed with the records
destination_unavailable the payout account is closed or frozen — the operator changes the payout and settles again
limit_exceeded a ledger limit
rejected any other refusal

The operator retries or voids a failed settlement (its lines then go into the next one).

The person's side (for information)

Gerege App (iOS) receives these; your backend never does, but they explain what the person saw.

HTTP code When
404 unknown_payment · unknown_terminal · nothing_pending the pay_url token is not known (or is a test payment this person may not see) · no such or inactive terminal · nothing to pay at the terminal now
409 already_claimed somebody else is paying it (retry_after: 90 — an unconfirmed claim lapses after 90 seconds)
409 not_claimable the intent cannot be paid now (status attached)
410 expired the intent ran out of time when it was claimed
429 too_many_open_claims the person already holds 3 unconfirmed claims
409 merchant_unavailable the app does not take payments now (suspended, disabled, no payout)
403 verification_required · age_restricted the app or the intent's min_age needs an eID-verified person · under age, or the age cannot be read
409 already_processed · in_progress · expired confirmation: already decided · in flight · ran out of time
409 · 403 · 422 insufficient_funds · limit_exceeded / account_unavailable · test_declined / payment_failed confirmation outcomes (the intent attached)
409 not_pending · account_unavailable payout link: already answered or expired · the account is no longer the person's (or their organisation's)
428 consent_required launch: the person has not granted the scopes (scopes, app attached)
409 · 403 · 400 maintenance · channel_unavailable · invalid_path / invalid_channel / invalid_scope launch: the app is in maintenance · not offered on this channel · a bad path, channel or scope

Bridge codes: Bridge.

Developer console and sandbox

Codes of the console (/api/v1/miniapp/dev/…) and of the operator's review — your server integration never receives them; the console's screens show them.

HTTP code When
409 slug_taken another app has this slug
409 sandbox_only a sandbox organisation (a person's, with no legal entity): no live key, live secret, review submission, agreement, payout link or app payout — test mode works in full (Sandbox)
400 sandbox_slug a sandbox's app slug begins with sbx- (^sbx-[a-z0-9-]{3,27}$), and no other organisation may use sbx-
503 sandbox_unknown the platform could not say whether the organisation is a sandbox — nothing live was done; try again
409 · 400 version_conflict · version_required somebody changed it meanwhile — read again and redo the change · the change must name the version it was made against
409 locked_for_review · needs_review · status_locked · suspended an app in review cannot change · a sensitive field of an approved app (kind, return_hosts …) — draft or rejected only · the status cannot change this way · the app is suspended
400 unknown_field · invalid_field · invalid_environment · invalid_range · invalid_app_id an unknown PATCH field · wrong type · environment not live/test · analytics dates (≤ 366 days) · app_id not a UUID
400 · 409 not_ready · not_submittable submit: requirements missing (details) · not in a state that can be submitted
409 merchant_no_manifest a merchant has no manifest to check
503 no_secret_key the installation has no key to seal the webhook secret with — it is never stored in plaintext
409 no_webhook no webhook URL / endpoint (send again, webhook.test) — the other endpoint codes are under «Webhook endpoints and the events API» below
404 · 409 · 503 user_not_found · too_many_test_users · directory_unavailable the test user is not an active member of your organisation · > 50 · the member list cannot be read just now
400 idempotency_key_invalid the sandbox test intent's Idempotency-Key is not 1–128 characters
409 merchant_unavailable · already_processed · expired confirming a test intent
503 settlements_unavailable no settlement store
422 · 422 account_not_owned · account_unavailable the app's payout (PUT …/apps/{id}/payout): the account is not your organisation's — the operator sets it · no such account, not active, or not MNT
403 admin_required · impersonated only the organisation's admin, in their own session, sets the app's payout

Operator codes (operator console only): invalid_review_status, not_in_review, already_suspended, note_required, not_draft, not_archived (layouts), settlements_off, settlement_open, refund_pending, not_failed, not_retryable, rule_clash, rule_in_effect, ledger_seg_taken, gl_unavailable.

The codes of withdrawing from a wallet to a bank (/api/v1/bank/…): Withdrawals to a bank.

SDK errors

Node (@gerege/miniapp-node) Go (sdk/go)
API refusal GeregeError — status, code, message, requestId, details (the extra fields) *miniapp.APIError — StatusCode, Code, Message, RequestID, Details ([]Issue), Extra
Token endpoint GeregeOAuthError (error, error_description) *APIError (Code = OAuth's error)
Network / timeout GeregeError with status: 0, code: "network_error" a wrapped error (not an APIError)
Retry? err.retryable — 0, 429, 5xx apiErr.Retryable() — 429, 5xx
Before the request TypeError (key, id or credential missing) ErrIdempotencyKey, ErrPresentment, ErrMissingID, ErrMissingAccount, ErrNoCredentials
Webhooks GeregeWebhookError.reason ErrMissingHeader, ErrMalformedHeader, ErrTimestamp, ErrSignatureMismatch, ErrInvalidPayload, ErrNoSecret

More: Node.js, Go.

The sections below hold the codes of the developer platform's new APIs (keys, endpoints and events, the dashboard and statements, terminal tools, agreements) — each change that adds an API writes its own section.

API keys and scopes

API keys (1.8.0): client id gk_live_… / gk_test_…, secret gks_live_…_<checksum> (the old mapp_… + gma_cs_… pair = the «default key», with every scope of its environment). Every server route names the scope it needs in the OpenAPI document's x-gerege-scope.

HTTP code When What to do
401 invalid_client wrong secret; the key was revoked, has expired, or was rolled and its overlap is over (indistinguishable) check the key's state in the console
403 insufficient_scope the key lacks the route's scope — required_scope names it (WWW-Authenticate: Basic error="insufficient_scope", scope="…") a new key with that scope (scopes do not change)
403 ip_not_allowed this address (ip) is not in the key's IP allowlist fix the allowlist
403 live_only a test key writing a terminal / sub-merchant / payout link (checked before the scope) the live key or the console

Console (/dev/apps/{id}/keys…): 400 validation_error (details[].path: name, environment, scopes — unknown, duplicate, live-only on a test key, preset, allowed_ips, expires_at, overlap_seconds, reason), 400 scopes_immutable (a PATCH trying to change the scopes), 403 permission_required (permission: miniapp.develop — test, miniapp.keys.live — live), 403 fresh_sign_in_required (within_seconds), 409 name_taken, 409 too_many_keys (20 active per environment), 409 key_not_active (status: revoked / expired / rolled), 409 suspended, 503 fresh_sign_in_unavailable (the installation could not tell when the member signed in — no live key is created, rolled or re-addressed, no live secret issued by POST /dev/apps/{id}/credentials; try again later), 409 sandbox_only (a sandbox holds no live key), 400 invalid_environment / invalid_cursor / invalid_limit (lists). Since 1.13.0 a live key or live secret needs miniapp.keys.live and a sign-in within the last ten minutes (403 fresh_sign_in_required); IP allowlists use the address the core resolves (behind its trusted proxies).

Webhook endpoints and the events API

/webhook-endpoints (client credentials, the credential's environment) and the console's /dev/apps/{id}/webhook-endpoints, /dev/apps/{id}/webhook/…; /events (client credentials).

HTTP code When What to do
409 too_many_endpoints the environment already has 5 endpoints (endpoint №1 included) delete or merge one
403 live_only a test key called /webhook-endpoints — webhooks:write is live keys' only test endpoints are the console's
403 insufficient_scope the key lacks webhooks:write / events:read (required_scope) a key with that scope
403 environment_mismatch the key tried to create / list an endpoint of the other environment use that environment's key
409 environment_mismatch console: redelivering a live event to a test endpoint (or the other way round) pick an endpoint of the same environment
400 validation_error url (https, a public address), environment, description ≤ 200, event_types (known, no duplicates, ≤ 20; not webhook.test), status details[].path
409 suspended · locked_for_review the app is suspended; endpoint №1's URL/secret of an app in review wait for the review
409 no_webhook rotating endpoint №1's secret after the app's webhook was removed; the console's test/redelivery has no endpoint add an endpoint
503 no_secret_key the installation has no key to seal secrets with contact the operator
400 invalid_type the type filter is not an event type
400 test_event_not_listed /events?type=webhook.test — the console's test send goes to the endpoint it names and is never in /events the console's delivery log
400 invalid_endpoint console: the endpoint_id filter is not an endpoint's id
400 invalid_cursor · invalid_limit · invalid_environment a list parameter is wrong
400 unknown_cursor /events?after= is not this app's and environment's event, or is older than 30 days (purged) start again with created_gte
400 invalid_created_gte created_gte is not RFC 3339
404 — (http_404) no such endpoint/event, or it is another app's / environment's

An endpoint failing every delivery for 24 hours gets the organisation's admins notified (miniapp.webhook.failing); for 5 days it becomes status: auto_disabled (miniapp.webhook.disabled) — fetch what it missed from /events, then PATCH {"status":"active"}.

Dashboard, statements and simulation

The console's payments dashboard (/dev/payments…, /dev/refunds, /dev/statements, /dev/audit), the merchant backend's GET /statements and the sandbox's simulation (…/simulate, …/simulate-tap).

HTTP code When
400 idempotency_key_required a dashboard action (capture, cancel, unclaim, refunds) without an Idempotency-Key (1–100)
400 validation_error details[].path: reason (required on a refund, ≤ 255), amount, app_id, terminal_id, sub_merchant_id, created_gte/created_lt (RFC 3339), amount_gte/amount_lte, q (≤ 128), presentment, action, test_user_id
400 invalid_status · invalid_environment · invalid_cursor · invalid_limit · invalid_format · invalid_range filters, paging, format (json | csv), from/to (YYYY-MM-DD, ≤ 366 days)
403 permission_required permission: miniapp.payments (actions), miniapp.finance (CSV, statements) or miniapp.develop (simulation)
403 fresh_sign_in_required a live refund of 1 000 000₮ or more from the dashboard — sign in again within within_seconds (600) and repeat
503 fresh_sign_in_unavailable the installation could not check a fresh sign-in — the large refund is not made
409 not_capturable · expired · not_cancelable · in_progress · not_refundable · refund_window_expired · idempotency_conflict · not_unclaimable · terminal_busy from the app backend's own path — the same meaning as under «Payment intents» and «Refunds» above
409 live_intent simulation is for test intents only
409 not_a_test_user test_user_id (or the caller) is not one of the app's test users or its owner
409 not_claimless · not_claimed · not_expirable a claim of an app intent; confirm/decline of an unclaimed one; expire of a finished intent
409 already_claimed (retry_after) · already_processed · merchant_unavailable · terminal_disabled as for a real claim and confirmation
404 nothing_pending simulate-tap: no open test intent at the terminal
410 expired the simulated claim — the intent ran out of time
429 too_many_open_claims the test user holds 3 unconfirmed claims
422 statement_too_large the statement is over 100 000 rows (max_rows) — shorten the range
503 dashboard_unavailable · pay_url_unavailable · payments_unavailable the installation's configuration / a passing fault — try again

Terminal tools

POST /terminals/batch, POST /terminals/print-sheet (the console also has GET …/print-sheet and GET …/terminals/{tid}/stats):

HTTP code When
400 too_many_terminals the batch is empty or has more than 500 rows (max); printing without terminal_ids when more than 500 terminals are active
400 invalid_csv Content-Type: text/csv could not be read: no header row, no label column, an unknown or duplicate column (columns), a broken row (line)
400 validation_error details[].path = terminals[i].label … — one bad row refuses the whole batch and nothing is written; printing: layout, locale, crop_marks, terminal_ids[i] (a wrong id, a duplicate, not found, a disabled terminal)
403 live_only a batch with a test key — the registry is written only by the live key or the console (printing and reading statistics work in test too)
409 no_terminals no active terminal to print
400 invalid_range / invalid_environment the statistics' from/to (YYYY-MM-DD, ≤ 366 days), environment

A batch answers 200 with each row's status: created, existing (that reference is registered), failed (error.code: reference_taken, invalid_sub_merchant, …; the other rows ran), skipped (not attempted because of a server fault — send the batch again; rows with a reference are not created twice).

Agreements and eID signatures

The console's /dev/agreements… and the operator's /review/agreements… (not server-to-server). Starting a signature needs the gate's miniapp.manage and miniapp.agreements.sign (or admin), checked by the core, and you must be a representative of that organisation in eID.

HTTP code When What to do
202 — POST …/{id}/sign: the request reached the phone — {signature_id, verification_code, org_name} match the code with the phone's, then PIN2; poll with GET …/signatures/{sid}?wait=20
409 signing_unavailable the installation has no eID signing path (no SIGNATURE right on the RP, no PDF signing) contact the operator
503 no_secret_key the installation has no key to seal secrets with the operator
403 not_representative in eID's records you are not a current representative of this organisation a representative (the director, …) signs; fix the record at the state registry
403 permission_required no miniapp.agreements.sign (permission) an admin grants it
403 impersonated a session signed in as somebody else use your own session
409 no_eid no eID is linked to your account sign in with eID to link it
409 no_registration_number the organisation's profile has no registration number (always so in a sandbox) fill in the profile
409 already_signed this version is already signed (signature_id) —
409 signature_running a ceremony is open (signature_id, verification_code) check your phone or wait for it to end
409 agreement_superseded an old version — only the current or the upcoming one can be signed sign the current one
409 not_signed …/document: the signature has not finished yet poll
502 signing_failed the eID request could not be started try again
503 agreements_unavailable the installation has no agreements store —
400 invalid_wait wait is not 0–20

The ceremony's state (signature.status): running → completed | failed | expired | rejected (eID's). failure: rejected, expired, failed, no_answer (eID did not answer in 2 hours), session_lost, stamp_unavailable (no timestamp/OCSP for 24 hours), organisation_mismatch, not_a_pdf, no_certificate, file_store, signature_unreadable. After failed/expired/rejected a new one may be started.

Operator: 400 validation_error (details), 400 invalid_kind, 409 version_taken, 409 not_draft (editing, deleting or republishing a published one), 409 draft_text (in production, a template text still marked as a draft not reviewed by a lawyer). A sandbox organisation cannot start a signature: 409 sandbox_only (1.13.0).