Presentments and claims¶
presentmentsays how the person reaches an intent. There are four; the three other thanappare claimless — born without a person, and the person claims one in Gerege App when they scan it. Which suits you: Choosing a flow.
1. Four flows¶
presentment |
Who creates it | How the person reaches it | After paying |
|---|---|---|---|
app |
the mini app's backend, the person's token (Bearer) |
your app inside Gerege App, bridge requestPayment |
the bridge answers; the backend verifies |
redirect |
the merchant's backend (Basic) + return_url |
pay_url: on a phone a Universal Link → Gerege App; on a computer a QR page («удахгүй») |
the app opens return_url?pi=<id> |
qr |
the merchant's backend (Basic) |
the QR of pay_url on a screen → Gerege App's QR reader |
the screen updates from the webhook |
terminal |
the merchant's backend (Basic) + terminal_id |
the terminal's permanent url (sticker QR, NFC NDEF URI) |
the machine dispenses on the webhook |
The person's side: Gerege App (iOS)'s in-app QR button and NFC reader → claim → payment sheet — in builds after
TestFlight 121. Scanning with the camera (the /pay Universal Link), the desktop /pay page and the App Clip are
«удахгүй». The API is complete.
2. pay_url¶
https://geregeapp.mn/pay/<token> ← qr, redirect (and terminal intents too)
https://geregeapp.mn/pay/t/<code> ← a terminal's permanent url (no amount)
- The token is the claim's bearer secret. 24 characters (144 random bits); the server keeps only its SHA-256.
- Only in the create answer (and in a replay with the same
Idempotency-Key— the same link). Never in aGET, a list or a webhook. Keep it out of logs and analytics. - No amount and no payee in the URL. The server reads the amount from the intent; editing a URL cannot change it.
- It dies with the intent: once the intent is over (
expired,canceled,succeeded) the token opens nothing. - The public view
GET /api/v1/miniapp/pay/{token}(no session): the app's name and icon, the terminal, the amount, the state,claimed— no description, reference or who claimed it. The desktop page polls it.
3. Claim¶
Taking a claimless intent as one's own: POST /api/v1/miniapp/payments/claim {"token"} or {"terminal"} (made by
Gerege App with the person's session).
your backend ─create (Basic)─▶ intent (nobody's, user_ref: null)
│
person scans ─▶ Gerege App ─claim─▶ checks: token / terminal → may the app take payments →
age floor, eID verification (min_age, requires_verified)
│ now the person's: user_ref = u_<new random>, claimed_at
├────────── webhook payment_intent.claimed ──▶ your backend («confirm on your phone»)
▼
payment sheet (registry names) → Face ID → confirm
- Idempotent for the same person: claiming again → the same intent.
- At a terminal: first an open intent already this person's, then an unclaimed one, then one whose claim has lapsed.
- A test intent can be claimed only by the person who registered the app and its test users; to anybody else it does
not exist (
404).
4. How claims are protected¶
A terminal's code is printed and the /pay/t page is public — anyone can claim from afar. So:
| Rule | Against |
|---|---|
An unconfirmed claim lapses after 90 seconds — the next person takes it (a new user_ref, another payment_intent.claimed); the previous person no longer sees the intent |
claiming remotely to block a machine |
Somebody else's live claim → 409 already_claimed + retry_after: 90 |
two people on one basket |
One person holds at most 3 unconfirmed claims → 429 too_many_open_claims |
blocking many machines at once |
The merchant POST /payment-intents/{id}/unclaim releases an unconfirmed claim (the person walked away, the wrong person scanned) |
a stuck screen |
| The terminal's name and location come from the registry, before Face ID | swapped stickers |
A disabled terminal's basket is not paid even by its token (409 terminal_disabled) |
a machine switched off |
Later: NTAG 424 DNA tags with SUN (a freshly signed URL on every tap — proof the phone is at the machine) — «удахгүй».
Return a machine's screen from «Confirm on your phone» after 90 s.
5. A redirect's return_url¶
- Its host is one registered in the app's
return_hosts(≤ 5, exact hosts — no scheme, port, path or wildcard), over https. Review must have seen where people are sent back, soreturn_hostschange only in draft or after a rejection. - After paying, the app opens
return_urlwith only?pi=<intent id>added — not the outcome. Anyone can type that URL: your backend checksstatus,amountandreferenceagainst its own order withGET /payment-intents/{pi}.