Static terminals and NFC stickers¶
On this page
What: register a terminal once for a permanent point of payment — a machine, a till, a table, a door — then print, encode, lock, test and install its QR + NFC sticker. Time: a terminal ~10 min; ordering stickers 1–2 weeks; encoding/locking ~1 min per sticker. You need: a merchant app and a live key (or the developer console), a phone with NFC, NXP TagWriter (iOS/Android).
A terminal is one permanent code. The sticker (QR and NFC) carries only a URL containing that code — never an amount. The amount comes from the open intent your backend has bound to the terminal at that moment (the "pull the basket" flow).
1. What a terminal is¶
once per basket
┌───────────────────────────┐ ┌───────────────────────────────────────────────┐
│ POST /terminals │ │ your backend: POST /payment-intents │
│ → code: 7k2m9x4q3p │ │ {presentment:"terminal", terminal_id, …} │
│ → url: https://geregeapp│ │ │ │
│ .mn/pay/t/7k2m… │ │ ▼ │
│ sticker: QR + NFC (url) │──────▶ │ terminal 7k2m9x4q3p: open intent 3 500₮ │
└───────────────────────────┘ person │ ▲ │
taps │ Gerege App: claim {"terminal":"7k2m9x4q3p"} │
└───────────────────────────────────────────────┘
- Code: 10 characters from
23456789abcdefghjkmnpqrstuvwxyz(no 0/o, 1/l/i — people read it by eye), issued by the server and never changes. It is not a secret (it is printed). - One unclaimed open intent per terminal per environment (a test basket does not block a live machine).
Creating a second gives
409 terminal_busy(payment_intent: the id of the open one). - When a person scans: first an open intent that person has already claimed, then an unclaimed one, then one whose
claim has expired. If there is nothing,
404 nothing_pending(«Энэ терминалд одоогоор төлөх зүйл алга» ("There is nothing to pay at this terminal right now")). - The app's owner and its test users get the terminal's test basket first; everybody else gets only the live one.
2. Registering a terminal¶
Writing (create, update) is live key or developer console only — a terminal and a sub-merchant are one record
across both environments, so a leaked sandbox secret must not be able to point a real machine elsewhere (test →
403 live_only; a test key can read). In the developer console: /api/v1/miniapp/dev/apps/{id}/terminals (same
fields).
curl https://geregeapp.mn/api/v1/miniapp/terminals -u "$GEREGE_LIVE_ID:$GEREGE_LIVE_SECRET" \
-H 'Content-Type: application/json' \
-d '{"label": "Машин №7", "location": "СБД, 1-р хороо, Их сургуулийн гудамж 3, 1 давхар",
"reference": "VM-0007", "sub_merchant_id": "9a7c5d1e-…"}'
{
"id": "4b8e2f0c-6a1d-4f3b-9c2e-7d5a1b0e3f9a", "object": "terminal",
"code": "7k2m9x4q3p", "url": "https://geregeapp.mn/pay/t/7k2m9x4q3p",
"label": "Машин №7", "location": "СБД, 1-р хороо, Их сургуулийн гудамж 3, 1 давхар",
"reference": "VM-0007", "sub_merchant": "9a7c5d1e-…", "status": "active",
"created_at": "2026-10-27T09:00:00Z", "updated_at": "2026-10-27T09:00:00Z"
}
| Field | Rule |
|---|---|
label |
1–60, shown to the person before Face ID — an exact identifying name such as «Машин №7» ("Machine No. 7") |
location |
≤ 120, also shown — if the sticker is stuck somewhere else, the person notices the mismatch |
reference |
your ID (the machine's serial), ^[A-Za-z0-9_.:-]{1,64}$, unique within the app: a repeated POST → that terminal, 200 (no Idempotency-Key needed); if another terminal has it, 409 reference_taken |
sub_merchant_id |
when there are several owners (sub-merchant); carried into the intent automatically |
GET /terminals ({"data": […]}), GET /terminals/{id}, PATCH /terminals/{id} (the fields above + status:
"active" | "disabled"). Bulk registration (≤ 500, CSV) as a batch — «coming soon».
3. The pull-the-basket flow¶
- The person puts their basket together in front of the machine/till (the machine's screen, the cashier).
- Your backend binds an intent to the terminal:
{"presentment": "terminal", "terminal_id": "4b8e…", …}— for a machine the vending manual capture, for a till/table usuallyautomatic. - The person taps their phone on the sticker (NFC) or scans the QR → Gerege App
POST /payments/claim {"terminal": "<code>"}→ the open intent at that moment → the terminal'slabel/location, the merchant's name, the amount → Face ID. - Your backend carries on from the webhook (
payment_intent.claimed,.succeeded/.requires_capture).
When a computer/phone without Gerege App opens that URL, the public page GET /api/v1/miniapp/pay/t/{code} shows the
terminal and its unclaimed live intent (pending, or null) — never the description, the reference or who is
paying («coming soon» — the core's /pay/t page; the API is ready).
4. Which URL to encode¶
| URL | When | Status |
|---|---|---|
The terminal's url — https://geregeapp.mn/pay/t/<code> |
the only address to encode into QR and NFC | API ✓; Gerege App iOS ✓ (QR, in-app NFC); Universal Link and background NFC reading — once the core's AASA with /pay/* is in production |
https://appclip.geregeapp.mn/pay?t=<code> |
do not encode — the appclip. host does not exist; Gerege App merely recognises this form |
App Clip «coming soon» |
- Copy the URL from the API/developer console response — never type it by hand (a one-character mistake = another terminal, or nothing).
- A locked sticker is never rewritten, so there is one form only: the terminal's
url, the console's print page and Gerege App's sticker writer all encodehttps://geregeapp.mn/pay/t/<code>; this URL keeps working after the App Clip ships. - Even once the App Clip ships, it will not activate on a tobacco (age-restricted) machine (vending §11).
NDEF size: https:// is compressed into URI prefix 0x04 — geregeapp.mn/pay/t/7k2m9x4q3p is 29 bytes + header
≈ 36 bytes. NTAG213's 144 bytes are enough.
5. Buying NFC stickers¶
| Chip | User memory | When |
|---|---|---|
| NTAG213 | 144 bytes | the default choice — enough for the URL, the cheapest |
| NTAG215 | 504 bytes | if the supplier has no 213 |
| NTAG216 | 888 bytes | not needed (for larger data) |
| NTAG 424 DNA | — | SUN, which detects copies (below) — supported «coming soon» |
When ordering, tell the supplier:
- "NXP NTAG213, ISO/IEC 14443-A, NFC Forum Type 2, NDEF formatted, unlocked" — arriving unlocked matters (you encode and lock them yourself).
- Metal surface (a vending machine's body, a metal till) → anti-metal (on-metal) sticker: with a ferrite layer behind the antenna (~1 mm thick). An ordinary sticker will not read on metal.
- Antenna ≥ 25 mm (an iPhone's antenna is at the top of the phone — a small tag is hard to find); overall sticker size 40–50 mm.
- Outdoors/humid: a PET/epoxy coating; adhesive that withstands frost and sun.
- The supplier may encode and lock the URLs from your CSV — even then, take delivery of every sticker and read it back (§9).
NTAG 424 DNA (SUN) — «coming soon» (phase 4): on every tap it appends an encrypted UID + counter + CMAC to the URL
(?picc=…&cmac=…), and the server checks it with each tag's AES key → both a copied sticker and a remote claim made
with the printed code are detected. There is no need to buy them now; you switch when tightening a machine's
protection.
6. The printed layer¶
One sticker = NFC inlay + printed face:
- Material: vinyl (or PET) + UV laminate (colours do not fade under light or abrasion); weatherproof if outdoors; tamper-evident material (leaves "VOID") — peeling it off to stick it elsewhere shows.
- Size: 40–50 mm (square or round).
- Face: the Gerege App logo · the NFC mark (shows where to hold the phone) · the QR (for a person whose phone
has no NFC, or has it off — the same
url) · the human-readable code (7k2m9x4q3p) · «Gerege App-аар төлөх» ("Pay with Gerege App"). - QR: ≥ 25 mm, error correction M, quiet zone ≥ 4 modules, dark on white — check that the laminate's glare does not spoil scanning.
- NFC mark and QR on the same sticker: the person chooses for themselves whether to tap or scan.
A print sheet with every terminal's QR (PDF: A4 24 stickers / 50×50 mm / tent) — «coming soon» (dashboard and
GET /terminals/…/print-sheet). Until then, produce the QR from url with your own print template.
7. Encoding¶
NXP TagWriter (iOS, Android — free):
- "Write tags" → "New dataset" → Link / URL.
- URL: paste the terminal's
url(https://geregeapp.mn/pay/t/7k2m9x4q3p). The app turns the prefix intohttps://(0x04) itself. - A single URI record. Add no other record (text, Android AAR, vCard) — an Android AAR is not needed: Android opens the URL.
- Write → hold the phone to the sticker → success.
- Read it back (TagWriter "Read", or the iPhone's background reading): exactly that URL, one record.
NFC Tools (iOS/Android) works the same way: "Write" → "Add a record" → "URL / URI".
Gerege App's sticker writer (iOS): an organisation admin opens Тохиргоо → Хэтэвч → «Худалдагч ба байгууллага»
→ NFC sticker (Settings → Wallet → "Merchants and organisations" → NFC sticker), picks the terminal and holds the phone
to the sticker: it writes the url and reads it back to check; locking is a separate, confirmed step. That page
switches the session to that organisation for the while and switches it back on leaving.
8. Locking¶
Lock after encoding and checking — anybody can overwrite an unlocked tag with their phone in 2 seconds. But do
not lock until the core's AASA (/pay/*) is in production and you have confirmed that holding a phone to the sticker
opens Gerege App — until then, test with unlocked stickers.
| Method | What happens | When |
|---|---|---|
| Permanent read-only (NTAG21x lock bits + the CC's write access) | irreversible, never rewritten again | recommended — the sticker belongs to one terminal for life |
Password protection (PWD_AUTH: 32-bit PWD, AUTH0, PROT) |
writing needs the password; the URL can be changed later | if you will reuse the sticker for another terminal later |
- The read-only / lock option in TagWriter's write settings, or NFC Tools → "Other" → "Lock tag". Read the URL again before locking — lock a wrong URL and that sticker is scrap.
- The
PWD_AUTHpassword travels over the air in the clear, 32 bits — it can be sniffed at close range. The protection is no more than "a random person does not overwrite it"; a permanent lock is more reliable. - Even locked, it can be copied (the URL is public) — the copy names only your terminal; detecting copies is NTAG 424 DNA (§5).
9. Testing¶
Start with an unlocked test sticker: write → read → rewrite, fixing mistakes without scrapping anything. Then test the real (anti-metal) sticker on the machine itself (metal, behind the door) — a tag that reads on a desk may not read on metal.
| Check | How |
|---|---|
| URL correct, single record | TagWriter "Read" / NFC Tools "Read" |
| iPhone background reading | hold an iPhone with its screen on to the sticker → notification → Gerege App (once the core's AASA with /pay/* is in production); «NFC уншуулах» ("Scan NFC") inside Gerege App works now |
| Android | the URL opens (Gerege App Android is paused — the web page) |
| QR | Gerege App's QR scanner, the phone's camera |
| Locked | a rewrite attempt is refused |
| With a basket | bind a test intent to the terminal with a test key, then use a test user's phone |
Without a real sticker — emulate an NFC tag on Android (HCE). An Android phone emulates an NFC Forum Type 4 Tag and serves an NDEF URI; the iPhone's Core NFC reads Type 4 NDEF. This lets you test the flow before ordering stickers, and check many terminals by swapping the URL in code. The reader sends these APDUs:
| Reader sends | Meaning | Your service answers |
|---|---|---|
00 A4 04 00 07 D2760000850101 00 |
select the NDEF Tag Application | 90 00 |
00 A4 00 0C 02 E103 |
select the Capability Container file | 90 00 |
00 B0 00 00 0F |
read the CC | 000F 20 003B 0034 04 06 E104 00FF 00 FF + 90 00 |
00 A4 00 0C 02 E104 |
select the NDEF file | 90 00 |
00 B0 <offset> <length> |
read NLEN (2 bytes) and the NDEF message | the bytes + 90 00 |
// AndroidManifest.xml: <service android:name=".StickerHce" android:exported="true"
// android:permission="android.permission.BIND_NFC_SERVICE"> + intent-filter
// android.nfc.cardemulation.action.HOST_APDU_SERVICE + meta-data res/xml/apdu_service.xml.
class StickerHce : HostApduService() {
private val ndef = NdefMessage(NdefRecord.createUri("https://geregeapp.mn/pay/t/7k2m9x4q3p")).toByteArray()
private val ndefFile = byteArrayOf((ndef.size shr 8).toByte(), ndef.size.toByte()) + ndef // NLEN + message
private val ccFile = hex("000F20003B00340406E10400FF00FF")
private var selected: ByteArray? = null
override fun processCommandApdu(apdu: ByteArray, extras: Bundle?): ByteArray {
val h = apdu.joinToString("") { "%02X".format(it) }
return when {
h.startsWith("00A4040007D2760000850101") -> OK
h.startsWith("00A4000C02E103") -> { selected = ccFile; OK }
h.startsWith("00A4000C02E104") -> { selected = ndefFile; OK }
h.startsWith("00B0") && apdu.size >= 5 -> {
val file = selected ?: return NOT_FOUND
val offset = ((apdu[2].toInt() and 0xFF) shl 8) or (apdu[3].toInt() and 0xFF)
val length = (apdu[4].toInt() and 0xFF).let { if (it == 0) 256 else it }
if (offset > file.size) NOT_FOUND
else file.copyOfRange(offset, minOf(offset + length, file.size)) + OK
}
else -> NOT_FOUND
}
}
override fun onDeactivated(reason: Int) { selected = null }
private companion object {
val OK = hex("9000")
val NOT_FOUND = hex("6A82")
fun hex(s: String) = ByteArray(s.length / 2) { s.substring(it * 2, it * 2 + 2).toInt(16).toByte() }
}
}
<!-- res/xml/apdu_service.xml -->
<host-apdu-service xmlns:android="http://schemas.android.com/apk/res/android"
android:description="@string/app_name" android:requireDeviceUnlock="false">
<aid-group android:category="other" android:description="@string/app_name">
<aid-filter android:name="D2760000850101" />
</aid-group>
</host-apdu-service>
On an iPhone, check first with an NDEF reader app (TagWriter, NFC Tools — foreground); background reading of HCE may behave differently depending on the device. This is a development tool — never a replacement for a sticker at a real point of payment.
10. Installation and tampering risks¶
Installation: at the height where a person holds their phone (1–1.4 m), near the screen/buttons, one sticker per point; anti-metal on metal; no other NFC tag/card reader nearby.
| Risk | Protection |
|---|---|
| A fake sticker over yours (pointing to another merchant's terminal) | before paying, Gerege App shows the registered merchant's name and the terminal's label/location — write them accurately and clearly; tamper-evident material; staff check regularly; a note telling people «нэр, байршлыг шалгаарай» ("check the name and location") |
| Fake QR → phishing website | people pay only inside Gerege App; a Gerege web page never asks for a PIN or password; the URL is https://geregeapp.mn/… |
| Overwriting an unlocked NFC tag | lock it (§8) |
| A remote claim made with the printed code (the code is public) | an unconfirmed claim expires in 90 s; ≤ 3 per person; POST …/unclaim; when the machine's screen shows «Утсаараа баталгаажуул» ("Confirm on your phone"), the machine knows whether a person is present; NTAG 424 DNA SUN «coming soon» |
| Copying the tag | the copy names only your terminal; detecting it — SUN «coming soon» |
| The sticker moved to another machine | the location will not match; when moving a machine, PATCH the terminal and replace the sticker |
Terminal statistics (scans ↔ payments — to detect fake stickers) — «coming soon» (dashboard).
11. Disabling and transferring a terminal¶
- Disable —
PATCH /terminals/{id} {"status": "disabled"}: claim →404 unknown_terminal, the public page 404s, an open basket cannot be paid even with its token (409 terminal_disabled), a new intent →409 terminal_disabled. The sticker stays — set it back to"active"and it works again. - Machine sold —
PATCH /terminals/{id} {"sub_merchant_id": "<new owner>"}(that sub-merchant's payout must be approved). Code and sticker do not change; money already captured for the previous owner stays theirs. - Machine moved — update
location.
12. Implementation status¶
| Part | Status |
|---|---|
Terminal (register, update, disable), terminal intent, claim, public GET /pay/t/{code} |
✓ |
Gerege App iOS: in-app NFC reader, QR, /pay/t/<code> Universal Link → claim → payment sheet |
✓ (Universal Link and background NFC reading — once the core's AASA with /pay/* is in production) |
| App Clip | «coming soon» |
| Print sheet (PDF), batch registration, terminal statistics | «coming soon» |
| Gerege App NFC sticker writer (organisation admin) | ✓ (iOS) |
| NTAG 424 DNA (SUN) | «coming soon» (phase 4) |