First test payment in 15 minutes¶
What you will do: create a
qrpayment intent with a test key, play the person's side, receive the webhooks and capture the money. Time: about 15 minutes. No money moves — everything is in test mode (livemode: false). Server:modules/miniapp1.9.0.
At the end you have a merchant app (kind: "merchant"), a scoped test API key, a webhook endpoint and a receiver
that verifies signatures, and one test intent that went requires_capture → succeeded. The ideas behind it are in
Payment lifecycle and Presentments and claims.
0. What you need¶
| A Gerege App account | signed in at geregeapp.mn with eID |
| An organisation | one where you are an admin or a manager (a workspace) — §1 |
| Tools | curl, jq, Python 3 (for the webhook receiver), cloudflared or ngrok (a tunnel) |
Without a legal entity
A sandbox organisation for individuals (one click, no eID representation) is «удахгүй» (not built yet).
Until then ask a colleague's organisation to add you as a member (its /join/<slug> link → an admin approves),
or open your company's organisation yourself (a person listed in eID as the company's ADMIN representative:
profile → Organisations).
1. Install «Mini апп-ууд» in your organisation¶
An admin of the organisation: geregeapp.mn → choose the organisation → App Store (/apps) → «Mini апп-ууд»
(Mini apps, mn.gerege.miniapp) → Install. This opens the developer console and the Pay with Gerege App API to the
organisation. Once only; from that moment the whole test mode works — no review needed.
2. Authenticate to the console API¶
Registering a merchant app (§3) is, for now, done through the console API only (a «Merchant» option in the console's
register sheet is «удахгүй»); this page does §4–§5 through the API too. The console API takes your person's
session (Authorization: Bearer):
- Make your organisation the active one on geregeapp.mn (the organisation switcher in the header).
- Browser DevTools → Application (Storage) → Cookies →
https://geregeapp.mn→ copy the value ofsession_token.
A session is your whole sign-in
Never hand it to anyone, never paste it into a repository, a log or a chat. It lives 12 hours at most and dies
when you sign out. Use it only for the console (/dev/…) and for playing the person's side (§7) — your server
talks with an API key (§4).
3. Register a merchant app¶
curl -sS "$GEREGE/dev/apps" \
-H "Authorization: Bearer $GEREGE_SESSION" -H 'Content-Type: application/json' \
-d '{"kind": "merchant", "slug": "my-shop-test", "name": "My Shop"}' | tee app.json | jq
export APP_ID=$(jq -r .id app.json)
Expected 201 (abridged):
{
"id": "00000000-0000-4000-8000-000000000001",
"slug": "my-shop-test", "name": "My Shop", "kind": "merchant",
"scopes": ["profile.basic", "payments"], "category": "services",
"status": "draft", "review_status": "draft",
"live_client_id": "mapp_…", "test_client_id": "mapp_test_…",
"has_live_secret": false, "has_test_secret": false,
"webhook_url": null, "return_hosts": [], "payouts_configured": false, "version": 1
}
slug— 2–32 characters,a-z,0-9,-, unique on the platform:409 slug_takenmeans pick another. In a personal sandbox organisation the slug begins withsbx-(sbx-my-shop; Sandbox).- A merchant never appears in Gerege App's launcher and needs no manifest (Merchants and mini apps).
4. Get a test API key¶
The «Checkout server» preset (checkout_server) = payments:read, payments:write, refunds:write, events:read —
every permission this page needs:
curl -sS "$GEREGE/dev/apps/$APP_ID/keys" \
-H "Authorization: Bearer $GEREGE_SESSION" -H 'Content-Type: application/json' \
-d '{"name": "quickstart", "environment": "test", "preset": "checkout_server"}' | tee test-key.json | jq
export GEREGE_CLIENT_ID=$(jq -r .client_id test-key.json)
export GEREGE_CLIENT_SECRET=$(jq -r .client_secret test-key.json)
{ "id": "…", "object": "api_key", "name": "quickstart", "environment": "test",
"client_id": "gk_test_…", "secret_hint": "…", "status": "active", "legacy": false,
"scopes": ["payments:read", "payments:write", "refunds:write", "events:read"], "allowed_ips": [],
"client_secret": "gks_test_…", "notice": "Энэ secret-ийг дахин харуулахгүй — одоо хадгална уу" }
The secret is shown once (the server keeps only its SHA-256; secret_hint is its last 4 characters, to tell keys
apart). Every merchant call from here on is Authorization: Basic base64(client_id:client_secret) — -u in curl. A
missing scope is 403 insufficient_scope + required_scope. Roll (…/keys/{kid}/roll) and revoke
(…/keys/{kid}/revoke) from the console. The old POST /dev/apps/{id}/credentials (mapp_test_… / gma_cs_…, all
permissions) keeps working; live keys come from that route for now (the new API needs a fresh sign-in for them —
«удахгүй»).
The secret lives on your server only: never in a browser, a mobile app, a terminal or a machine
(Security model).
5. A webhook receiver¶
Gerege delivers webhooks to public https addresses only — not to localhost directly — so expose your local
server through a tunnel.
5.1 The tunnel (in its own terminal) — note the https://….trycloudflare.com address it prints:
5.2 Create a test webhook endpoint and get its secret (empty event_types = every event; an app has ≤ 5
endpoints per environment, each with its own secret):
export TUNNEL=https://<your-tunnel>.trycloudflare.com
curl -sS "$GEREGE/dev/apps/$APP_ID/webhook-endpoints" \
-H "Authorization: Bearer $GEREGE_SESSION" -H 'Content-Type: application/json' \
-d "{\"url\": \"$TUNNEL/gerege\", \"environment\": \"test\", \"event_types\": []}" | tee webhook.json | jq
export ENDPOINT_ID=$(jq -r .endpoint.id webhook.json)
export GEREGE_WEBHOOK_SECRET=$(jq -r .webhook_secret webhook.json)
{ "endpoint": { "id": "we_…", "object": "webhook_endpoint", "environment": "test", "livemode": false,
"url": "https://<your-tunnel>.trycloudflare.com/gerege", "event_types": [], "status": "active" },
"webhook_secret": "whsec_…", "notice": "Энэ secret-ийг дахин харуулахгүй — одоо хадгална уу" }
5.3 The receiver — webhook.py (Python's standard library only):
import hashlib, hmac, json, os, time
from http.server import BaseHTTPRequestHandler, HTTPServer
SECRET = os.environ["GEREGE_WEBHOOK_SECRET"].encode()
def verify(header: str, body: bytes) -> bool:
t, sigs = None, []
for part in header.split(","):
key, _, value = part.strip().partition("=")
if key == "t" and value.isdigit():
t = int(value)
elif key == "v1":
sigs.append(value)
if t is None or abs(time.time() - t) > 300: # older than 5 minutes: a replay
return False
want = hmac.new(SECRET, str(t).encode() + b"." + body, hashlib.sha256).hexdigest()
return any(hmac.compare_digest(want, s) for s in sigs)
class Hook(BaseHTTPRequestHandler):
def do_POST(self):
body = self.rfile.read(int(self.headers.get("Content-Length", 0))) # raw bytes — before parsing
if not verify(self.headers.get("Gerege-Signature", ""), body):
self.send_response(400); self.end_headers(); return
event = json.loads(body)
obj = event["data"]["object"]
print(event["id"], event["type"], obj.get("id"), obj.get("status"), flush=True)
self.send_response(204); self.end_headers() # a 2xx within 10 s
HTTPServer(("127.0.0.1", 8787), Hook).serve_forever()
5.4 Check it — send a signed webhook.test now:
curl -sS -X POST "$GEREGE/dev/apps/$APP_ID/webhook-endpoints/$ENDPOINT_ID/test" \
-H "Authorization: Bearer $GEREGE_SESSION" | jq '{ok, status_code}'
You get {"ok": true, "status_code": 204} and the receiver prints evt_… webhook.test None None. Every signature
rule: Webhooks.
6. Create a qr intent¶
Your server creates one intent per basket. This one uses manual capture — the money is first held, and taken only after you hand over the goods (Holds and capture).
curl -sS "$GEREGE/payment-intents" -u "$GEREGE_CLIENT_ID:$GEREGE_CLIENT_SECRET" \
-H 'Content-Type: application/json' -H 'Idempotency-Key: order-1001' \
-d '{"amount": 3500, "description": "Order #1001", "reference": "order-1001",
"presentment": "qr", "expires_in": 600,
"capture_method": "manual", "capture_within": 300}' | tee pi.json | jq
export PI=$(jq -r .id pi.json)
export PAY_TOKEN=$(jq -r '.pay_url | split("/") | last' pi.json)
Expected 201:
{
"id": "pi_8Hn2Wq4Zc6Xv1Bm3Np5Rt7Ly",
"object": "payment_intent",
"status": "requires_confirmation",
"amount": 3500,
"currency": "MNT",
"description": "Order #1001",
"reference": "order-1001",
"user_ref": null,
"livemode": false,
"capture_method": "manual",
"refunded_amount": 0,
"fee": null,
"net": null,
"failure_code": null,
"expires_at": "2026-10-27T03:10:00Z",
"capture_before": null,
"confirmed_at": null,
"created_at": "2026-10-27T03:00:00Z",
"metadata": {},
"presentment": "qr",
"terminal": null,
"sub_merchant": null,
"return_url": null,
"claimed_at": null,
"min_age": null,
"capture_within": 300,
"pay_url": "https://geregeapp.mn/pay/Qk3vX9aB2cD4eF6gH8jK0mNp"
}
- The same
Idempotency-Keywith the same body answers200— the same intent and the samepay_url; another body is409 idempotency_conflict(Idempotency). pay_urlis only ever in this answer (and its replay) — never in aGET, a list or a webhook. It carries no amount and no payee; do not log it.- A real till draws this
pay_urlon its screen as a QR (qrencode -t ansiutf8 "$(jq -r .pay_url pi.json)").
7. The person's side — claim and confirm¶
In real life the person scans the QR with Gerege App and taps Face ID on the payment sheet. Behind it the app makes two calls: claim (take the intent as theirs) and confirm (pick an account and pay).
Scanning with a phone
Gerege App (iOS)'s in-app QR button and NFC reader claim a pay_url and open the payment sheet — in builds
after TestFlight 121. Scanning with the phone's camera (Universal Link) and the desktop /pay page are
«удахгүй». If your phone does not have that build, make the app's two calls yourself with your own session,
as below — the server path is exactly the same. A test intent can be claimed only by the person who registered
the app (you) and its test users; to anybody else it does not exist (404 unknown_payment).
7.1 Claim:
curl -sS "$GEREGE/payments/claim" -H "Authorization: Bearer $GEREGE_SESSION" \
-H 'Content-Type: application/json' -d "{\"token\": \"$PAY_TOKEN\"}" | jq
{
"id": "pi_8Hn2Wq4Zc6Xv1Bm3Np5Rt7Ly", "status": "requires_confirmation",
"amount": 3500, "currency": "MNT", "description": "Order #1001",
"livemode": false, "capture_method": "manual",
"merchant": {"slug": "my-shop-test", "name": "My Shop", "icon_url": ""},
"account_id": null, "failure_code": null,
"expires_at": "2026-10-27T03:10:00Z", "confirmed_at": null, "created_at": "2026-10-27T03:00:00Z",
"presentment": "qr", "terminal": null, "sub_merchant": null, "return_url": null
}
The receiver prints evt_… payment_intent.claimed pi_… requires_confirmation. An unconfirmed claim lapses after
90 seconds — then somebody else may take it; until then claiming again is idempotent for you.
7.2 Confirm — choose the account to pay from (Gerege Wallet):
export ACCOUNT=$(curl -sS https://geregeapp.mn/api/v1/bank/me/accounts \
-H "Authorization: Bearer $GEREGE_SESSION" | jq -r '.accounts[0].id')
curl -sS -X POST "$GEREGE/payments/$PI/confirm" -H "Authorization: Bearer $GEREGE_SESSION" \
-H 'Content-Type: application/json' -H "Idempotency-Key: confirm-$PI" \
-d "{\"account_id\": \"$ACCOUNT\"}" | jq '{id, status, account_id}'
The receiver prints evt_… payment_intent.requires_capture pi_… requires_capture. In test mode no money moves — the
last two digits of the amount decide: …01 → failed (test_declined), …02 → canceled, anything else → as here
(Test and live).
8. Capture¶
You handed over the goods (or delivered the service), so take the money — within capture_within (300 s here):
curl -sS -X POST "$GEREGE/payment-intents/$PI/capture" -u "$GEREGE_CLIENT_ID:$GEREGE_CLIENT_SECRET" \
| jq '{id, status, capture_before, confirmed_at}'
{ "id": "pi_8Hn2Wq4Zc6Xv1Bm3Np5Rt7Ly", "status": "succeeded",
"capture_before": "2026-10-27T03:06:12Z", "confirmed_at": "2026-10-27T03:01:40Z" }
The receiver prints evt_… payment_intent.succeeded pi_… succeeded. Goods not handed over? Instead of capturing,
POST /payment-intents/$PI/cancel → canceled, and the hold goes back to the person. Did nothing at all? When
capture_before passes the hold is released by itself and payment_intent.expired arrives
(failure_code: "authorization_expired").
9. Verify and reconcile¶
Do not trust a webhook blindly — before an important step read the state again with your own key:
curl -sS "$GEREGE/payment-intents/$PI" -u "$GEREGE_CLIENT_ID:$GEREGE_CLIENT_SECRET" | jq '{status, amount, reference, user_ref}'
curl -sS "$GEREGE/events?limit=20" \
-u "$GEREGE_CLIENT_ID:$GEREGE_CLIENT_SECRET" | jq -r '.data[] | "\(.id) \(.type)"'
user_ref is a random name for this one payment (u_<32 hex>) — the person's name, national id or account never
reach you (Personal data). The events API (GET /events, events:read) returns the very
webhook bodies for the key's environment, going back 30 days — reconcile from it when webhooks were missed (a server
down, a machine offline). The delivery log and resend are in the console (Developer sandbox).
10. When something goes wrong¶
| Answer | Cause | Fix |
|---|---|---|
401 {"error": "invalid_client"} |
wrong client id/secret, a revoked or expired key, or a live key before the app is approved | use the test key from §4 |
403 insufficient_scope (required_scope) |
the key lacks that scope | a new key (scopes never change) |
400 idempotency_key_required |
no Idempotency-Key header (1–128 characters) |
send your order number |
400 presentment_required |
an intent made with Basic and no presentment |
qr / redirect / terminal |
400 validation_error |
the field in details[].path (amount 100–50,000,000, description 1–80, expires_in 60–1800, capture_within 60–604,800) |
fix the field |
404 unknown_payment (claim) |
wrong token, or a test intent claimed by someone who is neither the app's owner nor a test user | check your session |
409 already_claimed (retry_after: 90) |
somebody else claimed it | wait 90 s, or the merchant unclaims |
410 expired |
expires_at has passed |
a new intent |
409 not_capturable |
not manual, or not yet requires_capture |
read the state with GET |
401 unauthorized (console) |
session expired or wrong | repeat §2 |
503 no_secret_key · pay_url_unavailable |
the installation's configuration | contact us |
Every code: Error codes.
11. Next¶
- Permanent stickers / NFC / vending: Test payment at a terminal.
- Web checkout (
redirect), many owners, settlements: Pay with Gerege App. - Which flow suits you: Choosing a flow.
- Going live: the organisation's agreement, the business profile (KYB), app review, a live key — Test and live.