Skip to content

Vending machine — a complete example

On this page

What: take payment at a vending machine with Gerege App and collect the money only after the goods have dropped — release it back to the person if nothing dropped, refund the part that did not drop if only some did. Every case: the network drops, the machine jams, an answer arrives late. Time: 1–2 days (including the machine firmware side). You need: a merchant app and a test credential (registration), a terminal and a sticker for every machine (terminals and stickers), a machine ↔ your backend connection (HTTPS or MQTT).

Working code in full: examples/go/cmd/vending/main.go, examples/node/src/vending.ts (in this repo, §12).

1. Architecture

┌──────────── Machine ────────────┐        ┌──────── Your backend ─────────┐        ┌──────── Gerege ────────┐
│ controller · screen · motor     │ HTTPS/ │ basket, price, log            │ HTTPS  │ /api/v1/miniapp        │
│ NFC/QR sticker (terminal code)  │ MQTT ─▶│ client_id + client_secret     │───────▶│ payment-intents        │
│ eID age check (tobacco 21+)     │◀────── │ webhook receiver              │◀────── │ webhook (HMAC)         │
│ Gerege secret: NONE             │        │ reconcile loop (30 s)         │        │                        │
└─────────────────────────────────┘        └───────────────────────────────┘        └────────────────────────┘
                 ▲ sticker: https://geregeapp.mn/pay/t/<code>                                   │
                 └──────────── person's phone (Gerege App): claim → Face ID → hold ─────────────┘
  • The machine holds no secret. A machine can be taken apart and its flash memory read — a Gerege key there would be a way to redirect every machine's money elsewhere. The machine talks only to your backend, with its own (per-machine) credential: mTLS, or MQTT with a per-device key.
  • The sticker only names the terminal — the amount comes from the server, from the open intent bound to the terminal at that moment.
  • The money goes by manual capture: as soon as the person confirms, a hold is placed on their account, and your backend does the capture only after the goods have dropped.

2. One-time setup

  1. A merchant app (kind: "merchant"), test and live keys (preset «Вендинг / kiosk backend», "Vending / kiosk backend"), a webhook URL — Pay with Gerege App §2. The webhook receives payment_intent.* and refund.* (up to 5 endpoints per environment, each with its own event filter and secret; the original webhook_url serves both test and live — tell them apart by livemode).
  2. With several owners (each machine has a different owner), make each owner a sub-merchant, and the owner confirms their payout account in Gerege App themselves — sub-merchants. Until it is confirmed, a live intent gets 409 payouts_not_configured.
  3. Each machine = a terminal, reference = the machine's serial (never registered twice; a repeated POST → that terminal, 200):
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": "9a7c…"}'

Writing terminals takes a live key or the developer console only (test → 403 live_only; a test key can read). The person sees label and location before Face ID — write them so they identify exactly that machine. 4. Sticker — print the terminal's url as a QR code, write it to an NFC tag and lock it: terminals and stickers.

3. Per basket — sequence diagram

Person       Machine               Your backend                       Gerege                     Gerege App
 │ picks goods   │                       │                                 │                            │
 │──────────────▶│ (age-restricted: machine eID check)                     │                            │
 │               │── POST /machine/baskets ▶│                              │                            │
 │               │                       │── POST /payment-intents ───────▶│ terminal, manual,          │
 │               │                       │   Idempotency-Key: 77120        │ capture_within 180         │
 │               │◀── «3 500₮ · утсаа хүргэнэ үү» ◀── 201 pi_… ────────────│                            │
 │ holds phone to sticker / scans QR ─────────────────────────────────────────────────────────────────▶│
 │               │                       │                                 │◀──── claim (code) ─────────│
 │               │                       │◀── payment_intent.claimed ──────│                            │
 │               │◀── «Утсаараа баталгаажуулна уу»                         │                            │
 │ Face ID ───────────────────────────────────────────────────────────────────────────────────────────▶│
 │               │                       │                                 │◀── confirm → HOLD ─────────│
 │               │                       │◀── payment_intent.requires_capture                           │
 │               │                       │── GET /payment-intents/{id} ───▶│ still requires_capture?    │
 │               │◀──── dispense ────────│                                 │                            │
 │◀── goods ─────│── result {dropped} ──▶│                                 │                            │
 │               │                       │── capture | cancel | capture + refund ▶│                     │
 │               │                       │◀── payment_intent.succeeded / .canceled · refund.succeeded   │
 │               │◀── «Баярлалаа» ───────│                                 │                            │

