Receiving a cabinet for installation — ZHZN
Android head units: use ZHZN Android installation and handover. The installation, networking, credential-file and OTA commands below describe the Python/Linux agent. The ZHZN Android APK implements CSM directly and does not require Python, systemd or the Reyeah vending APK.
For whoever unboxes the cabinet at the venue: an operator, or a contracted installation technician. You are not expected to know how this system is built. Work through the sections in order; each one ends with something you can see on a screen, so you always know whether to continue or stop and call it in.
The factory half of the story — what was installed and tested before the crate was closed — is First install at the factory.
What you need before you start
- The delivery paperwork with the cabinet's serial on it.
- An account on the operator console (
operator.kiosk-x.ai) or the Operator X app, signed in, with the machine's operator visible to you. - Your console admin password. You will be asked to re-type it to change the cabinet's Wi-Fi; being signed in is not enough.
- A phone that can join a Wi-Fi network and open a web page.
- The venue's Wi-Fi network name and password, from the venue — not from us.
Everything here is traceable to code, and anything this runbook would like to tell you to do but cannot is in §11 Not yet automated instead of being invented.
1. Before you cut the straps
Check the serial on the cabinet's plate matches the paperwork, character for character. Then check it against the build record, which is the factory's own account of this specific cabinet:
Console: open Machines → the machine → Build & factory QA. You are looking
at MachineBuildCard (kiosk-x-operator/components/MachineBuildCard.tsx).
Or from a terminal:
export KIOSKX_API_BASE=https://api.kiosk-x.ai
export KIOSKX_KEY=<your operator API key>
curl -s -H "X-API-Key: $KIOSKX_KEY" \
"$KIOSKX_API_BASE/api/v1/machines/VM-2026-0001/build" | python3 -m json.tool
Four things to read, and what each one means for you:
| What you see | What to do |
|---|---|
state: "signed_off" |
Good. The factory has certified it and it is waiting for your acceptance. |
state: "testing" or "recorded" |
Stop. Nobody at the factory has signed this cabinet off. You will not be able to record acceptance either — the API refuses acceptance with Nothing to accept yet: this machine has no factory sign-off on file (app/machine_build.py, operator_acceptance). Report the serial before you install it. |
testSummary.failed is not empty |
Stop. A subsystem failed at the factory and was never repaired. The console says so in red and names the order reference. Raise it with the manufacturer before installing. |
state: "accepted" |
Somebody has already accepted this cabinet. Acceptance cannot be recorded twice (app/machine_build.py, operator_acceptance), so if that was not you, find out who. |
A 404 here has three possible causes, and the message tells you which of the common two you are in:
Machine … exists but is not in any fleet yet— the cabinet is real, it has powered on and registered itself, and nobody has claimed it. Normal for a cabinet you have only just uncrated. Claim it (§4) and read the record again. The message names the call.Machine … not found— either the serial does not exist, or it belongs to a different operator. Those two stay deliberately indistinguishable, so that someone guessing serials cannot map another operator's fleet (app/machine_build.py,_require_visible). Check the plate against the paperwork character for character first; if the serial is definitely right, the cabinet is registered to somebody else and needs a platform-side ownership transfer.
The distinction matters because you are standing next to a real cabinet: without it, "you have not claimed this yet" and "you have mistyped the serial" read identically, and the first is thirty seconds of work while the second is a phone call to the depot.
Also read the four version rows on the card: Firmware at ship / Firmware now, and Agent software at ship / Agent software now. The cabinet reports its own controller firmware and agent version on registration and on every heartbeat, so once it has called home these are live values, and the card says in amber when either has changed since the factory crated it. On a ZHZN cabinet the on-machine software row is labelled "Agent software" rather than "APK", because there is no APK on the box.
If a row still reads Not reported after the cabinet has registered, the agent build on it is too old to report that version. It will still work; you just have no live comparison for it until it is upgraded.
2. Power on, and what first boot does by itself
Uncrate, position, level, and connect power. Set the VDC-SEL jumper for auto-power-on after outages if the factory has not — otherwise the first venue power cut leaves the cabinet dark until somebody drives out to press a button.
Power on and wait. You do not configure identity, and you do not type a machine number anywhere. On first boot the cabinet does this on its own:
- It reads its serial from its configuration and calls
POST /zhzn/register, using its persisted/provisioned device key when available, or the fleet bootstrap secret before enrolment (app/routes/zhzn.py,_device). - The cloud maps that serial to a machine code, creates the machine record if
it has never been seen, and returns the code plus the cabinet's configuration
(
app/routes/zhzn.py,register;app/store.py,Store.auto_provision_machine). - The agent writes the machine code to
/var/lib/zhzn-agent/identity.jsonwith mode0600(agent/provision.py:56-63,:192-196). - If it holds no credential of its own, it enrols:
POST /zhzn/enroltrades the fleet secret for this cabinet's own key, once, and the agent stores it beside the identity file (app/routes/zhzn.py,enrol). From then on the shared secret cannot replace that key on the gateway — which matters, because it is the same string on every cabinet on the platform. See §4.1: there is one failure here that looks like broken hardware and is not. The design is in Per-cabinet device credentials. - It sends the board an Init and starts heartbeating.
The whole sequence is exercised end to end in tests/test_zhzn_lifecycle.py,
which boots a factory-fresh machine holding nothing but a fleet secret and a
serial and asserts it comes up with a machine code and the cloud's configuration
(tests/test_zhzn_lifecycle.py:196-213).
This step needs network. A cabinet with no route to the cloud cannot register, so if the venue's Wi-Fi is not yet joined, do §3 first and come back.
3. Getting the cabinet on the venue's network
Use the on-site path below. It is the one designed for exactly this situation — a cabinet that cannot reach the cloud — and it needs no connectivity at all. The full design rationale is in Changing a machine's Wi-Fi.
Do not try to queue the credentials from the console. That path
(POST /api/v1/machines/{no}/network/changes) only reaches a machine that can
already hear us, which a freshly-uncrated cabinet cannot
(app/routes/machine_network.py:172-177).
3.1 Get the service code
The cabinet's local admin screen is unlocked by an 8-digit service code. It
is unique to this cabinet, and it is derived, not stored — an HMAC of the fleet
secret and the machine code — which is exactly what lets a disconnected cabinet
check it with no cloud round trip (app/machine_network.py:178-187).
The per-device key used for cloud polling does not replace this local unlock
secret. In Python source 8344f108ac1ae2a68b5ccdc7466bda059b58a3ce, empty/whitespace
ZHZN_DEVICE_SECRET makes
local /provision return 403 without touching the radio, even when cloud key
authentication works. Before a key-only cabinet needs offline setup, have the
platform provision its supported service-code configuration. There is no
independent local service-PIN environment variable; ZHZN_LOCAL_TOKEN is only
the separate loopback dispense bearer. Do not paste keys or service codes into
support chats or public logs.
From the console, Machines → the machine → Network → Change Wi-Fi, and re-type your admin password. Or:
curl -s -X POST -H "X-API-Key: $KIOSKX_KEY" -H "Content-Type: application/json" \
"$KIOSKX_API_BASE/api/v1/machines/VM-2026-0001/network/unlock" \
-d '{"password": "<your console admin password>"}'
You get back the service code, the cabinet's setup access point, and a ticket
good for 5 minutes (app/machine_network.py:73, :436-467):
{ "ticket": "kxu_…", "expiresInSeconds": 300,
"serviceCode": "04128733",
"setupAp": { "ssid": "KioskX-Setup-260001",
"passphrase": "EXAMPLE7ONLY9",
"joinPayload": "WIFI:T:WPA;S:KioskX-Setup-260001;P:EXAMPLE7ONLY9;;",
"provisionUrl": "http://192.168.4.1:8770/provision" },
"revertTimeoutSeconds": 90 }
(Values above are illustrative. Real ones are specific to your fleet — verified against a local API, which returned exactly this shape.)
This call is audited: who unlocked it, when, and from which surface, because
it reveals the cabinet's service code even though nothing has changed yet
(app/machine_network.py:457-458). Treat the code as a credential and do not
write it on the cabinet.
3.2 Unlock the cabinet and join its setup network
- On the cabinet's touchscreen, open the local admin screen and enter the
8-digit service code. Five wrong attempts lock the screen for five
minutes (
agent/network.py:114-115,:879-891), and a wrong code and a locked-out screen give the same message on purpose — telling them apart would help somebody grinding codes (agent/localctl.py:441-446). - The cabinet raises a temporary access point named
KioskX-Setup-<last 6 of the machine code>and displays a Wi-Fi join QR for it (agent/network.py:893-916). - Scan that QR with your phone's camera and join the network. The QR contains no venue password — it is only a join code for the machine's own setup network, which is why photographing the screen gives nobody the venue's Wi-Fi.
- If the cabinet's screen is dead or cracked, the console can render the
identical join QR, because the setup AP is derived deterministically
(
app/machine_network.py:190-211). Use the ticket from §3.1:
bash
curl -s -H "X-API-Key: $KIOSKX_KEY" \
"$KIOSKX_API_BASE/api/v1/machines/VM-2026-0001/network/setup-qr.png?ticket=kxu_…" \
-o setup.png
Verified: returns a PNG, served no-store, private because the payload
embeds a credential (app/routes/machine_network.py:139-165).
3.3 Pick the venue's network on the cabinet's page
On your phone, open http://192.168.4.1:8770/provision?t=<token> — the URL the
cabinet displays. The token in it is signed, single-use, bound to this
serial, and valid for ten minutes, so a QR photographed off cabinet A cannot
reconfigure cabinet B beside it (agent/network.py:163-197).
The page is served by the cabinet itself and is deliberately plain: at the moment it is needed the machine has no internet, so it cannot fetch a stylesheet, a font or a framework.
The page leads with the networks the cabinet's own radio scanned, strongest first. That scan was taken at §3.2, before the setup network went up, because on most of the fleet's radios a scan cannot run while the cabinet is hosting an access point — the sweep would drop the very network your phone is on. Full rationale in Changing a machine's Wi-Fi.
Read the list, do not just take the top row:
too weak heremeans the cabinet measured this network below −75 dBm from where it is standing. It will associate and then drop out intermittently, which looks like a faulty machine for weeks before anyone blames the network. Pick something else, or move the cabinet.- The band matters. A cabinet is a metal box; 2.4 GHz gets out of it and 5 GHz often does not, even when both look fine at the counter. Where the same name appears on both bands the row says so.
no passwordis an open network. The cabinet takes card payments — only choose it if the venue genuinely has nothing else.knownis a network this cabinet has connected to before. Leave the password blank to reuse the saved one; type a new one only if the venue rotated it.
Tapping a network fills the form, it does not submit it — a mis-tap costs you nothing. Then enter the password and press Connect.
Hidden network? A network that does not broadcast its name can never appear in a scan. Open "My network is not in the list (hidden network)" and type it exactly as the venue gave it to you, capitals included.
Scan again re-runs the scan. On most cabinets this briefly drops this page; it asks first, comes back on its own, and nothing on the machine has been changed while you wait. Do not power-cycle the cabinet.
The credentials go from your phone to the machine and stop there. They are never sent to the cloud.
3.4 What happens next, and why you should not power-cycle
The cabinet now does something specific, and knowing it will stop you making things worse:
snapshot the network that was working
→ switch to the new one
→ prove it can REACH THE CLOUD, not merely that the radio associated
→ on any failure within 90 seconds, put the old network back
Ninety seconds (agent/network.py:75) is chosen to cover a venue access point
behind a slow gateway while keeping the cabinet's dark window under two minutes
— short enough that you do not conclude you bricked it and start
power-cycling. Wait the full 90 seconds.
Association alone is not treated as success, because the common real failure is
a venue that changed ISP: the radio associates perfectly and nothing routes
(agent/network.py:830-842).
Three outcomes, all of which the page tells you:
| Outcome | Page says | What it means |
|---|---|---|
applied |
Connected. | Done. Continue to §4. |
reverted |
That didn't work. | The credentials were wrong or the network does not reach the internet. The cabinet is back on the network that worked and is still sellable. Get the correct credentials from the venue and repeat §3. |
failed |
Still offline. | There was no previous working network to fall back to — normal for a brand-new cabinet with wrong credentials. Repeat §3 with the correct ones. |
reverted is a success for this system, not an error
(agent/network.py:790-798).
4. Confirm the cabinet registered and is online
Console: Machines. The cabinet should appear with a green online badge.
curl -s -H "X-API-Key: $KIOSKX_KEY" \
"$KIOSKX_API_BASE/api/v1/machines/VM-2026-0001" \
| python3 -c 'import json,sys; m=json.load(sys.stdin)["data"]; \
print("status :", m["status"]); print("lastSeen:", m["lastSeen"]); \
print("software:", m["software"])'
status: "online" is set by the cabinet's heartbeat, which touches the machine
record every 60 seconds by default
(app/routes/zhzn.py, heartbeat; app/store.py, Store.touch_machine;
agent/config.py:176). The lifecycle test asserts the machine shows ONLINE in
the operator app's machine list immediately after its first heartbeat
(tests/test_zhzn_lifecycle.py:204-213).
Also confirm the link the cabinet thinks it is on:
curl -s -H "X-API-Key: $KIOSKX_KEY" \
"$KIOSKX_API_BASE/api/v1/machines/VM-2026-0001/network" \
| python3 -c 'import json,sys; d=json.load(sys.stdin)["data"]; \
print({k: d[k] for k in ("link","ssid","signalDbm","signalQuality","agentVersion")})'
signalQuality is graded on the right ladder for the link type — Wi-Fi RSSI and
LTE RSRP are not the same scale, so -75 dBm is marginal Wi-Fi and a perfectly
good cellular link (app/machine_network.py:373-392). fair or poor on
Wi-Fi is worth fixing while you are still on site: reposition, or ask the
venue about a closer access point. A weak link is the cause of most later
"machine keeps going offline" calls.
agentVersion appears here too (app/machine_network.py:413), but it is no
longer the only place: the same value is on the machine record as
software.agentVersion, written on registration and on every heartbeat
(app/store.py, Store.report_agent_version), which is what the console's
Build & QA card compares against the version the factory shipped.
If the cabinet is not claimed into the right fleet, register the serial. On
the cabinet's screen, tap the machine number to get a registration QR
(app/routes/device.py:749-763), or:
curl -s -X POST -H "X-API-Key: $KIOSKX_KEY" -H "Content-Type: application/json" \
"$KIOSKX_API_BASE/api/v1/machines/register" \
-d '{"machineNo": "VM-2026-0001", "name": "Lobby", "location": "123 Main St"}'
If the board already auto-provisioned itself, this claims it — the machine
and any vends it already took move into your fleet
(app/routes/machines.py:238-245). A 409 means the serial belongs to another
operator, which is a paperwork problem, not something you can fix at the venue.
4.1 Confirm the cabinet holds its own key
Registered and online is not the same as enrolled. Check it explicitly, because the failure mode below is silent and gets misdiagnosed as a hardware fault:
curl -s -H "X-API-Key: $KIOSKX_KEY" \
"$KIOSKX_API_BASE/api/v1/machines/device-credentials/fleet" \
| python3 -c 'import json,sys; d=json.load(sys.stdin)["data"]; \
print("still on the fleet secret:", d["onFleetSecret"], \
"| windows open now:", d["armed"], \
"| machines the secret still reaches:", d["reachableOnFleetSecret"]); \
[print(m["machineNo"], m["state"], m.get("keyId") or "-", \
m["enrolmentBinding"], "ARMED" if m["enrolmentArmed"] else "") \
for m in d["machines"]]'
You want state: "enrolled" for this cabinet, with a key id. The view lists
cabinets still on the shared fleet secret first, and armed ones first among
those, because those are the ones with work outstanding
(app/routes/machine_identity.py, device_credential_fleet).
Three numbers, and they answer different questions. onFleetSecret is the ZHZN
migration's progress bar. armed is how many enrolment windows are open right
now, which should be zero once you have finished — a number that stays up is an
install somebody walked away from. reachableOnFleetSecret is the blunt one:
every un-enrolled machine record the gateway would still serve on the shared
secret, ZHZN or not, listed or not. It is normally the largest of the three, and
it is the exposure rather than the progress. See the residual in
the design.
The enrolmentBinding column is the one to read on a cabinet that has not
enrolled. device-id means the cloud knows which physical box this is and the
cabinet will enrol itself unattended. unbound means there is nothing to bind an
enrolment to, and the cabinet needs arming (§4.2). self-asserted means the
device id on file was put there by a fleet-secret call and is worth nothing to an
enrolment — do not read it as protection.
| What you see | What it means | What to do |
|---|---|---|
enrolled |
This cabinet has its own key. The shared fleet secret no longer addresses it | Nothing. Carry on |
fleet-secret |
It has not enrolled yet. It sells and reports normally, but the shared secret still reaches its planogram and its order book | If enrolmentBinding is device-id, give it a few beats and re-check — its enrolment retries on a lengthening ladder. If it is unbound or self-asserted, it is waiting for an arm (§4.2). If it stays put, journalctl -u zhzn-agent \| grep -i enrol |
revoked |
Somebody stopped this cabinet on purpose — usually a suspected clone | Do not touch the hardware. Ring Kiosk-X; it needs an attributed identity reset |
4.2 The cabinet is waiting to be armed (428)
A cabinet the cloud has never heard from cannot enrol on the fleet secret alone.
The agent's log shows its enrolment refused with 428, naming the call that
clears it, and it keeps retrying — this is a cabinet waiting for permission, not
a broken one. Do not swap parts and do not reimage.
The operator who owns the cabinet, or a Kiosk-X admin acting for them, opens a window for it:
curl -s -X POST -H "X-API-Key: $KIOSKX_KEY" -H "Content-Type: application/json" \
"$KIOSKX_API_BASE/api/v1/machines/VM-2026-0001/device-credential/arm" \
-d '{"reason": "field install, Westfield bay 4", "ttlMinutes": 30}'
reason is mandatory, because an unattributed arm is worth nothing after the
fact. The window is 30 minutes by default and 24 hours at most, it is for that one
cabinet, and the first enrolment spends it. Add "deviceId": "<the unit's
/etc/machine-id>" if you are standing at the cabinet and can read it — then only
that box can spend the arm. POST .../device-credential/disarm closes a window you
opened by mistake, and you do not have to wait out the TTL.
You do not need to reboot the cabinet or drive back out. The agent is already retrying; it enrols on its next attempt after the arm.
If you are arming from the office for a cabinet an installer is about to power on, arm it after they confirm they are on site. Arming a crate that then sits on a loading dock for the afternoon is exactly the window the arm exists to remove.
The 409 you may meet, and why it is not the hardware. If the agent's log shows
its enrolment refused with 409 already holds a device credential while this
cabinet holds no key of its own, then somebody else enrolled this serial —
and the real cabinet is locked out. Escalate to Kiosk-X: an admin clears the
binding with POST /api/v1/machines/{no}/identity/reset, which takes a named
signer and a reason, arms the cabinet as part of the same call, and the cabinet
then enrols itself on its next beat.
That used to be reachable by anyone holding the fleet secret against any cabinet that had never reported a device id — there was nothing for the cloud to bind an enrolment to, so any claimant was accepted. The arm above is what closed it, and a 409 on a cabinet that never enrolled is now much more likely to be an innocent duplicate than a theft. It is still worth escalating rather than guessing.
The same endpoint's other refusals, so they are not mistaken for each other:
| Code | Means | Cabinet or cloud |
|---|---|---|
428 |
Nothing proves this claimant is this cabinet. Needs an arm | Cloud. §4.2 above |
409 |
This serial already holds a credential, or reports from a different box | Cloud. Escalate |
403 |
The credential was revoked, or this deployment has closed enrolment (KIOSKX_ZHZN_ENROLMENT=closed) |
Cloud. Ring Kiosk-X |
429 |
This serial has spent its enrolment budget for now. Refused attempts count too, so a serial being hammered stops being answered | Cloud. Wait |
None of the four is the cabinet. Nothing about the board, the bus, the reader or the wiring produces any of them.
A cabinet that reinstalled its software and lost /var/lib/zhzn-agent gets the
same 409 for an innocent reason, and that case has its own recovery in
§10.
5. Planogram and initial stock
An aisle is a product slot, so setting the product, price and stock is one
call per slot (app/routes/inventory.py:106-120):
curl -s -X PUT -H "X-API-Key: $KIOSKX_KEY" -H "Content-Type: application/json" \
"$KIOSKX_API_BASE/api/v1/inventory/machines/VM-2026-0001/aisles/1" \
-d '{"productName": "Cola 330ml", "productCode": "COLA-330",
"category": "drinks", "sellingPrice": 2.50,
"maxStock": 12, "currentStock": 12, "alertThreshold": 3}'
Then load product physically and set every aisle to capacity in one call:
curl -s -X POST -H "X-API-Key: $KIOSKX_KEY" -H "Content-Type: application/json" \
"$KIOSKX_API_BASE/api/v1/inventory/machines/VM-2026-0001/restock" -d '{}'
An empty body restocks all aisles (app/routes/inventory.py:85-104), which the
lifecycle test confirms sets every aisle to its maximum
(tests/test_zhzn_lifecycle.py:304-311).
Check the grid maps to the real cabinet. The cabinet's slot geometry is
aisle = (row - 1) × columnsPerRow + column (agent/config.py:246-254), and
the cloud serves the planogram using the same formula
(app/routes/zhzn.py, _aisle_to_slot and _slots_for). If columnsPerRow
does not match the physical grid, every purchase lands on the wrong lane.
Read what the cabinet was told:
curl -s -H "X-API-Key: $KIOSKX_KEY" \
"$KIOSKX_API_BASE/api/v1/inventory/machines/VM-2026-0001" | python3 -m json.tool
then confirm on the machine that aisle 1 is the top-left lane and aisle
columnsPerRow + 1 is the leftmost lane of the second row. The default is 10
(app/routes/zhzn.py, _config_for), verified live against a freshly-registered
serial.
6. Test vend
Do a remote-led vend, because it exercises the cloud → cabinet path that a phone or web purchase will use, which a manager-menu test dispense does not.
curl -s -X POST -H "X-API-Key: $KIOSKX_KEY" -H "Content-Type: application/json" \
"$KIOSKX_API_BASE/zhzn/dispense" \
-d '{"machineId": "VM-2026-0001", "orderId": "install-test-1",
"productId": "COLA-330", "row": 1, "column": 1, "quantity": 1,
"dispenseId": "install-test-1"}'
The dispense is queued, not pushed, so nothing happens instantly. The
cabinet polls every 3 seconds by default (agent/config.py:238), takes the item
on a lease rather than popping it, and the item is only removed from the queue
once the outcome is reported (app/routes/zhzn.py, get_pending and
_complete_pending). That is deliberate:
an agent that crashes between poll and vend gets the item re-delivered instead
of a paid dispense being lost.
Within a few seconds the product should drop. That is the pass condition for this step — the cloud reached the cabinet, the cabinet reached the board, and the board moved a motor. Two things confirm it beyond watching the lane:
# on the cabinet: the item it took, the aisle it resolved to, and the outcome
sudo journalctl -u zhzn-agent -n 50 --no-pager | grep -iE 'dispense|unit '
# expect: [dispense] order=install- ... slot=(r1,c1) -> aisle 1 x1
# unit 1/1: success (...)
# and that the outcome reached the cloud: the spool holds only UNDELIVERED
# reports and is drained the moment one is accepted (agent/spool.py)
sudo wc -l /var/lib/zhzn-agent/reports.jsonl
# expect: 0
An empty spool is the real proof the cloud heard about the vend, because the
cabinet retries a report until it is accepted and only then removes it
(app/routes/zhzn.py report). A line still sitting in reports.jsonl means
the cabinet vended and the cloud has not acknowledged it — a connectivity
problem, not a dispenser one.
Stock does not move on this test, and that is correct
Do not use "stock went down by one" as the pass condition for the command
above. The orderId in it — install-test-1 — is a label you invented, not a
Kiosk-X order. Stock and the order list belong to an order, so a dispense with no
order behind it moves product and nothing else. Verified against a local API on
2026-08-31: a stocked aisle read 10 before the dispense and 10 after a
successful report, with {"forwarded": true} on the report and no new order on
the operator's list.
That is not a fault and there is nothing on site to fix. Do not go looking through MDB wiring, reader profiles or Core settings for it, and do not mark the cabinet faulty.
Restock the lane you tested. The physical item left the cabinet and the count did not change, so the count is now one higher than the shelf. Test on a lane you are about to reload anyway.
If you want to see the whole loop close
Name a real order on the enqueue and the books do move: the report lands on
that order, it goes to shipped, and the aisle decrements. Verified on the same
run — a real order id in place of install-test-1 took the same aisle from 10
to 9 and left the order vended.
You do not need to do this by hand. §7 is this test: a card tap opens a real
order, and rung 4 there is the console check. If you would rather see it before
you get the reader working, open one on the cabinet with POST /zhzn/createOrder
(app/routes/zhzn.py create_order) and use its orderNumber above.
If stock does not move on a dispense that did name a real order, that is a genuine order-settlement problem: §9 Troubleshooting, "The vend worked and stock did not move".
If the product does not drop, the failure is almost always mechanical or serial, not cloud: see §9 Troubleshooting, "Product does not drop".
7. Test a cashless transaction
This is the step most likely to fail on a new install, and the failure is usually not in anything either of us controls — the Nayax reader lives in a different company's account, authorizes over its own cellular link, and talks to the cabinet over MDB.
First, bind the reader so settlement records point at this cabinet. Use the
long Device Number off the reader, leading zero included — never the short
Machine ID (app/routes/machines.py, docs/payments-nayax.md):
curl -s -X POST -H "X-API-Key: $KIOSKX_KEY" -H "Content-Type: application/json" \
"$KIOSKX_API_BASE/api/v1/machines/VM-2026-0001/nayax" \
-d '{"terminalId": "<the reader Device Number>"}'
Then tap a real card on the cheapest item, once, and walk these in order. Stop at the first rung that fails; each rung has a different owner:
- The reader shows the amount while the cabinet is still counting down. If it does not, the MDB session never opened — a reader profile or wiring problem. Cellular "Online" with a green LED proves the modem only; a reader can be online and completely absent from MDB.
- The tap authorizes on the reader. If it declines, it is a Nayax-side decline, a Default Credit set below the price, or no cellular.
- The product drops.
- The order shows as shipped and stock decrements in the console. Unlike
§6's hand-queued test, the tap opened a real Kiosk-X order, so this rung is a
real check: the order reaches
shipped/vendedand the aisle goes down by one. If the product dropped and this does not happen, stop here and use §9 "The vend worked and stock did not move" — the fault is in order settlement, not in anything on site, so do not start pulling MDB wiring for it. Two cabinet-side facts are worth having before you escalate:sudo wc -l /var/lib/zhzn-agent/reports.jsonlreads0, andjournalctl -u zhzn-agentshows the aisle andsuccess. - The payment carries a real Nayax
transactionId. An id startingnyx_emu_is a cloud-side emulation, not a real capture. - The money appears in your own Nayax payout report. The account named in the console is a mirrored label, not proof of deposit.
Read the cloud's own diagnosis rather than guessing:
curl -s -H "X-API-Key: $KIOSKX_KEY" \
"$KIOSKX_API_BASE/api/v1/machines/VM-2026-0001" \
| python3 -c 'import json,sys; \
print(json.load(sys.stdin)["data"]["nayax"]["cardPath"])'
cardPath.status |
What it is telling you |
|---|---|
no_card_attempts |
No card sale has been attempted on this cabinet yet. |
waiting_for_reader |
A card session is open on our side; the reader must show the amount and capture. This cloud never starts that prompt. |
reader_never_authorized |
Rung 1 failed — the MDB session never opened. Reader profile or wiring. |
captured_without_vend |
The worst one: money was taken and no product came out. Deal with this before anything else. |
cloud_emulated_only |
The last settled card sale was a webhook emulation, not a real Nayax capture. Not proof the reader works. |
external_core_capture |
The reader reports into somebody else's Nayax account, so settlement notifications never reach us. The vend is real; the money is not yours. |
prepaid_ghost_sale |
Shipped with no capture we can see, and we expected one. |
ok |
The last card sale has a real Nayax transactionId. This is the only value that means the whole path works. |
(app/store.py, Store._compute_nayax_card_path.) The detailed remedy for each
is Activating a Nayax reader. If the reader cannot
be made to work today, Scan & Pay is the card path with no Nayax Core
dependency at all, and it settles to you — see
Cards without MDB.
8. Place it, then accept it
Placing a cabinet is two separate facts, and only one of them is the map pin.
8.1 — Where it is. Set the location so it appears in the right place on the fleet map:
curl -s -X PATCH -H "X-API-Key: $KIOSKX_KEY" -H "Content-Type: application/json" \
"$KIOSKX_API_BASE/api/v1/machines/VM-2026-0001" \
-d '{"name": "Riverside Gym — lobby", "location": "12 Quay Street, Auckland"}'
8.2 — Whose premises those are, and what they get paid. An address says where the cabinet stands. It says nothing about who owns that floor, so on its own it earns the venue nothing: no commission accrues, no host statement exists, and nobody is told. The cabinet sells perfectly well in the meantime, which is why this is the step that gets skipped.
Create the venue if it is new, or use the one you already have. If the
cabinet has already been selling, name its serial on the venue when you create
it. A serial listed at creation accrues the host's commission from the
cabinet's first sale; a serial attached afterwards by PATCH /api/v1/machines/{no}
accrues only from the moment you attach it, and everything sold before that
earns the host nothing — with no error, and a hostLink that reads linked /
none exactly as if it were right.
curl -s -X POST -H "X-API-Key: $KIOSKX_KEY" -H "Content-Type: application/json" \
"$KIOSKX_API_BASE/api/v1/crm/venues" \
-d '{"name": "Riverside Gym", "address": "12 Quay Street, Auckland",
"machineNos": ["VM-2026-0001"], "revShareBps": 1500,
"contactName": "T. Reeve", "contactEmail": "t@riverside.example"}'
That returns the venue with an id like ven_4de41cc28efe — the field is id
on the venue and venueId on the machine.
If the venue already exists, name the serial on the venue — the same
sentence as create — not on the machine. PATCH /api/v1/machines/{no} with
venueId is a move: it opens the host's accrual window at the moment you
send it, and everything sold before that earns the host nothing, with no
error and a hostLink that reads linked. Do not use it to wire a cabinet
that has already been selling.
curl -s -X PATCH -H "X-API-Key: $KIOSKX_KEY" -H "Content-Type: application/json" \
"$KIOSKX_API_BASE/api/v1/crm/venues/ven_4de41cc28efe" \
-d '{"kioskMachineNo": "VM-2026-0001", "revShareBps": 1500,
"deploymentDate": "2026-05-01"}'
deploymentDate is the day the cabinet went in. Omit it only when you mean
"from this cabinet's first sale". revShareBps is basis points of net
revenue — 1500 is 15%. Check it with hostLines[].vends on
GET /api/v1/pnl: a host line showing vends: 0 against a cabinet that has
sold is this ordering, not a quiet month.
If the cabinet genuinely has no host — your own retail space, your warehouse, a unit you are burning in — say so, rather than leaving the question open:
curl -s -X PATCH -H "X-API-Key: $KIOSKX_KEY" -H "Content-Type: application/json" \
"$KIOSKX_API_BASE/api/v1/machines/VM-2026-0001" \
-d '{"hostArrangement": "none", "hostArrangementReason": "Our own warehouse floor"}'
Read the hostLink block back on the machine to check which state you are in:
hostLink.state / action |
What it means |
|---|---|
unlinked / link_venue |
Nothing pays a host. Acceptance is refused in this state — go back and do 8.2. |
linked / review_deal |
A venue is linked but its deal pays nothing (revShareBps, guarantee and flat rent are all zero). The host will get a statement showing zero. Set the terms. |
linked / none |
Done: a venue, and terms that pay it. |
no_host / none |
You declared this cabinet has no host, and the declaration is on the record with your reason and the time. |
Verified end to end against a local API: POST /api/v1/crm/venues with
machineNos (new site) or PATCH /api/v1/crm/venues/{id} with
kioskMachineNo + deploymentDate (existing site) moves hostLink from
unlinked to linked with terms that pay. PATCH /api/v1/machines/{no}
with venueId still links, but it is the move path — accrual starts now.
8.3 — Then, and only then, record acceptance. Do it last, because it is the statement that you received this cabinet in working order, and it cannot be withdrawn.
Console: the Accept this machine button on the Build & factory QA card. Or:
curl -s -X POST -H "X-API-Key: $KIOSKX_KEY" -H "Content-Type: application/json" \
"$KIOSKX_API_BASE/api/v1/machines/VM-2026-0001/build/accept" \
-d '{"condition": "good", "note": "Uncrated, on venue Wi-Fi, test vend and card tap both passed"}'
condition is your assessment; put anything you noticed in the note, because
this is the record that ends any later argument about damage in transit.
Five rules apply, all of them on purpose:
- It must be signed with the operator's own account. A platform admin
attempting it gets
403 Only the operator taking delivery can accept a machine— the point of the record is that the party taking delivery said so (app/routes/machine_build.py,post_accept). - The factory must have signed first, or you get
409 Nothing to accept yet: this machine has no factory sign-off on file(app/machine_build.py,operator_acceptance). This is why §1 tells you to check. - Something must say who gets paid for the cabinet, or you get
409 Nothing is set up to pay a host for …naming both ways out (app/routes/machine_build.py,_require_host_answered). Either a venue lists the serial or you have declared it has no host — §8.2. This is the one rule that exists because the runbook used to skip it: a cabinet accepted whilehostLink.stateisunlinkedsells from that day and its host is never paid, with nothing failing at install time to tell you. - It can only be recorded once — a second attempt names who accepted it and
when (
app/machine_build.py,operator_acceptance). - It cannot be undone. There is no route that withdraws an acceptance.
Verified end to end against a local API: acceptance on a cabinet whose
hostLink.state is unlinked is refused with the 409 above; linking a venue and
then accepting moves the record to state: "accepted" with both signatures on
file. Declaring hostArrangement: "none" satisfies the same rule.
9. Troubleshooting
Each row starts from what you can actually see, and gives the one check that separates it from the thing it looks like.
The cabinet never appears in the console at all
You see: no machine record, and nothing new in the machines list after several minutes powered on and on the venue network.
Work down this list; each check rules out one layer.
- Bootstrap is unavailable. A fleet-secret request returns
503 ZHZN gateway disabled (no device secret configured)when the cloud has no fleet secret set (app/routes/zhzn.py,_device). The platform must configure bootstrap for a fresh unit; an already valid per-device key is checked first and can authenticate without that shared secret. - Wrong fleet secret on the cabinet.
401 Unauthorized(app/routes/zhzn.py,_device), asserted intests/test_zhzn_lifecycle.py:336-339. The env file probably still has theREPLACE_ME_WITH_THE_FLEET_SECRETplaceholder in it — inspect it privately withsudoedit /etc/zhzn-agent.env, without copying values into logs or chat. An enrolled cabinet must use its own key; do not remove the key to retry on the shared secret. Use §4.1 and the recovery procedure for a key refusal. - No serial being sent.
400 Missing equipmentNo(app/routes/zhzn.py,_device), asserted intests/test_zhzn_lifecycle.py:340-342. - The serial is not mapped, in a fleet that requires mapping.
404 Unknown device— this only happens when the cloud runs in allowlist mode (KIOSKX_ZHZN_STRICT_MAP=true), where unmapped serials are rejected rather than passing through as their own machine code (app/routes/zhzn.py,_device;app/config.py:89-91). The distinguishing check: 404 means the secret was accepted and the serial was refused, so the cabinet is fine and the cloud's serial list needs your serial added. That is deployment config, not something you can do on site. - The serial is malformed.
400 Invalid equipmentNo(app/routes/zhzn.py,_IDENT_RE). Must be[0-9A-Za-z_-], 3–64 characters. - The agent is not running.
sudo systemctl status zhzn-agentandjournalctl -u zhzn-agent -n 100 --no-pager.
It registers but never shows online
You see: the machine exists in the console but the badge stays grey, or goes green and then grey again.
The badge is driven purely by heartbeats touching the record
(app/routes/zhzn.py, heartbeat; app/store.py, Store.touch_machine), so
the machine exists because some call succeeded, and the badge is stale because
the heartbeat is not repeating. Three causes, distinguished by what else works:
- The heartbeat loop is not running but registration did. The agent may have
crashed after boot. Check
journalctl -u zhzn-agent. - Intermittent link. Check
signalQualityonGET /api/v1/machines/{no}/network(§4).fairorpooron Wi-Fi is very likely the answer, and the fix is physical. - A captive portal. See below — this is the one that produces "registered once, then nothing", because the portal's grace window let one call through.
No network, or a captive portal at the venue
You see: the Wi-Fi provisioning page reports reverted or failed, or the
cabinet associates and still never appears in the console.
A captive portal is the specific case that defeats a naive check, and this
system is built for it: the cabinet requires reachability, not association —
it proves it can reach the cloud before it keeps a network, and reverts if it
cannot (agent/network.py:830-842). So a portal produces reverted, not a
silently broken cabinet.
The distinguishing check: join the same Wi-Fi on your phone and open any website. If a login page appears, it is a portal. A vending cabinet cannot complete one, so ask the venue for a network with no portal — a MAC-allowlisted SSID, a device-registration exemption, or a separate IoT network. Do not spend time on the cabinet; there is nothing wrong with it.
If the venue has no usable Wi-Fi, the cabinet may be able to ride LTE. The
link field on the network read reports which it is on
(app/machine_network.py:407-408), but LTE APN provisioning is reported and
displayed, not settable — see
Changing a machine's Wi-Fi, "Deliberately out of
scope".
The service code does not work
You see: the cabinet says the service code is incorrect.
- You are reading the code for a different cabinet. The code is per-machine:
knowing one cabinet's code tells you nothing about the one beside it
(
app/machine_network.py:181-185). Re-check the serial. - You are locked out. Five wrong attempts stop the screen answering for five
minutes (
agent/network.py:114-115). The message is identical to a wrong code on purpose. The distinguishing check: wait five minutes and try once, carefully. - The fleet secret on the cabinet does not match the cloud's. Both sides
derive the code from the fleet secret (
app/machine_network.py:161-171,agent/network.py:124-151), so a mismatch makes every code wrong forever. This can also cause401on fleet-secret registration. Successful cloud key authentication does not prove that the separate local setup secret matches.
The provisioning page refuses the token
You see: the page returns a refusal instead of applying your credentials.
The reason is one word, and each word means something different
(agent/network.py:163-197):
| Refusal | Meaning | What to do |
|---|---|---|
expired |
More than 10 minutes since the token was minted | Re-enter the service code for a fresh one |
already-used |
Single use; this token has been redeemed | Re-enter the service code |
machine-mismatch |
This token was minted by a different cabinet | You scanned the wrong cabinet's QR. Common with two cabinets in one venue |
bad-signature |
The token is not one we issued | Do not retype tokens by hand; use the QR |
not-yet-valid |
The token claims to be from the future | Clock skew. See below |
Clock skew
You see: not-yet-valid when unlocking, or expired immediately on a token
you just minted; or console sign-in tokens rejected.
Provisioning tokens carry the time they were issued and are checked against the
verifier's clock. A token more than 60 seconds in the future is not-yet-valid,
and one more than 600 seconds old is expired
(app/machine_network.py:261-265, agent/network.py:187-189). A cabinet whose
clock is minutes off will therefore refuse tokens the cloud considers perfectly
fresh, and vice versa.
The distinguishing check: compare the clocks directly.
date -u # your laptop
sudo ssh <cabinet> date -u # or read it on the cabinet's screen
More than a minute or two apart is the cause. Fix the cabinet's time (NTP, or the host's time setting) rather than retrying the token. This is worth checking early on a cabinet that has been crated for months with a flat RTC battery.
The same skew is why the screen may show no clock in the top-right corner.
The gateway checks the cabinet's reported time against its own on every beat and
withholds the clock when they disagree, because a wrong time shown confidently
is worse for a shopper than none. Correct the cabinet's clock and it reappears
on the next beat — no reinstall, no restart. If the clock stays blank on a
cabinet whose time is right, its venue zone could not be established: record it
with PUT /api/v1/machines/{machineNo} {"timeZone": "America/Chicago"}. See
the cabinet clock.
Product does not drop
You see: the queued dispense is accepted by the cloud, and nothing moves.
no_ackfrom the board. The agent got no acknowledgement at all. Three causes, and they are indistinguishable from the symptom alone: wrong baud (must be 9600 —agent/config.py:221), TX/RX swapped, or the wrongZHZN_MODULEfor the board type, since Y-lift frames carry an XOR check byte and spring-board frames do not (agent/config.py:222,:241-243). The distinguishing check: if the factory'svmcacceptance test passed and nothing has been rewired, it is not the module setting — look for a cable disturbed in transit.- A board error code. The board reports a specific failure —
0x07is "no shipment", for example — and it is forwarded as a failed outcome with the code attached (tests/test_zhzn_lifecycle.py:271-281). A real code means the bus is fine and the mechanism is not: a jam, an empty lane, or lost Y-lift calibration. - The wrong lane fires.
columnsPerRowdoes not match the physical grid. See §5.
The vend worked and stock did not move
You see: a vend drops product, the cabinet reports it, and nothing in the console changes. Stock stays where it was, and every earlier rung of §7 passes. This reads exactly like a successful install that failed at the last step, which is why it is here: the product did drop, so nothing on the rest of this page matches, and a careful technician goes through the MDB wiring, the reader profile and the Core settings looking for a fault that is not in the building.
Check the invented-order case first, because it is the common one and it is not
a fault. Stock and orders belong to an order. A dispense whose orderId is a
label somebody made up — install-test-1, test, a lane number — has no order
behind it, so there is nothing for the report to land on and nothing moves. This
is §6's test vend by design. It is only a problem if you expected otherwise.
So the question is whether the vend named a real Kiosk-X order:
| Where the vend came from | Did it carry a real order? | What to do |
|---|---|---|
§6's POST /zhzn/dispense with "orderId": "install-test-1" |
No | Nothing. Working as intended — see §6, "Stock does not move on this test". Restock the lane and move on |
A card tap (§7), a phone or web purchase, or a dispense you queued against an orderNumber from /zhzn/createOrder |
Yes | A real settlement fault. Escalate — see below |
When it was a real order. Get the order's own account of itself before you
escalate; it says which half stalled. orderNo is the orderNumber from the tap
or from createOrder. Have the platform inspect
GET /zhzn/orderStatus?orderNo=<the order number> through the cabinet's
authenticated agent session. An enrolled cabinet requires its own x-device-key
and the agent's witness handling; the fleet-secret fallback is refused. Do not
copy a running cabinet's key into a second service client or a support chat.
status |
What it means | Who owns it |
|---|---|---|
vended |
The order did close and stock did move. Somebody was reading a stale console, the wrong cabinet, or the wrong aisle | Nobody — re-read the inventory call in §5 |
paid |
The money landed and the vend never got recorded against the order. The report did not reach the order | Platform. Escalate with the order number and the dispenseId |
pending |
No payment was ever captured for this order, so nothing was owed a vend. If a product still came out, it was dispensed outside the order — a manager-menu test, or a dispense queued by hand | Check where the vend came from before escalating |
dispenseFailed: true or needsRefund: true in the body |
The cloud already knows the vend failed and has flagged it | Platform, and it is already visible on their side |
Verified against a local API on 2026-08-31, on one cabinet and one aisle: a
dispense carrying install-test-1 left the aisle at 10 with {"forwarded":
true} on the report; the identical dispense carrying a real orderNumber took
it to 9 and left the order vended.
The card reader does not activate
You see: the reader never shows the amount; taps do nothing.
Read nayax.cardPath.status (§7). reader_never_authorized with
failureClass: mdb_session_never_opened means the MDB session never opened
(app/store.py, Store._compute_nayax_card_path), which narrows it to two
things:
- The reader is not on the bus. It is powered from MDB and must be on cashless device #1. Being "Online" in Nayax proves the cellular modem only.
- The reader is commissioned for the wrong dialect. A reader profiled for
MDB L3 Always Idleignores the controller's cashless arm entirely.
The check that separates "the reader is dead" from "the reader is being spoken
to in a dialect this controller does not use" is to take the controller out of
the picture: scripts/nayax_mdb_bench.py drives the reader directly through an
MDB-RS232 adapter, and whether the very first SETUP answers is the whole answer.
Full runbook: Activating a Nayax reader.
The software version does not match the firmware
You see: the cabinet works but a version somewhere looks wrong, or the console's "Firmware now" is empty.
- An empty "Firmware now" or "Agent software now" is not normal, and it is not
nothing. A ZHZN cabinet reports its own controller firmware and agent
version on registration and on every heartbeat, so once it has called home
both rows carry live values with the time they were reported (§1). A row that
is still Not reported after the cabinet is online means the agent build on
that cabinet is too old to send that version — which is precisely the cabinet
most in need of an upgrade, and the one OTA can reason about least, because
the release ladder compares against a version it has not been told. Do not
close the job on it: note the serial and have the agent upgraded (§10 covers a
replaced head unit; the release side is
Upgrading the agent over the air). Verified against a
local API: a cabinet registering with
vmcVersionandagentVersionhas both on the machine record immediately, withvmcReportedAtandagentReportedAt, and a heartbeat carrying newer values replaces them. - On a Reyeah cabinet, an offer is only made for a higher version. Version
comparison gates the offer, so a rollback must carry a higher version string
— Android will not downgrade a
versionCodewithout a reinstall anyway. Re-tag the old build (docs/apk-rollouts.md, "Operator-app rollback"). - An OTA that refuses to install is usually working correctly. The offer
must be HTTPS and, when a digest is published, the artifact must match it
before installing (
app/routes/rollouts.py:86-92,app/routes/device.py:1379-1389). On a ZHZN cabinet the bar is higher again: the release record has to carry a signature that verifies against the public key on the box, and the artifact URL has to sit under the prefix that cabinet was installed with, both checked before a single byte is downloaded (agent/ota.pycheck_offer). A refused install means the guard did its job. - A ZHZN cabinet can also refuse an offer because it is too far behind. A
release may declare a
minFrom— the lowest version it will install over — and the cabinet compares that against its own build after verifying the signature over it. A cabinet below the floor keeps vending on what it has and reports the refusal on its heartbeat, where it shows in the fleet view asblockedrather thandeferring:deferringmeans the venue is too busy and ends by itself,blockednever does. There is nothing to do on the machine — the fix is publishing the rung it can climb to (docs/zhzn-factory-install.md§2.5). - A cabinet that quietly went back a version did not lose its upgrade — it
rejected it. A freshly-swapped agent has to reach the cloud within five
minutes, and has three starts to do it in. If it cannot, the boot guard
restores the previous tree and the cabinet carries on selling. You will see it
as the reported
software.agentVersiongoing back down; the reason is injournalctl -u zhzn-agent | grep ota. Do not reinstall by hand — pin the cabinet and tell whoever owns the release. - Never treat a controller-firmware version report as failable. The cabinet
reads a failure from that endpoint as "this controller needs a firmware
update", and a VMC flash is the one operation on this hardware with no
rollback (
app/routes/device.py:1414-1425). If you are asked to flash a VMC, get it confirmed in writing.
10. Reinstalls, RMAs and replaced boards
This is where field installs go wrong, so read it before you touch a cabinet that has already been working.
Reinstalling the software on a cabinet that already has an identity
Nothing breaks, and it does not re-register. Identity is persisted on disk,
and that is proven: the lifecycle test boots the same cabinet a second time and
asserts the identity source is persisted, not bootstrap
(tests/test_zhzn_lifecycle.py:216-221).
Where it lives, concretely:
| File | Holds | Mode |
|---|---|---|
/var/lib/zhzn-agent/identity.json |
the machine code, the serial, the cached config | 0600 (agent/provision.py:56-63) |
/var/lib/zhzn-agent/device.key |
this cabinet's own gateway credential, keyId:deviceKey. Cannot be re-issued on request — see below |
0600 (agent/device_key.py) |
/var/lib/zhzn-agent/networks.json |
remembered venue networks including passphrases | 0600 (agent/network.py:655-667) |
/var/lib/zhzn-agent/serial |
the generated fallback serial, if one was ever derived | — |
/var/lib/zhzn-agent/reports.jsonl |
undelivered dispense reports | — |
So the rule is simple: reinstall the software, keep /var/lib/zhzn-agent.
The agent reads identity from there and comes up as the same machine with the
same venue Wi-Fi.
What you must not do is wipe the state directory casually. Wiping it costs
you four things at once: the cabinet re-registers from scratch, it forgets every
venue network it knew (so you are back to §3 with a machine that has no
fallback), any undelivered dispense reports in the spool are lost — those are
vends that happened and would never be accounted for — and it loses
device.key, which the gateway will not re-issue: a second enrolment for one
identity is what a clone looks like from the cloud's side, so the cabinet gets a
409 and cannot talk to the gateway at all.
If device.key is gone, in this order:
- Look for the rescue copy the factory should have left outside the state
directory, at
/etc/zhzn-agent-device.key, orZHZN_DEVICE_CREDENTIALin/etc/zhzn-agent.env(agent/config.py:66-74). Restoring either one is the whole fix and needs nobody at the cloud end. - If there is no rescue copy, this needs an admin:
POST /api/v1/machines/{no}/identity/resetclears the credential, with a named signer and a reason, and the cabinet enrols itself again on its next beat.
There is nothing you can do on site to recover a lost credential without one of those two, which is the reason the rescue file exists.
Also note reports.jsonl when decommissioning: bring the cabinet online and let
the spool drain before taking it apart, or the last sales it made vanish. The
lifecycle test shows the spool holding a vend through an outage and draining it
exactly once afterwards (tests/test_zhzn_lifecycle.py:284-302).
An RMA, or a replaced control board: same serial, identity gone
This is the common case — the head unit or the control board is swapped and the cabinet keeps its serial and its plate.
A replacement board arrives with no state directory, so it holds neither the machine code nor a credential. The machine code it can get back by itself, from the serial. The credential it cannot: the gateway refuses a second enrolment for an identity that already holds one, so a swapped board is refused until the platform clears the old credential.
One platform-side step is therefore required first, and it does both jobs at once. The cloud remembers which physical device answers for a machine record, and refuses a different device presenting the same serial — that check is what stops two cabinets quietly sharing one machine's sales, stock and dispense queue. A replacement board is a different device, so it is refused until an admin clears the binding:
curl -s -X POST -H "X-API-Key: $KIOSKX_ADMIN_KEY" \
-H "Content-Type: application/json" \
"$KIOSKX_API_BASE/api/v1/machines/VM-2026-0001/identity/reset" \
-d '{"reason": "RMA 4471, control board replaced on site"}'
Admin-only, and the reason is mandatory: this is the operation that lets a
different physical cabinet inherit a machine's sales history and its operator, so
an unattributed one would be indistinguishable from an attacker with a leaked
fleet secret taking over a machine. Who did it and why goes on the record and in
the platform audit log (app/routes/machine_identity.py:89-110). The 409 the
board gets names this endpoint, so you do not have to remember it.
The reset clears the device binding, clears the machine's credential and arms
the cabinet for one enrolment, which together is what lets the replacement
board come up as this cabinet. All three in one call is deliberate: clearing the
credential without arming would leave the cabinet in precisely the state that now
needs an arm, so the board would swap the 409 for a 428 and a technician
would meet a refusal on a cabinet an admin had just fixed.
The arm the reset leaves is the ordinary one — that cabinet, 30 minutes, one use
— so if the board is not ready to go within the window, ask for another arm
rather than another reset. The response carries it under enrolmentArmed, and
the fleet view shows the cabinet as armed until the new board spends it.
Ask Kiosk-X to run the reset for every board swap. A cabinet whose old agent
build never reported a deviceId would be let past the binding check, but it
would still be refused enrolment for the credential the old board held.
Then:
- Put the software on the replacement board (factory runbook §2.2).
- Set the same
ZHZN_SERIAL_NUMBERas the plate serial. This is the whole trick: the serial is what resolves to the machine code, so a matching serial lands on the existing machine record with its history, its planogram and its operator intact (app/routes/zhzn.py,_device). - Set
ZHZN_CLOUD=kioskxand the fleet secret. - Power on. It registers and the cloud recognises the machine
(
app/store.py,Store.auto_provision_machinereturns the existing record rather than re-provisioning). - Do not call
POST /api/v1/machines/register— it is already claimed. - Redo §3 (venue Wi-Fi) and §5 (planogram check), because both lived on the old board's disk.
Do not give the replacement a new serial to "start clean". A new serial creates a second machine record, splits the cabinet's sales history across two records, and leaves the old record heartbeating never again and looking dead.
Two things this platform cannot currently do for you, so plan around them:
- The build record stays with the serial, not the board. A replaced control
board means
boardRevisionandshippedFirmwareon the build record now describe hardware that is no longer in the cabinet, and results cannot be amended after the factory sign-off (app/machine_build.py,record_tests). Raise a field service record against the machine instead, as the refusal message says. - A cabinet on the
directcloud mode is different. There, the agent logs "already provisioned on the backend but no local identity exists; an operator must re-issue credentials" and stops (agent/provision.py:110-115) — and there is no route that re-issues them. If you meet this, escalate rather than retrying.
11. What is not yet automated
Honest gaps you will notice on a real install.
- There is no console flow for the on-site Wi-Fi path end to end. The
console can unlock, reveal the service code and render the setup QR
(
app/routes/machine_network.py:119-165), but the credential entry happens on a page the cabinet serves, which is by design — the cloud is not in that hop at all. Nothing in the console tells you whether the on-site provisioning succeeded; you learn that from the page on your phone and then from the machine coming online. - An agent upgrade still needs an admin to aim it. CI publishes a signed
release, but publishing offers it to nobody: someone has to set a rollout
(
app/routes/agent_releases.py). There is no automatic promotion from a green canary to the fleet, deliberately — nothing should widen a rollout at 3am without a person looking at the coverage view first. - Adding a serial to the cloud's allowlist is deployment config, not an API
call (
app/config.py:68,91). If a fleet runs in strict-map mode, a new serial cannot be brought online from the field. - Acceptance cannot be withdrawn, and there is no partial or conditional acceptance. If you accept a cabinet and then find damage, the record will say you accepted it in good condition. Inspect before you sign.
- LTE and Ethernet are reported, not settable
(
docs/machine-wifi-provisioning.md). - A dispense only moves the books if it names a Kiosk-X order. Stock and the
order list are properties of an order, and
POST /zhzn/dispensewill happily queue a vend against anorderIdthat is just a label — which is what §6's test vend does. Product comes out; nothing else changes. There is no validation that refuses an unknown order id, so nothing warns you at the time. A real sale (a card tap, a phone or web purchase, or a dispense queued against anorderNumberfrom/zhzn/createOrder) does close the order and does decrement the aisle (app/routes/zhzn.pyreport,_settle_local_order). The practical consequence in the field is small but real: every hand-queued test vend leaves the count one higher than the shelf, so restock the lane you tested. - A cabinet's enrolment is not confirmed for you. Nothing on the machine record or in the console flags a cabinet still on the shared fleet secret; §4.1 is a check you have to make.
Nobody can enrol a cabinet out from under you any more — that needs an arm,
and an arm names whoever made it. What a fleet-secret cabinet is still
exposed to is quieter and does not touch enrolment at all: whoever holds the
shared secret can read that cabinet's planogram and open real orders on its
operator's books, for as long as it has not enrolled. Getting the cabinet
enrolled is what ends it, per cabinet, for good. reachableOnFleetSecret on
the §4.1 view is how many machines are still in that state; the residual and
why it is not simply refused are in
the design.
Also: Per-cabinet device credentials for the design behind §4.1.
Previous: First install at the factory · Also: Changing a machine's Wi-Fi · Activating a Nayax reader · Vending manufacturers manual