Skip to content

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: POST again with the same reference → that terminal, 200.
  • code is the server's and never changes. url becomes the sticker's QR and the NFC tag's NDEF URI record.
  • label and location are 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
{ "error": "…", "code": "terminal_busy", "payment_intent": "pi_3Fk9Lm2Qw8Er5Ty7Ui1Op4As" }

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

curl -sS "$GEREGE/pay/t/$TERMINAL_CODE" | jq
{ "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