Screen texts: «3 500₮ · утсаа хүргэнэ үү» ("3 500₮ · hold your phone to it"), «Утсаараа баталгаажуулна уу» ("Confirm on your phone"), «Баярлалаа» ("Thank you").

4. Creating the intent

POST /api/v1/miniapp/payment-intents · Authorization: Basic … · Idempotency-Key: 77120

{
  "amount": 5500,
  "description": "Захиалга #77120",
  "reference": "77120",
  "expires_in": 90,
  "presentment": "terminal",
  "terminal_id": "4b8e2f0c-…",
  "capture_method": "manual",
  "capture_within": 180,
  "metadata": {"machine": "VM-0007", "basket": "77120"}
}
Field Why
amount from your price list (not a number the machine sent — the machine can be tampered with)
description «Захиалга #77120» ("Order #77120") — never the product name (§11)
reference = Idempotency-Key = the basket number one basket = one intent: however many times the request is repeated, the same intent (200)
expires_in: 90 for a person standing in front of the machine; 60–1800 s
capture_method: "manual", capture_within: 180 capture/cancel within 180 s of the confirmation; otherwise the hold is released to the person (§7)
sub_merchant_id leave it out — the terminal's is applied automatically

A terminal has only one open, unclaimed intent (per environment). When a new basket starts, cancel your own previous unpaid basket first. If you forgot to cancel it (the machine restarted, an answer was lost), you get 409 terminal_busy with the open intent's id in the payment_intent field — POST …/cancel it and create again; the machine shows «Өмнөх захиалга цуцлагдлаа» ("Previous order cancelled"). An intent whose time has passed but which the job has not closed yet is closed by the server itself, and the server tries again.

5. Claim, hold, dispense command

  1. payment_intent.claimed → the screen shows «Утсаараа баталгаажуулна уу» ("Confirm on your phone"). If the person has not confirmed within 90 s, the claim ends and the next person who scans takes it over (a new user_ref, payment_intent.claimed again); you can release it earlier with POST …/unclaim (then claimed_at: null).
  2. payment_intent.requires_capture → the money is held. Before the dispense command, read the intent again with GET /payment-intents/{id} and check that status == "requires_capture" and that amount and reference match your basket (events can be repeated and arrive out of order).
  3. Dispense to the machine (with the intent id). The machine writes to its local log before turning each motor (77120 A1 started), and then the result (A1 dropped / B1 jammed) — that log is the truth after a power cut.
  4. The machine reports its result to the backend: {dropped: [{slot, qty}]}.

6. Capture, cancel, partial dispense

Machine's result Your backend For the person
Everything dropped POST /payment-intents/{id}/capture → succeeded the full amount is charged
Nothing dropped POST /payment-intents/{id}/cancel → the hold is released (canceled) nothing is charged (not a refund — no fee)
Partial (2 of 3) capture in full → wait until succeeded → POST …/refunds {"amount": <price of what did not drop>, "reason": "dispense_partial"} + Idempotency-Key: refund:77120 the full amount is charged and the part that did not drop is refunded
  • Partial capture with an amount (POST /capture {amount}) — «coming soon»: until core banking has capture with an amount, capture in full + refund.
  • A live capture may return 202 + status: "processing" (the ledger has not answered yet). A refund is only for a succeeded intent (409 not_refundable) — so wait with GET until it is succeeded.
  • Capture and cancel are idempotent by state: capturing an intent that is already captured → 200 with that intent. A refund is idempotent by its own Idempotency-Key — it is refund:<basket>, so settling again creates no second refund.
// examples/node/src/vending.ts — settle()
if (b.dropped === 0) return void (await retry(() => gerege.paymentIntents.cancel(b.intent)));
let pi = await retry(() => gerege.paymentIntents.capture(b.intent));
while (pi.status === "processing") { await sleep(1000); pi = await gerege.paymentIntents.retrieve(b.intent); }
const rest = b.amount - b.dropped;
if (rest > 0 && pi.refunded_amount < rest) {
  await retry(() => gerege.refunds.create(b.intent, { amount: rest, reason: "dispense_partial" },
    { idempotencyKey: `refund:${b.id}` }));
}

