Skip to content

First test payment in 15 minutes

What you will do: create a qr payment 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/miniapp 1.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):

  1. Make your organisation the active one on geregeapp.mn (the organisation switcher in the header).
  2. Browser DevTools → Application (Storage) → Cookies → https://geregeapp.mn → copy the value of session_token.
export GEREGE=https://geregeapp.mn/api/v1/miniapp
export GEREGE_SESSION='<the session_token value>'

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_taken means pick another. In a personal sandbox organisation the slug begins with sbx- (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:

cloudflared tunnel --url http://127.0.0.1:8787

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()
python3 webhook.py

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-Key with the same body answers 200 — the same intent and the same pay_url; another body is 409 idempotency_conflict (Idempotency).
  • pay_url is only ever in this answer (and its replay) — never in a GET, a list or a webhook. It carries no amount and no payee; do not log it.
  • A real till draws this pay_url on 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}'
{ "id": "pi_8Hn2Wq4Zc6Xv1Bm3Np5Rt7Ly", "status": "requires_capture", "account_id": "0b6c…" }

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