Vending machine client app guide
This guide is for developers building the on-device client app that runs on a kiosk / vending machine (or a device-management service acting on its behalf). It covers which endpoints the machine side uses, how to use each one, and the sync patterns that keep the device and the platform consistent.
Base URL: https://api.kiosk-x.ai
How a machine fits into the model
- Every device is identified by its
machineNo— the hardware serial number printed on the unit (e.g.866903013700011). All machine-side calls are keyed by it. - The device authenticates with its operator's credential (API key or an OAuth
client-credentials token minted at boot). Use a key with
machines:read inventory:read inventory:write orders:read— the machine app never needsaccounts:admin. - The platform is the source of truth for configuration (prices, thresholds, planogram); the device is the source of truth for events (dispenses, faults, stock consumption). The device reconciles on every sync.
The machine sync loop
A typical client app runs this loop every 2–5 minutes (the "heartbeat"):
boot ──► mint token ──► GET my machine record ──► GET my inventory ──► apply config
▲ │
└────────────── every 2–5 min ◄──────────────┘
1. Mint a token at boot (OAuth clients)
curl -X POST https://api.kiosk-x.ai/oauth/token \
-d grant_type=client_credentials \
-d client_id=kiosk-x-sandbox-superior \
-d client_secret=kx_secret_superior_0a1b2c3d4e5f6a7b
Tokens last 3600s — re-mint when expires_in is close to elapsing, or on the
first 401. Devices using a static X-API-Key skip this step.
2. Confirm identity and config
curl ".../api/v1/machines/866903013700011" -H "X-API-Key: $KEY"
Use this to verify the device is registered, read its display name,
location, and rentState (1 = active lease; treat 0 as "do not vend").
lastSeen is maintained by the platform from your traffic — a device that
stops calling in shows as offline to the operator.
3. Pull the planogram (inventory config)
curl ".../api/v1/inventory/machines/866903013700011" -H "X-API-Key: $KEY"
For each aisle, apply platform-side config to the device UI and vend logic:
| Field | Device behavior |
|---|---|
productName, productCode, productImageUrl |
What to display on the touchscreen |
sellingPrice |
Price to charge — always use the platform value, never a cached one |
currentStock |
Reconcile: if device count differs, trust local sensors and push a correction (step 4) |
maxStock |
Capacity for restock math |
alertThreshold |
When to surface "low stock" in the device UI |
faulted |
If true and the jam is cleared, push faulted: false to clear it |
4. Push state changes as they happen
After each vend, decrement the aisle (requires inventory:write):
curl -X PUT ".../api/v1/inventory/machines/866903013700011/aisles/3" \
-H "X-API-Key: $KEY" -H "Content-Type: application/json" \
-d '{"currentStock": 6}'
When a dispense motor jams or a sensor fails, flag the aisle so the operator sees it immediately:
curl -X PUT ".../api/v1/inventory/machines/866903013700011/aisles/3" \
-H "X-API-Key: $KEY" -H "Content-Type: application/json" \
-d '{"faulted": true}'
When a field tech physically refills the machine and confirms on the device screen, acknowledge it in one call:
curl -X POST ".../api/v1/inventory/machines/866903013700011/restock" \
-H "X-API-Key: $KEY"
5. Reconcile sales (optional but recommended)
The platform records orders from the payment flow. A device can cross-check its local vend log against the platform's view:
curl ".../api/v1/orders?machineNo=866903013700011&startTime=2026-08-10%2000:00:00&endTime=2026-08-10%2023:59:59" \
-H "X-API-Key: $KEY"
Match on orderNumber/buyTime. payState values: 3 = shipped (paid and
dispensed), 4 = refunded. If a local vend has no matching shipped order,
queue it for retry-reporting; if an order shows refunded, expect a fault
flag on that aisle.
Resilience rules for device firmware
- Offline-first: queue writes (stock decrements, fault flags) locally and
replay them in order when connectivity returns. All writes are idempotent —
replaying a
PUTwith the same body is safe. - Backoff: on
5xxor network failure, retry with exponential backoff (start 5s, cap 5 min). On429, respectRetry-After(3600s) — batch your queued writes rather than sending one call per vend during rush hour. - Budget: one heartbeat (machine GET + inventory GET) every 5 minutes plus typical vend traffic stays well inside the 1,000 req/hour limit.
- Clock: timestamps are UTC (
yyyy-MM-dd HH:mm:ssin query params, ISO-8601 in responses). Sync the device clock via NTP; don't derivebuyTimelocally. - Envelope: every response is
{code, message, data}— checkcode(mirrors the HTTP status), never parsemessagefor logic.
Endpoint cheat sheet (machine side)
| Device event | Call |
|---|---|
| Boot / get credentials | POST /oauth/token |
| Heartbeat: identity + config | GET /api/v1/machines/{machineNo} |
| Heartbeat: planogram + prices | GET /api/v1/inventory/machines/{machineNo} |
| Vend completed | PUT .../aisles/{aisleNo} with currentStock |
| Jam / sensor fault | PUT .../aisles/{aisleNo} with faulted: true |
| Tech confirmed refill | POST .../restock |
| Reconcile sales log | GET /api/v1/orders?machineNo=... |
| Platform health probe | GET /health |
What the machine app should not call: GET /api/v1/machines (fleet-wide,
operator concern), PATCH /api/v1/machines/... (naming/location is set by the
operator), and POST /api/v1/accounts (admin only).
Reyeah "JD" kiosk: the native device protocol (/apk/*)
The Partner API above is the clean, operator-facing surface. The Reyeah JD
kiosk firmware (com.ruiye.jd) instead speaks its own vendor device protocol
under /apk/* (envelope {code, data, msg} with code == 0 = success). Kiosk-X
implements that protocol too, so an unmodified Reyeah build can run against the
Intelliverse cloud with a single base-URL change.
| Device call | Kiosk-X behaviour |
|---|---|
GET /apk/getEquipment |
Returns the calling device's provisioned record. With auto-provisioning enabled, a valid new serial enters the unclaimed pool. Any starter product grid or stock is provisional data, not physical inventory or permission to sell. The authorized operator claims the serial, maps actual products and records actual filled quantities before acceptance. |
GET /apk/getGoods |
Planogram built live from that machine's aisles (aisle N ↔ aisle N). |
GET /apk/getStyle |
Kiosk UI theme (fixture). |
GET /apk/getAd |
Operator-managed screensaver ads, proxied per machine from the Intelliverse ad system (S3-hosted video); bundled defaults as fallback. |
GET /apk/getUpgradeVersion |
null normally; serves the CI-signed S3 APK when a rollout targets this machine — see APK rollouts. |
POST /apk/apkUp |
Firmware reports its installed build → tracked as rollout adoption. Needs a device credential once enforcement is on — see Device credentials on /apk/* below. |
POST /apk/authPassword |
Checks the configured manager password. Obtain authorized access through this cabinet's protected handover; never use a default copied from a guide. |
POST /apk/createOrder / createOrderCart |
Records the vend via record_sale against the calling machine → shows up in the operator app. |
everything else (ordersUpdate, uploadLog, …) |
Accepted and acked. |
Device credentials on /apk/*
This surface was built to mirror the vendor cloud, which authenticated nothing
per-machine, so no handler here has ever asked the caller to prove anything.
The firmware, though, has always sent a credential: MyApplication installs
three OkGo common headers at splash — secret (the constant in
UrlConfigString.secret), equipmentNo and apkVersion — and they ride every
request the app makes, including the parameterless ones. So a credential check
can be added here without touching the client.
POST /apk/apkUp is the first call to use it, because it is the one whose write
steers OTA rather than a dashboard. The version it stores is what
active_rollout_for_machine compares against, so a caller who can set it can
declare a cabinet current and stop it ever being offered a build again — and a
report at or above the target for every machine in a wave flips that wave to
completed and persists it.
Two credentials are accepted, in this order (the same ladder, for the same
reasons, as /zhzn/* — see app/routes/zhzn.py:_device):
x-device-key: <keyId>:<secret>— a per-device key from/zhzn/enrol. The machine comes from the key, so a holder can only ever speak as the one cabinet it was issued to.secret: <fleet secret>(KIOSKX_REYEAH_DEVICE_SECRET) — the constant baked into the published APK. Shared by every cabinet, so it proves fleet membership and nothing about which machine is calling. Accepted because it is what the firmware in the field actually sends.
The cutover
KIOSKX_REYEAH_DEVICE_AUTH_ENFORCED is off by default, and enforcement
additionally requires KIOSKX_REYEAH_DEVICE_SECRET to be set — a flag on its
own must never start refusing the fleet, because a config mistake would then be
a fleet-wide outage. Order of operations:
- Set
KIOSKX_REYEAH_DEVICE_SECRETto the baked-in constant. Nothing changes yet; reports now simply get classified. - Watch the log. Every credential-less report logs at WARNING with a stable prefix, carrying the machine, the client IP and a running count:
apkup unauthenticated: endpoint=apkUp machine=… ip=… count=… secretConfigured=True enforced=False
This line going quiet is the evidence — and the only evidence — that step 3
strands nobody. A cabinet that is unplugged this week is in nobody's column,
so give it longer than feels necessary.
3. Set KIOSKX_REYEAH_DEVICE_AUTH_ENFORCED=true. Credential-less reports
now get 401 and write nothing. The refusal is a real HTTP status rather
than the protocol's code: 1 on a 200, so it shows up in an access log
without parsing bodies; that is safe here specifically because the firmware's
apkUpCallBack is an empty override and reads nothing from the envelope —
unlike validVmcVersion, where a non-success answer means "flash the VMC".
4. Remove the fleet-secret arm only once cabinets present x-device-key.
GET /api/v1/fleet/device-identity is where that is counted.
Rollback at any point is unsetting the flag.
One guard does not wait for any of this. A cabinet the ZHZN gateway has
heard register is not writable through this endpoint at all: both fields the
call would set steer that fleet's OTA (apkVersion is the number compared for
both fleets, and a com.ruiye.jd versionAppId drops a machine out of every
wave published for the ZHZN agent), so one call naming a ZHZN serial silenced
that cabinet's /zhzn/upgrade while it still showed as a target of the wave —
which reads to an operator as "the cabinet never asked" and sends somebody to
the machine. A board that authenticated to /zhzn/* has already said what it
is, and this endpoint has no standing to overrule it with a call that proved
nothing.
No vendor-hosted URLs in device payloads
Every URL the device protocol serves is on infrastructure we control
(regression-tested for getStyle chrome, getGoods product images, and
getEquipment). Two legacy fields in the equipment payload deserve a note:
gotoHttpUrl(was the vendor's OEM remote-agent APK) — dead: no code path in the decompiled firmware reads this field; thecom.pingbo.gotoagentinstall URL arrives via an MQTTBUS_CONNECT_APPmessage (or a hardcoded RK3568 fallback), never fromgetEquipment. Served asnull.animation(was a vendor boot-animation zip) — live and consequential:SplashFragment.startupAnimation()downloads the zip andMyManager.replaceBootanimation()remounts/systemread-write to overwrite/system/media/bootanimation.zip. Served asnullon purpose: the firmware treats an empty URL as "skip" (no download, no/systemremount — boot is unaffected and the currently installed animation stays), while shipping a Kiosk-X zip would trigger fleet-wide system-partition writes that can't be verified without hardware. Revisit only with a hardware-validatedbootanimation.zip(STORED zip,desc.txt+ part folders, panel-matched resolution).
Device identity (multi-machine)
Every /apk/* call is keyed to the calling device. Identity is resolved
from, in order: query param / body field / header (equipmentNo, machineNo,
deviceNo, deviceId, sn, or X-Equipment-No), then the session cookie.
getEquipment pins the resolved serial in a cookie (kx_equipment_no), so
firmware that sends parameterless GETs after boot stays attributed to the right
machine — the Reyeah build persists cookies across calls and reboots. Calls
with no identity at all fall back to the demo machine 866903013700011 so the
emulator and docs examples keep working.
Screensaver ads (operator-managed, S3)
getAd fetches the machine's approved ads from the Intelliverse ad-management
system (GET /kiosk/screensaver-ads/{machineId} on the User Management API):
operators upload video ads, admins approve them, the media lives on S3, and
each machine gets its own list. Responses are cached for 60s. If a machine has
no ads or the upstream is unreachable, the bundled default screensaver +
banners are served — a kiosk never boots ad-less. Serial → Intelliverse
machineId translation is configured with INTELLIVERSE_ADS_MACHINE_MAP
(e.g. 866903013700011:24).
One cloud build — and migrating boards off the vendor cloud
The shipped APK (reyeah-vending-kioskx.apk, installs on the tablet as
"Kiosk X") points UrlConfigString.baseUrl at api.kiosk-x.ai
— our cloud implements the full device protocol, so the board boots to the
product grid and every purchase flows straight into the operator app and the
emulator. Registration order doesn't matter: an unregistered
board is auto-provisioned on first contact and claimed later.
A board still running the factory firmware (pointed at the original vendor backend) migrates in three steps and never touches the vendor again:
- Install —
adb uninstall com.ruiye.jd && adb install -r reyeah-vending-kioskx.apk. The kiosk boots and auto-provisions itself against our cloud. - Claim the board's serial — operator app → Machines → + Register
(or
POST /api/v1/machines/register). - Verify — a test vend appears in the operator app under your fleet.
The APK is published to the downloads hub; rebuild it with
scripts/build_vending_variants.sh in the vending-kiosk repo.
Payments: the Nayax reader is cloud-independent
The kiosk app never processes card data. A Nayax VPOS Touch reader sits on
the machine's MDB bus: it authorizes the tap with Nayax's cloud, the VMC
vends, and the reader captures the exact amount. Settlement is decided by the
terminal → merchant-account binding inside Nayax, so repointing the APK at
a different cloud changes nothing about who gets paid — the operator's Nayax
account receives the funds either way. What the cloud receives is the
transaction record (POST /api/v1/payments/nayax/webhook), which is how the
sale appears in the operator app with card brand, last4, and settlement
account. Full walkthrough: Payments & Nayax.