7. Timeouts

Time Meaning When it runs out
expires_in (90 s here) from the basket's creation until the person confirms expired, payment_intent.expired → the screen shows «Хугацаа дууслаа» ("Time is up")
The claim's time, 90 s the person scanned but did not confirm the next person can claim (a new user_ref, claimed again)
capture_within (180 s here; 60–604 800) from the confirmation until capture/cancel; capture_before = the moment of confirmation + capture_within expired, failure_code: "authorization_expired", the hold is released to the person, payment_intent.expired; capture → 409 expired
The ledger's backstop capture_before + half the window (≥ 60 s, ≤ 15 min) core banking releases the hold only when the platform itself could not release it

How to choose capture_within: the dispense time + the worst-case delay for the machine to report its result (the network drops and comes back) + a margin. 120–300 s for a machine on a stable network; more where 4G keeps dropping (600–1800 s) — but the longer it is, the longer the person's money stays held if the machine goes down. A «dropped» result that arrives after capture_before cannot be captured — that is a loss (§8).

8. Errors and offline

Case What happens What to do
Webhook did not arrive / arrived late — while the machine waits, the backend GETs every 2 s (up to 60 s); the reconcile loop checks ?status=requires_capture every 30 s
Backend → Gerege timeout (create) unknown whether the intent was created repeat with the same Idempotency-Key → that intent (200)
Capture/refund 5xx, timeout — repeat the same call (capture is idempotent by state, refund by key); 202 → read it later
The machine dispensed but could not tell the backend the hold is waiting the machine reports from its log as soon as it is online; before capture_before, capture; after it, a loss (record it in the log)
The machine jammed / went down before dispensing the hold is waiting the backend (or an operator) does cancel → released; otherwise by itself when capture_within runs out
The person cancelled in the app payment_intent.canceled refresh the screen
Payment failed (failed) the person may try again until the time runs out «Дахин оролдоно уу» ("Please try again") — do not close the basket
The same person scanned again a claim is idempotent for one person —
Another person scanned (a claim is active) they get 409 already_claimed, retry_after: 90 —
The person scanned and walked away the claim ends in 90 s or POST …/unclaim; the machine goes back from the «утсаараа баталгаажуул» ("confirm on your phone") screen after 90 s
One person > 3 unconfirmed claims 429 too_many_open_claims — (protection against remote claim attempts)
The previous basket is open 409 terminal_busy (payment_intent) cancel it and try again (§4)
Terminal disabled 409 terminal_disabled PATCH /terminals/{id} {"status": "active"}
Test basket and live basket separate per environment a test basket does not block a live machine
An intent with no reference / an unknown intent is holding money (the basket was lost in a crash) reconcile: cancel — do not hold the person's money for nothing

9. Idempotency

Call What protects a repeat
POST /payment-intents Idempotency-Key = the basket number (= reference): same key + same body → the same intent 200; a different body → 409 idempotency_conflict
POST …/capture, …/cancel the intent's state: if already done, that intent is returned
POST …/refunds its own Idempotency-Key (refund:<basket>)
POST …/unclaim an unclaimed intent, as it is, 200
Webhook dedupe by id (at-least-once)

The same rule holds between the machine and the backend: the machine creates its basket number itself (VM-0007-000123), and a repeated POST /machine/baskets returns that basket.

10. Reconciliation

  • Reconcile loop (30 s): GET /payment-intents?status=requires_capture — settle the baskets whose result is known, cancel unknown intents, warn about those close to their deadline; GET the intent of every waiting basket.
  • Every day: the machine's log ↔ GET /payment-intents?reference=<basket> ↔ your basket row. Whatever does not match, by hand.
  • If the machine or backend was down, catch up on missed events with GET /events?after=<last evt> (30 days). Report CSV — «coming soon». More: reconciliation.

