Skip to content

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

  1. The person puts their basket together in front of the machine/till (the machine's screen, the cashier).
  2. Your backend binds an intent to the terminal: {"presentment": "terminal", "terminal_id": "4b8e…", …} — for a machine the vending manual capture, for a till/table usually automatic.
  3. 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's label/location, the merchant's name, the amount → Face ID.
  4. 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 encode https://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):

  1. "Write tags" → "New dataset" → Link / URL.
  2. URL: paste the terminal's url (https://geregeapp.mn/pay/t/7k2m9x4q3p). The app turns the prefix into https:// (0x04) itself.
  3. A single URI record. Add no other record (text, Android AAR, vCard) — an Android AAR is not needed: Android opens the URL.
  4. Write → hold the phone to the sticker → success.
  5. 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_AUTH password 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)