by Intelliverse X

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

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:

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:

  1. 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).
  2. 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).
  3. The agent writes the machine code to /var/lib/zhzn-agent/identity.json with mode 0600 (agent/provision.py:56-63, :192-196).
  4. If it holds no credential of its own, it enrols: POST /zhzn/enrol trades 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.
  5. 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

  1. 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).
  2. 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).
  3. 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.
  4. 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:

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:

  1. 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.
  2. 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.
  3. The product drops.
  4. 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 / vended and 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.jsonl reads 0, and journalctl -u zhzn-agent shows the aisle and success.
  5. The payment carries a real Nayax transactionId. An id starting nyx_emu_ is a cloud-side emulation, not a real capture.
  6. 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:

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.

  1. 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.
  2. Wrong fleet secret on the cabinet. 401 Unauthorized (app/routes/zhzn.py, _device), asserted in tests/test_zhzn_lifecycle.py:336-339. The env file probably still has the REPLACE_ME_WITH_THE_FLEET_SECRET placeholder in it — inspect it privately with sudoedit /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.
  3. No serial being sent. 400 Missing equipmentNo (app/routes/zhzn.py, _device), asserted in tests/test_zhzn_lifecycle.py:340-342.
  4. 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.
  5. The serial is malformed. 400 Invalid equipmentNo (app/routes/zhzn.py, _IDENT_RE). Must be [0-9A-Za-z_-], 3–64 characters.
  6. The agent is not running. sudo systemctl status zhzn-agent and journalctl -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:

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.

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.

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 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.


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:

  1. Look for the rescue copy the factory should have left outside the state directory, at /etc/zhzn-agent-device.key, or ZHZN_DEVICE_CREDENTIAL in /etc/zhzn-agent.env (agent/config.py:66-74). Restoring either one is the whole fix and needs nobody at the cloud end.
  2. If there is no rescue copy, this needs an admin: POST /api/v1/machines/{no}/identity/reset clears 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:

  1. Put the software on the replacement board (factory runbook §2.2).
  2. Set the same ZHZN_SERIAL_NUMBER as 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).
  3. Set ZHZN_CLOUD=kioskx and the fleet secret.
  4. Power on. It registers and the cloud recognises the machine (app/store.py, Store.auto_provision_machine returns the existing record rather than re-provisioning).
  5. Do not call POST /api/v1/machines/register — it is already claimed.
  6. 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:


11. What is not yet automated

Honest gaps you will notice on a real install.

  1. 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.
  2. 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.
  3. 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.
  4. 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.
  5. LTE and Ethernet are reported, not settable (docs/machine-wifi-provisioning.md).
  6. 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/dispense will happily queue a vend against an orderId that 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 an orderNumber from /zhzn/createOrder) does close the order and does decrement the aisle (app/routes/zhzn.py report, _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.
  7. 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