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
captureonly after the goods have dropped.
2. One-time setup¶
- 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 receivespayment_intent.*andrefund.*(up to 5 endpoints per environment, each with its own event filter and secret; the originalwebhook_urlserves both test and live — tell them apart bylivemode). - 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. - Each machine = a terminal,
reference= the machine's serial (never registered twice; a repeatedPOST→ 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¶
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 newuser_ref,payment_intent.claimedagain); you can release it earlier withPOST …/unclaim(thenclaimed_at: null).payment_intent.requires_capture→ the money is held. Before the dispense command, read the intent again withGET /payment-intents/{id}and check thatstatus == "requires_capture"and thatamountandreferencematch your basket (events can be repeated and arrive out of order).- 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. - 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 asucceededintent (409 not_refundable) — so wait withGETuntil it issucceeded. - Capture and cancel are idempotent by state: capturing an intent that is already captured →
200with that intent. A refund is idempotent by its ownIdempotency-Key— it isrefund:<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;GETthe 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'sdescription(M4), but the person's wallet statement gets<slug>: <description>. Sodescription= «Захиалга #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 intentmin_age: 21and 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 oldermapp_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 issucceededat 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/locationis 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_withinis 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.