Skip to content

Presentments and claims

presentment says how the person reaches an intent. There are four; the three other than app are 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 a GET, 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, so return_hosts change only in draft or after a rejection.
  • After paying, the app opens return_url with only ?pi=<intent id> added — not the outcome. Anyone can type that URL: your backend checks status, amount and reference against its own order with GET /payment-intents/{pi}.