11. Age-restricted goods

  • The age check is on the machine. For goods such as tobacco (21+), the machine runs its own eID QR check and binds that check to that basket (the person checked = the person buying; verify the signature of the eID answer) — only then POST /machine/baskets.
  • The payment screen never names the product. What Gerege App shows: the app's registered name and logo, the terminal's label/location, the sub-merchant's name, the amount — the payment screen does not show a merchant's description (M4), but the person's wallet statement gets <slug>: <description>. So description = «Захиалга #77120»; no tobacco brand or image in the app's name, logo or terminal name. The reason: Gerege App is a general payment instrument — App Store Review Guidelines 1.4.3 forbid apps that encourage tobacco and vape use; a tobacco name inside the app would make Gerege App look like an app that sells tobacco.
  • min_age (13–21) — optional, a second layer. Give the intent min_age: 21 and Gerege checks the payer: no eID → 403 verification_required, under age → 403 age_restricted (on claim and on confirm; the person is told only «age-restricted» — not the product). This checks the person paying, the machine's check the person taking the goods — if both are there, both must pass. It does not replace the machine's check.
  • An App Clip (on a phone without Gerege App) is never activated on a tobacco machine.

12. Full code

File What
examples/go/cmd/vending/main.go the machine API, intent (with terminal_busy), webhook, settle (capture / cancel / capture + refund), reconcile loop
examples/node/src/vending.ts exactly the same, in TypeScript
examples/*/…/webhook* a general webhook receiver (queue, dedupe)
export GEREGE_CLIENT_ID=gk_test_…  GEREGE_CLIENT_SECRET=gks_test_…  GEREGE_WEBHOOK_SECRET=whsec_…
export GEREGE_TERMINAL_ID=4b8e2f0c-…  MACHINE_TOKEN=$(openssl rand -hex 24)
cd examples/go && go run ./cmd/vending        # or: cd examples/node && npm run build && npm run vending

# In place of the machine:
curl -X POST localhost:8080/machine/baskets -H "Authorization: Bearer $MACHINE_TOKEN" \
  -d '{"basket_id": "77120", "lines": [{"slot": "A1", "qty": 1}, {"slot": "B1", "qty": 2}]}'
curl localhost:8080/machine/baskets/77120 -H "Authorization: Bearer $MACHINE_TOKEN"   # until "state": "dispense"
curl -X POST localhost:8080/machine/baskets/77120/result -H "Authorization: Bearer $MACHINE_TOKEN" \
  -d '{"dropped": [{"slot": "A1", "qty": 1}, {"slot": "B1", "qty": 1}]}'                  # 1 500₮ is refunded

Go's settle (from examples/go/cmd/vending/main.go; shared.Retry is a helper that repeats with the same Idempotency-Key):

pi, err := shared.Retry(ctx, func(ctx context.Context) (miniapp.PaymentIntent, error) {
    return s.gerege.PaymentIntents.Capture(ctx, intent)
})
if shared.Code(err) == "expired" { // capture_before has passed: the hold was released to the person
    s.end(b, stDone, "АЛДАГДАЛ")
    return nil
}
// … if processing, GET until succeeded, then:
_, err = s.gerege.PaymentIntents.Refund(ctx, intent, "refund:"+b.ID,
    miniapp.RefundParams{Amount: amount - dropped, Reason: "dispense_partial"})

13. Testing

  • Test key (gk_test_…, or the older mapp_test_…): no money moves, livemode: false. A terminal's test basket can be claimed only by the app's owner and test users (active members of your organisation); to everyone else it does not exist.
  • Test amounts (the last two digits): …01 → failed (test_declined) — try the «төлбөр амжилтгүй» ("payment failed") screen; …02 → canceled; anything else → requires_capture. A test capture is succeeded at once, and test refunds work.
  • A test key reads the terminal registry — register a terminal once, with a live key or the developer console.
  • The person's side: on a test user's iPhone, scan the sticker's QR with Gerege App or use the in-app «NFC уншуулах» ("Scan NFC") → claim → Face ID → a test hold. «Симуляц» ("Simulate"), which imitates it without a phone — «coming soon». Check your backend with the curl calls above, and your webhook with the developer console's test send and resend (developer console).

14. Go-live checklist

  • Every machine has a terminal, its label/location is the real location; the sticker is written and locked, the QR scans.
  • With several owners, every owner's payout is confirmed (payouts_configured: true).
  • capture_within is longer than the machine network's worst-case delay; there is a loss log.
  • The reconcile loop is running and releasing unknown holds.
  • No product name in description; for age-restricted goods the machine's check is bound to the basket.
  • Webhook signature, dedupe, an answer within 10 s; the client secret only on the backend.