Web shop — redirect checkout¶
On this page
What: wire up your web shop's «Gerege App-аар төлөх» ("Pay with Gerege App") button and fulfil an order only
after your server has checked it. Time: ~1 hour (with a test key). You need: a merchant app
(kind: "merchant") and its test credential (registration), a
backend with HTTPS, the host of your return page.
The person presses «Gerege App-аар төлөх» on your site → your backend creates a redirect intent → the browser
goes to pay_url → the person pays with Gerege App → the app opens your return_url with ?pi=<intent id> → your
backend re-reads the intent from Gerege and fulfils the order.
1. Flow¶
Browser Your backend Gerege Person's phone (Gerege App)
│ «Gerege App-аар төлөх» │ │ │
│────────────────────────▶│ POST /payment-intents │ │
│ │ {presentment:"redirect", …} │ │
│ │─────────────────────────────────▶│ │
│ │◀──── 201 {id, pay_url, …} ───────│ │
│◀── 303 Location: pay_url│ │ │
│──── GET pay_url ────────────────────────────────────────────▶ on a phone: Universal Link → app ──▶│
│ │ │ on a computer: QR page → phone ──▶│
│ │ │◀── claim → Face ID → paid ────────│
│ │◀── webhook payment_intent.succeeded │
│◀──────────── the app opens return_url?pi=pi_… ─────────────────────────────────────────────────│
│── GET return_url?pi= ──▶│ GET /payment-intents/{id} │ │
│ │─────────────────────────────────▶│ status, amount, reference │
│◀── «Төлөгдлөө» ─────────│ (fulfil on a match) │ │
- A browser on a phone:
pay_urlishttps://geregeapp.mn/pay/<token>— a Universal Link, so Gerege App opens straight away (once the core's AASA with/pay/*is in production — §9; until then a Safari page may open). - A computer: that page shows a QR; the person scans it with Gerege App on their phone. The page checks its status
with
GET /api/v1/miniapp/pay/{token}(no session, public) and changes from «Утсаараа баталгаажуулна уу» ("Confirm on your phone") to «Төлөгдлөө» ("Paid") («coming soon» — the core's/paypage). - Never: do not load
pay_urlin an iframe or open it in a popup — one tab, ordinary navigation.
2. One-time setup¶
- A merchant app and a test key (preset «Checkout сервер», "Checkout server") — Pay with Gerege App §2.
Keep the key in the server's environment variables (
GEREGE_CLIENT_ID,GEREGE_CLIENT_SECRET) — never in the repo, the browser or a mobile app. - Return hosts (
return_hosts) — the host ofreturn_urlmust be here, otherwise creating the intent is400 validation_error(details[0].path = "return_url"). From the developer console (a workspace session; the API isPATCH /api/v1/miniapp/dev/apps/{id}):
≤ 5, exact hosts (no scheme, port, path or *.; www.shop.mn is a separate host). They can be changed only
while the app is a draft or rejected (otherwise 409 needs_review) — review must have seen where people are sent
after paying. Proving ownership of a host with /.well-known/gerege-merchant.txt — «coming soon».
3. Webhook URL — POST …/dev/apps/{id}/webhook {"url": "https://shop.mn/webhooks/gerege"} → whsec_… once
(Webhooks). Submitting to review requires an active live endpoint — this one (№1) or
any added through the endpoints API.
4. return_url is https:// (http://localhost is allowed on installations other than production), ≤ 2048
characters, no userinfo.
3. An intent per order¶
Compute the amount from your own catalogue — never from a number the browser sent. Idempotency-Key is required:
the order number (for each attempt order-8812-1, order-8812-2) — if the network drops and you resend, the same
intent and the same pay_url come back (200); no second intent is created.
curl https://geregeapp.mn/api/v1/miniapp/payment-intents \
-u "$GEREGE_CLIENT_ID:$GEREGE_CLIENT_SECRET" \
-H 'Content-Type: application/json' -H 'Idempotency-Key: ord_8812-1' \
-d '{"amount": 45000, "description": "Захиалга ord_8812", "reference": "ord_8812", "expires_in": 900,
"presentment": "redirect", "return_url": "https://shop.mn/orders/ord_8812/done"}'
{
"id": "pi_3kQ9vR2mXn7LpT4wYb8cZd1e", "object": "payment_intent", "status": "requires_confirmation",
"amount": 45000, "currency": "MNT", "description": "Захиалга ord_8812", "reference": "ord_8812",
"user_ref": null, "livemode": false, "capture_method": "automatic", "refunded_amount": 0,
"fee": null, "net": null, "failure_code": null, "expires_at": "2026-10-27T09:15:00Z", "capture_before": null,
"confirmed_at": null, "created_at": "2026-10-27T09:00:00Z", "metadata": {},
"presentment": "redirect", "terminal": null, "sub_merchant": null,
"return_url": "https://shop.mn/orders/ord_8812/done", "claimed_at": null, "min_age": null, "capture_within": null,
"pay_url": "https://geregeapp.mn/pay/Qk3vN8pLx2Rt7YwZb4Mc9Hs1"
}
| Field | Rule |
|---|---|
amount |
whole ₮, 100 – 50 000 000 (not above the app's operator-set max_amount) |
description |
1–80 characters, shown in the person's wallet statement (Gerege App's payment screen does not show a merchant's description) — no personal data |
reference |
≤ 64, your order number; GET /payment-intents?reference= searches by it |
expires_in |
60–1800 s, default 600. If the person has not paid within it, expired |
return_url |
required for redirect; host in return_hosts |
pay_url is the claim's bearer secret: it is only in this response (and in a replay with the same key) — never in
GET, lists or webhooks. Do not log it, do not show it to anybody but the person.
4. Sending the person to pay_url¶
The backend answers with 303 See Other + Location: <pay_url> (or the frontend gets pay_url from the backend and
calls window.location.assign). Guard against a double press once the button is pressed — a duplicate request with
the same Idempotency-Key is the same intent and so safe, but disabling the button is clearer to the person.
Brand rules and assets for the «Gerege App-аар төлөх» button (logo, colour, size) — «coming soon». Until then: the button text is exactly «Gerege App-аар төлөх» ("Pay with Gerege App"), the same size as the other payment methods.
5. Verifying on the server when they come back¶
After paying, Gerege App opens return_url with a pi parameter added (the query already in return_url is kept:
…/done?o=7 → …/done?o=7&pi=pi_…). Anybody can type this URL by hand — so it decides nothing:
- Take the intent id you stored with the order (
piis only a hint; ignore it if it does not match your order's intent). GET /payment-intents/{id}(Basic) — reconcilestatus,amount,referencewith the order.status→ screen:
status |
To the person | Your backend |
|---|---|---|
succeeded |
«Төлөгдлөө» ("Paid") | fulfil the order once (it may also come from the webhook — an active lock or a status check) |
requires_confirmation, processing |
«Gerege App дээр баталгаажуулна уу…» ("Confirm in Gerege App…") (refresh every 3 s) | wait |
failed |
«Амжилтгүй — апп дотроо дахин оролдоно уу» ("Failed — please try again in the app") | the person may pay again until it expires |
expired, canceled |
«Төлбөр хийгдээгүй» ("Not paid") + «Дахин төлөх» ("Pay again") | a new attempt = a new intent (ord_8812-2) |
6. Webhook¶
The person may pay and still never come back to your page (closed the app, lost the network). So the main path for
fulfilment is the webhook payment_intent.succeeded; return_url is the path that shows the person the outcome:
- Verify the signature over the raw body, deduplicate by
id,2xxwithin 10 s (Webhooks). - Rather than trusting the event's
data.object, re-reading the intent withGETand deciding on that is the most reliable. payment_intent.expired— mark the order «not paid» and, if you wish, offer it again.payment_intent.failed— not final: the person may try again until it expires.
7. Cancel, expiry, retry¶
- The person cancelled the order (on your site) →
POST /payment-intents/{id}/cancel. A paid intent cannot be cancelled (409 not_cancelable) — then it is a refund:POST /payment-intents/{id}/refunds+Idempotency-Key. - Expired → a new
Idempotency-Keyfor each attempt (ord_8812-2), the samereference— every attempt for one order is found withGET /payment-intents?reference=ord_8812. - Both attempts were paid (paid on an old tab) — detect it by
referenceand refund the extra one. - Network errors,
429,5xx→ resend with the sameIdempotency-Key(errors).
8. Full example¶
examples/node/src/checkout.ts and examples/go/cmd/checkout/main.go (in this repo) — catalogue, orders, redirect,
the check on return_url, webhook. They run with a test key:
export GEREGE_CLIENT_ID=gk_test_… GEREGE_CLIENT_SECRET=gks_test_… GEREGE_WEBHOOK_SECRET=whsec_…
export PUBLIC_URL=https://shop.example.mn # its host is in return_hosts
cd examples/go && go run ./cmd/checkout # or: cd examples/node && npm run build && npm run checkout
The core of it (Node, @gerege/miniapp-node):
const pi = await gerege.paymentIntents.create(
{ presentment: "redirect", amount: order.amount, description: `Захиалга ${order.id}`, reference: order.id,
expires_in: 900, return_url: `${publicUrl}/orders/${order.id}/done` },
{ idempotencyKey: `${order.id}-${order.attempt}` },
);
res.writeHead(303, { Location: pi.pay_url }).end();
// On return_url: trust Gerege's answer, not the URL.
const cur = await gerege.paymentIntents.retrieve(order.intent);
if (cur.status === "succeeded" && cur.amount === order.amount && cur.reference === order.id) fulfil(order);
Go (sdk/go, package miniapp):
pi, err := c.PaymentIntents.Create(ctx, key, miniapp.CreatePaymentIntentParams{
Amount: o.Amount, Description: "Захиалга " + o.ID, Reference: o.ID, ExpiresIn: 900,
Presentment: miniapp.PresentmentRedirect, ReturnURL: public + "/orders/" + o.ID + "/done",
})
if err == nil {
http.Redirect(w, r, pi.PayURL, http.StatusSeeOther)
}
9. Implementation status¶
| Part | Status |
|---|---|
redirect intents, return_hosts, return_url?pi=, webhook, idempotency |
✓ (miniapp 1.7.0) |
Gerege App iOS: /pay/<token> → claim → payment screen → after paying, opening return_url?pi= through the system |
✓ (Universal Link — once the core's AASA with /pay/* is in production) |
The https://geregeapp.mn/pay/<token> QR page on a computer (the core's shell) |
«coming soon» (the API is ready: GET /api/v1/miniapp/pay/{token}) |
Host verification with /.well-known/gerege-merchant.txt; brand assets for the button |
«coming soon» |
| Android Gerege App | paused — on Android pay_url opens its web page |
Checklist: amount from the server · Idempotency-Key = order + attempt · no pay_url in logs · return_url checks
with GET · webhook signature + dedupe · fulfilment once · a pay-again path on expired/canceled.