Skip to content

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_url is https://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 /pay page).
  • Never: do not load pay_url in an iframe or open it in a popup — one tab, ordinary navigation.

2. One-time setup

  1. 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.
  2. Return hosts (return_hosts) — the host of return_url must be here, otherwise creating the intent is 400 validation_error (details[0].path = "return_url"). From the developer console (a workspace session; the API is PATCH /api/v1/miniapp/dev/apps/{id}):
{ "return_hosts": ["shop.mn", "www.shop.mn"] }

≤ 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:

  1. Take the intent id you stored with the order (pi is only a hint; ignore it if it does not match your order's intent).
  2. GET /payment-intents/{id} (Basic) — reconcile status, amount, reference with the order.
  3. 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, 2xx within 10 s (Webhooks).
  • Rather than trusting the event's data.object, re-reading the intent with GET and 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-Key for each attempt (ord_8812-2), the same reference — every attempt for one order is found with GET /payment-intents?reference=ord_8812.
  • Both attempts were paid (paid on an old tab) — detect it by reference and refund the extra one.
  • Network errors, 429, 5xx → resend with the same Idempotency-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.