Test payment at a terminal (sticker, NFC, machine)¶
What you will do: register a permanent terminal (QR sticker / NFC tag / vending machine), attach a test basket to it, «tap» it to place a hold, then capture or cancel. Time: about 10 minutes once you have done §1–§5 of the Quickstart. No money moves.
How it differs from qr: the QR or sticker names only the terminal, never an amount. The amount comes from the
one open intent attached to the terminal at that moment. Concepts: Merchants, sub-merchants,
terminals.
1. Before you start¶
From the Quickstart you have: GEREGE, GEREGE_SESSION, APP_ID, GEREGE_CLIENT_ID /
GEREGE_CLIENT_SECRET (a test key with payments:*), and webhook.py plus the tunnel running.
2. Register a terminal (from the console)¶
Terminals and sub-merchants are one registry for both environments, so writing to them takes a live key or the
console; with a test key POST /terminals is 403 live_only (a leaked sandbox secret must not change a real machine).
In the sandbox, register through the console API:
curl -sS "$GEREGE/dev/apps/$APP_ID/terminals" \
-H "Authorization: Bearer $GEREGE_SESSION" -H 'Content-Type: application/json' \
-d '{"label": "Test machine", "location": "Office, 3rd floor", "reference": "test-vm-1"}' | tee terminal.json | jq
export TERMINAL_ID=$(jq -r .id terminal.json)
export TERMINAL_CODE=$(jq -r .code terminal.json)
{
"id": "4b8e2f0a-7c1d-4e3b-9a6f-2d5c8e1b7a90",
"object": "terminal",
"code": "7k2m9x4q3p",
"url": "https://geregeapp.mn/pay/t/7k2m9x4q3p",
"label": "Test machine",
"location": "Office, 3rd floor",
"reference": "test-vm-1",
"sub_merchant": null,
"status": "active",
"created_at": "2026-10-27T03:00:00Z",
"updated_at": "2026-10-27T03:00:00Z"
}
reference— your machine's number, unique within the app:POSTagain with the samereference→ that terminal,200.codeis the server's and never changes.urlbecomes the sticker's QR and the NFC tag's NDEF URI record.labelandlocationare shown on the person's payment sheet before Face ID (from the registry — not from what is printed on the sticker).
3. The sticker¶
For a test, draw the QR yourself: qrencode -o sticker.png "$(jq -r .url terminal.json)" (or any QR generator). A
printable sheet from the server (PDF: QR + name + code) is «удахгүй», and so is the guide to writing NFC tags and
choosing stickers (the terminals page under Guides).
4. Attach a basket¶
For each basket your server attaches an intent to the terminal — manual capture, a short capture_within:
curl -sS "$GEREGE/payment-intents" -u "$GEREGE_CLIENT_ID:$GEREGE_CLIENT_SECRET" \
-H 'Content-Type: application/json' -H 'Idempotency-Key: basket-2001' \
-d "{\"amount\": 2500, \"description\": \"Order #2001\", \"reference\": \"basket-2001\",
\"presentment\": \"terminal\", \"terminal_id\": \"$TERMINAL_ID\", \"expires_in\": 120,
\"capture_method\": \"manual\", \"capture_within\": 120}" | tee basket.json | jq '{id, status, presentment, terminal, capture_within}'
export PI=$(jq -r .id basket.json)
{ "id": "pi_3Fk9Lm2Qw8Er5Ty7Ui1Op4As", "status": "requires_confirmation", "presentment": "terminal",
"terminal": "4b8e2f0a-7c1d-4e3b-9a6f-2d5c8e1b7a90", "capture_within": 120 }
A terminal has one open, unclaimed intent per environment. A second basket:
curl -sS "$GEREGE/payment-intents" -u "$GEREGE_CLIENT_ID:$GEREGE_CLIENT_SECRET" \
-H 'Content-Type: application/json' -H 'Idempotency-Key: basket-2002' \
-d "{\"amount\": 1800, \"description\": \"Order #2002\", \"presentment\": \"terminal\",
\"terminal_id\": \"$TERMINAL_ID\", \"capture_method\": \"manual\"}" | jq
Two baskets on one sticker would leave the person choosing which machine they stand at — so cancel the old one first
(POST /payment-intents/{id}/cancel) and create again. A test basket never blocks a live one (separate environments).
5. Tap (claim) and pay¶
Tapping with a phone
Gerege App (iOS)'s in-app QR button and «NFC уншуулах» (tap NFC) claim at a terminal — in builds after TestFlight 121 (a test basket is marked «ТЕСТ»). Scanning with the camera / background NFC (Universal Link) and the App Clip are «удахгүй». Without that build, make the app's calls with your own session. Claiming at a terminal offers the app's owner and test users its test basket first; everybody else gets only the live one.
curl -sS "$GEREGE/payments/claim" -H "Authorization: Bearer $GEREGE_SESSION" \
-H 'Content-Type: application/json' -d "{\"terminal\": \"$TERMINAL_CODE\"}" \
| jq '{id, status, merchant, terminal, sub_merchant}'
{ "id": "pi_3Fk9Lm2Qw8Er5Ty7Ui1Op4As", "status": "requires_confirmation",
"merchant": {"slug": "my-shop-test", "name": "My Shop", "icon_url": ""},
"terminal": {"label": "Test machine", "location": "Office, 3rd floor"}, "sub_merchant": null }
Webhook: payment_intent.claimed. The machine shows «Confirm on your phone». Pay:
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 '{status}'
{"status": "requires_capture"} and the webhook payment_intent.requires_capture — the machine's signal to dispense.
6. Dispensed: capture; not dispensed: cancel¶
# dispensed:
curl -sS -X POST "$GEREGE/payment-intents/$PI/capture" -u "$GEREGE_CLIENT_ID:$GEREGE_CLIENT_SECRET" | jq '{status}'
# or not dispensed:
curl -sS -X POST "$GEREGE/payment-intents/$PI/cancel" -u "$GEREGE_CLIENT_ID:$GEREGE_CLIENT_SECRET" | jq '{status}'
| What the machine did | Your call | State · webhook |
|---|---|---|
| Dispensed everything | capture |
succeeded · payment_intent.succeeded |
| Dispensed nothing | cancel |
canceled · payment_intent.canceled (the hold goes back — not a refund) |
| Dispensed part | full capture + POST …/refunds {"amount": <price of what was not dispensed>} |
succeeded → partially_refunded (partial capture by amount «удахгүй») |
| No answer (offline) | — | when capture_within passes: expired · payment_intent.expired, failure_code: "authorization_expired", the hold is released by itself |
If the person taps and walks away, their unconfirmed claim lapses after 90 s and the next person gets it; or your
server calls POST /payment-intents/{id}/unclaim. One person holds at most 3 unconfirmed claims at once
(429 too_many_open_claims).
7. The public view¶
What a person who opens the sticker's address on a computer is shown (no session, no personal data):
{ "object": "terminal", "merchant": {"name": "My Shop", "icon_url": ""},
"terminal": {"code": "7k2m9x4q3p", "label": "Test machine", "location": "Office, 3rd floor"},
"pending": null }
pending is the open live basket only (a test basket is never public). The description, the reference and who
claimed it are never here.
8. Next¶
- Machines with many owners (separate settlement for each): Merchants, sub-merchants, terminals.
- The full API with Node and Go examples: Pay with Gerege App.
- How to choose the hold's window: Payment lifecycle.