Payments & Nayax
How Kiosk-X handles cashless payments on the machines, how each transaction flows into the operator app, and — most importantly — how the money always settles to the correct Nayax account.
The hardware picture
Every kiosk carries a Nayax VPOS Touch card reader wired to the vending machine controller (VMC) over MDB, the standard vending peripheral bus. The Android kiosk app never touches card data:
customer tap ──> Nayax VPOS reader ──MDB──> VMC vends the product
│
└──cellular──> Nayax cloud (authorization + settlement)
- The reader authorizes the tap with the card networks via Nayax's cloud.
- The VMC vends; the reader then captures the exact vended amount (so a failed vend auto-voids the authorization — no charge, no refund ticket).
- Cash goes through the coin/bill mech and never involves Nayax.
Routing to the correct Nayax account
Each reader has a Nayax terminal ID (Nayax calls it the Device Number). In Nayax Core, every terminal is registered under exactly one merchant account — the machine operator's account, never the platform's. That binding is mirrored on the machine record in this API:
GET /api/v1/machines/{machineNo} → "nayax": {
"terminalId": "NYX903013700011",
"deviceSerial": "VPOS29A81C64F1",
"model": "VPOS Touch",
"connection": "MDB",
"status": "active",
"merchantAccountId": "nyx-act-63f2a9c01b",
"merchantAccountName": "Superior Vending Co."
}
Because settlement is decided by the terminal→account binding inside
Nayax, not by anything the kiosk software sends, a sale on machine
866903013700011 can only ever pay out to Superior Vending Co.'s Nayax
account. Kiosk-X mirrors the binding so the operator app can prove it:
every card/wallet order carries a payment block naming the settlement
account.
See the full fleet mapping at
GET /api/v1/payments/nayax/terminals — one row
per machine: reader serial, terminal ID, and the merchant account it feeds.
Binding your reader (operator onboarding)
Registration provisions every machine with a provisional placeholder terminal. Until you replace it with the reader's real Nayax Device Number, settlement webhooks cannot resolve to your machine. Binding is one call:
curl -s -X POST https://api.kiosk-x.ai/api/v1/machines/866903013700011/nayax \
-H "Authorization: Bearer $KIOSKX_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"terminalId": "NYX903013700011",
"deviceSerial": "VPOS29A81C64F1",
"model": "VPOS Touch",
"merchantAccountId": "nyx-act-63f2a9c01b",
"merchantAccountName": "Superior Vending Co."
}'
terminalIdis the Device Number printed on the reader's label and shown in Nayax Core under Operations → Devices. It is the only required field and must match what Nayax sends in webhooks exactly.- The key needs
machines:write; operators can only bind readers to machines they own. Binding a terminal ID that is already wired to another machine returns409— terminal IDs are globally unique. - Verify with
GET /api/v1/machines/{machineNo}/nayax— the binding is live whenprovisionalis gone andstatusisactive.
Once bound, money routing is closed: the webhook resolves your machine by exact terminal match, and settlement force-routes from this binding — a webhook payload can never claim a different account.
The "Credit card" tile, end to end
What actually happens on the kiosk screen when a customer taps the card
tile (verified against the shipped firmware, PayingPOP):
1. tap tile app POSTs /apk/createOrder with payType=3 → cloud answers
with the orderNumber for the receipt trail
2. arm reader app writes the price (cents) to the VMC over serial; the VMC
opens an MDB cashless session on the Nayax reader, screen
shows "Please tap your card to pay"
3. card tap Nayax authorizes via cellular; VMC reports HavePaid on serial
4. vend app drives the aisle motor via the VMC, then POSTs
/apk/ordersUpdate marking the order shipped
5. settle Nayax cloud settles to the terminal's merchant account and
calls our webhook (step 3 of the transaction flow above)
The tile only renders when payConfig.isCard is 1 in getEquipment. The
cloud pins the dead isBill/isCoin tiles off (no cabinet in this fleet has a
bill validator or coin mech) and isPaypal on (Scan & Pay needs nothing of the
cabinet), but isCard is decided per cabinet — see
card_tile_visibility in app/routes/device.py:
| What we know about the reader | isCard |
Why |
|---|---|---|
| The cabinet swept its MDB bus and found no card reader, or found one present and disabled | 0 |
Positive evidence a card cannot be taken. Step 2 above would arm nothing, and the shopper's tap dies of a timeout that is recorded nowhere. Same evidence the nayax health check renders as "nothing here can take a card" / "it will refuse cards". |
| No Nayax terminal is bound to the machine at all | 0 |
Registration mints a provisional placeholder for every machine it creates, so an absent binding is not an unasked question — it is a QR-only cabinet, which is how the health check already reads it. |
| The cabinet has never reported an MDB sweep | 1 |
Not evidence of anything. That telemetry rides the ZHZN heartbeat, so the whole Reyeah fleet reports nothing — and those readers work. |
The binding is still provisional |
1 |
Provisional breaks the settlement record, not the payment: the reader authorises over its own cellular link and the VMC vends over MDB before this cloud hears anything. Hiding the tile would stop money without recording a single extra sale. |
| The cabinet reported a reader answering the MDB poll in a live state | 1 |
The strongest evidence short of money moving. |
The rule may only ever withdraw the tile. Nothing here can advertise card payment on a cabinet that was not already advertising it, and serving the config never touches a reader binding.
Kiosk-X does not authorize the VPOS. Amount goes VMC serial
(FF00551106 + cents) → MDB. If Nayax Core is MDB L3 Always Idle
(and especially Type Cigarettes), the reader stays on its idle/QR home
(green LED, no amount) while Android waits for HavePaid, then cancels.
That is nayax.cardPath.status = reader_never_authorized on
GET /api/v1/machines/{machineNo}. Fix in Core: MDB Level 1
Pre-Selection, type off Cigarettes, Default Credit above the highest SKU,
Actions → Update Queue, and confirm MDB on cashless device #1 — not a
re-bind and not a second cloud charge API.
How a transaction reaches the operator app
1. tap Nayax reader authorizes, VMC vends, reader captures amount
2. settle Nayax cloud settles the txn to the terminal's merchant account
3. notify Nayax calls POST /api/v1/payments/nayax/webhook (HMAC-signed)
4. record Kiosk-X resolves the machine by terminalId and writes a paid
order with the full payment detail
5. display operator app polls GET /api/v1/orders + /api/v1/payments and
shows the sale, card brand/last4, and settlement account
The webhook is authenticated with an X-Nayax-Signature header — a hex
HMAC-SHA256 of the raw body using the shared webhook secret
(KIOSKX_NAYAX_WEBHOOK_SECRET). Anything unsigned or mis-signed is rejected
with 401; an unset/placeholder secret fails closed with 503. An unknown
terminal ID is rejected with 404, and a Device Number claimed by more than
one machine with 409 — a transaction can never be recorded against the
wrong fleet, or against a guess between two candidates.
Settlements are idempotent on the Nayax transactionId; a payload that
arrives without one is deduplicated on a digest of the signed body, so a
redelivery can never book twice or vend twice.
BODY='{"terminalId":"NYX903013700011","amount":5.99,"method":"card","cardBrand":"visa","last4":"4242","aisleNo":1}'
SIG=$(printf '%s' "$BODY" | openssl dgst -sha256 -hmac "kiosk-x-nayax-sandbox-secret" | awk '{print $2}')
curl -s -X POST https://api.kiosk-x.ai/api/v1/payments/nayax/webhook \
-H "Content-Type: application/json" -H "X-Nayax-Signature: $SIG" -d "$BODY"
The response confirms the routing:
{
"code": 200, "message": "success",
"data": {
"orderNumber": "20260811000000000412",
"machineNo": "866903013700011",
"settlementAccountId": "nyx-act-63f2a9c01b",
"settlementAccountName": "Superior Vending Co."
}
}
Note the server force-routes settlement from its own terminal binding — the payload cannot claim a different account.
What the operator sees
Every order now carries a payment block. Card and wallet sales are
Nayax-acquired; cash shows processor: "cash"; free loyalty drops show
processor: "reward":
{
"orderNumber": "20260810000000000101",
"productName": "Summit Cold Brew Coffee",
"paidPrice": 5.99,
"payType": 2,
"payment": {
"processor": "nayax",
"method": "card",
"terminalId": "NYX903013700011",
"transactionId": "nyx_9f2c1a7d3e5b0846",
"settlementAccountId": "nyx-act-63f2a9c01b",
"settlementAccountName": "Superior Vending Co.",
"cardBrand": "visa",
"last4": "4242",
"authCode": "A1B2C3",
"amount": 5.99,
"currency": "USD",
"settlementStatus": "settled"
}
}
Three read endpoints power the operator app's money views (all owner-scoped — an operator only ever sees their own terminals and settlements):
| Endpoint | What it answers |
|---|---|
GET /api/v1/payments |
Every card/wallet transaction with terminal, brand, last4, and settlement account |
GET /api/v1/payments/summary |
Totals grouped by Nayax account — reconcile line-by-line against the Nayax Core payout report |
GET /api/v1/payments/nayax/terminals |
The machine → reader → merchant account map for the whole fleet |
What migration means for payments
Nayax acquiring is independent of the vending cloud: the reader talks MDB to the VMC and cellular to Nayax. Migrating a board onto the Kiosk-X cloud changes zero about who gets paid — the Nayax terminal keeps settling to whichever Nayax actor owns it in Core. That is the operator's own account only when the operator commissioned the reader. Read the next section before assuming it is yours.
What migration adds is where the transaction record flows:
| Before (vendor cloud) | Kiosk-X cloud | |
|---|---|---|
| Card authorization + settlement | Nayax (terminal → its Core actor) | Nayax (terminal → its Core actor) — unchanged |
| Transaction record | vendor backend only | payments/nayax/webhook → orders + payments APIs → Kiosk-X operator app |
| Reconciliation | Nayax Core dashboard only | GET /api/v1/payments/summary matches Nayax Core payouts |
What migration cannot move: the reader's Core registration
Migration off the vendor vending cloud is complete — provisioning, grid, orders, faults, OTA, and Scan & Pay all move with the APK. Migration of the card reader is not ours to do, and on a second-hand cabinet it is usually not done:
| Lives in | Moves with the APK? | Who can change it |
|---|---|---|
| Serial, grid, orders, faults, OTA | Yes | Kiosk-X |
| Scan & Pay (Stripe QR) | Yes — settles to the Kiosk-X operator | Kiosk-X |
| Reader's MDB profile (level, Always Idle, machine type, Default Credit) | No | The terminal's Nayax Core actor |
| Card settlement destination | No | The terminal's Nayax Core actor |
So a migrated board can be fully live on Kiosk-X while its VPOS still belongs to the previous operator's Nayax organisation, commissioned for their old machine type. Two consequences, both real:
- The reader may never prompt. A profile like
MDB L3 Always Idleignores the Reyeah VMC's L1-style cashless arm, so taps die asnayax.cardPath.status = reader_never_authorizedand only the terminal's actor can re-profile it (Actions → Update Queue). - A successful tap may pay the previous owner. Kiosk-X mirrors a
settlement account from the machine's owner so the operator app can show
one; it cannot read Core's actor. When
nayax.merchantAccountNamewas never confirmed against Core, treat it as a label, not proof of deposit — verify the first real capture lands in your own Nayax payout report.
Step 4 of migration: migrating the reader itself
Treat this as a real migration step, not a footnote. Steps 1–3 (install, claim, verify) are the vending cloud; this one is Nayax and MDB. The sequenced version, with the zero-cost tests first and the acceptance criteria, is Activating a Nayax reader:
| What | Where | |
|---|---|---|
| 4a | Device moved into your Nayax actor | Nayax support (device move between actors) — the only step that migrates profile control and settlement |
| 4b | Re-commissioned for this cabinet: machine type off the old category, MDB Level 1 Pre-Selection, Transaction Start Method Product, Default Credit above the dearest SKU, then Actions → Update Queue | Nayax Core |
| 4c | MDB harness VMC → VPOS on cashless device #1, reader powered from MDB. Cellular "Online" proves the modem, not the bus | Site visit |
| 4d | POST /api/v1/machines/{machineNo}/nayax with the Device Number (leading zero included), never the short Machine ID |
Kiosk-X |
| 4e | The ladder below, one real tap | Site visit |
Skipping 4a: the seller keeps the card money
4a is the slow step — Nayax support has to move the device between actors, with both actors agreeing. 4b alone gets the reader working, because whoever holds the device can re-profile it without moving it. That is a legitimate choice (cards start working today, settlement is sorted later), but it means every capture lands in their Core, and Kiosk-X must not pretend otherwise: it mirrors a settlement label from the machine's owner and cannot read Core.
Say so on the binding:
POST /api/v1/machines/{machineNo}/nayax
{ "terminalId": "0434332923153297",
"coreActorName": "HotBox Vending LLC",
"settlesToOperator": false }
The binding then carries no operator merchant account at all
(merchantAccountId: null, merchantAccountName = the actor), and
nayax.cardPath.settlement states plainly that captures are not this
operator's. Sales and stock still reconcile — only the deposit is elsewhere.
Re-bind with settlesToOperator: true once 4a completes, which restores the
force-routed operator account.
Settlement notifications follow the actor too, so expect no transactionId
at all unless they point their Core webhook at us. On the wait-for-HavePaid
APK that is fine: a shipped order is itself proof the VPOS authorized, because
the firmware will not vend until the VMC reports HavePaid. Kiosk-X therefore
reports cardPath.status = external_core_capture rather than
prepaid_ghost_sale for these sales — without that distinction every good tap
on the cabinet would queue for a refund this operator cannot issue. If the
actor does agree to notify us, pass nayaxWebhookExpected: true and the strict
ghost-sale alarm stays armed.
Why no API of ours can replace the profile change — but one of Nayax's can
Read Taking cards without MDB alongside this section. Everything below is true of the MDB bus and of our own endpoints, and it is the reason a Core profile change is unavoidable while the reader is a peripheral on that bus. Nayax's Spark API and Marshall SDK take it off the bus, which makes the profile argument moot rather than won.
The failing negotiation is MDB SETUP between the VMC (bus master) and the
VPOS (peripheral on cashless device #1). The VMC declares Level 1 and drives
a sessioned, product-first flow; a reader commissioned as MDB L3 Always Idle
expects the other dialect. The Android app is not on that bus at all. Its entire
vocabulary to the controller is 28 Reyeah serial commands — arm cashless with an
amount, exit, deduct, get balance, dispense state, deliver, coin return, clear,
locks, eyes, door, and VMC id/version/time/log/update. There is no passthrough,
no raw MDB write, and no cashless-level setter, so there is no field we can add
to /apk/* that changes the dialect. Our half of the handshake is already the
L1 sessioned arm, and it is sent correctly.
The one lever that does reach lower is VMC firmware: installVMC over MQTT
takes {url, version}, downloads the image, and streams it to the controller at
115200 baud, watching for successfully. In principle a VMC build whose master
also speaks L3 Always Idle would make any reader profile work. In practice this
is a last resort, and a bad trade: we have no such build (it is a Reyeah
dependency), no documented flash protocol, and no rollback — the VMC drives the
motors and locks, so a failed flash is a site visit with a replacement board,
not a retry. A Core profile change is four fields and an Update Queue,
reversible in seconds. It is not the slow path; it is the safe one.
What does replace the profile change is refusing the premise. Our QR path already
dispenses with no MDB credit at all — cloud confirms, app sends clear/deliver/vend —
so an approval obtained through Spark (our backend wakes the terminal; Nayax calls
us back) or through Marshall (our app is the VMC over RS232) reaches the same
working code. That is a different project, sized in
Taking cards without MDB, not a field on /apk/*.
The two flags that fake a working reader
Field evidence from a sister cabinet on the same Reyeah controller, and the single highest-value thing to check on any reader that has never worked:
| Core flag | Left ON | Should be |
|---|---|---|
| Ignore VMC Configuration | reader boots without the VMC's SETUP/config data, so it has no price table and cannot tie a session to a selection | OFF |
| Ignore Start State | reader skips the reset/poll handshake and sits in a generic UI | OFF |
Both are bench-test conveniences: they let a reader come up on a desk with no machine attached. Left on in production the reader looks healthy — it takes a tap, shows a friendly screen — but there is no MDB vend session behind it, so it never calls the payment network at all. Nothing appears in the bank, nothing appears in Core, and nothing appears to be wrong with the hardware.
Switching both off on that cabinet produced, immediately: the correct product and price on the reader's screen, a real authorization, and "paid" in MoMa. This is a Core setting plus an Update Queue — no wiring, no firmware, no re-commissioning.
Then the opposite failure: captured, never vended
Turning the handshake on exposes the next step. The MDB session only closes when the controller reports the vend result, and the reader waits for it (Vend Result Timeout, typically 90s). Authorizations that are never confirmed accumulate, and Nayax's safeguard then blocks the machine — "this machine could not complete its recent sales". That message means the money side is working and the delivery report is not.
Kiosk-X treats this as its own diagnosis, ranked above every dead-reader class, because it is the one that costs a shopper money:
cardPath.status: "captured_without_vend",failureClass: "vend_never_confirmed", with the offending orders listed inunconfirmedVends.- Those orders already carry
needsRefund, so they surface in the refund queue. - The Credit card tile is withdrawn after two of them, for the same reason it is withdrawn from a dead reader: being blocked by Nayax with a queue of refunds behind us is worse than sending buyers to Scan & Pay.
Note what we cannot do from here. The vend result is an MDB frame, and the app is
not on that bus — the controller owns it. Our leverage is to report the dispense
outcome promptly (/apk/ordersUpdate, which we already do), to stop selling
cards on a machine that cannot deliver, and to ask Reyeah whether this controller
build emits the vend result on its own or expects the host to close the loop with
FF0055E101. Raising the Vend Result Timeout in Core hides the symptom and
should not be the fix.
The kiosk withdraws the card tile from a proven-dead reader
The Reyeah VMC protocol cannot be asked whether a cashless device is on the
bus. The app arms it with an amount (FF00551106 + cents), polls for credit
(FF0055E104…), and there is no fault code for a silent reader — so a
mis-profiled VPOS is indistinguishable from a customer who walked away. The
buyer absorbs the difference as thirty seconds of "Please tap your card" at a
dark reader.
/apk/getEquipment therefore serves payConfig.isCard per machine instead of
as a constant. After two consecutive card sessions that opened and never saw
credit, the tile is withdrawn and buyers land on Scan & Pay, which works and
settles to the operator. Only the newest outcomes count, so a reader that
worked last week but fails every tap now still loses the tile. Recovery needs
no operator action: the next successful capture, a re-bind, or 24 hours passing
restores it for a retest.
End-to-end card verification ladder
"Card doesn't work" has six distinct causes with different owners. Walk them in order and stop at the first failure:
| # | Must happen | If it doesn't |
|---|---|---|
| 1 | The VPOS displays the amount while the kiosk counts down | MDB session never opened → 4b profile or 4c wiring. Shows as cardPath.status = reader_never_authorized |
| 2 | The tap authorizes on the reader | Nayax decline, Default Credit below price (4b), or no cellular |
| 3 | VMC reports HavePaid (FF00AAE104) and the product drops |
MDB reply not reaching the VMC. The APK logs Kiosk-X: VMC HavePaid - starting card vend when it does |
| 4 | Order goes shipped, stock decrements | /apk/ordersUpdate not landing — ours |
| 5 | Payment carries a real Nayax transactionId |
Webhook missing or terminal unbound (4d) — ours. A nyx_emu_ id is a cloud emulation, not a capture |
| 6 | The money is in your own Nayax payout report | Device still in another actor → 4a |
All six passing is what flips cardPath.status to ok.
Closing 4a takes one of these, none of which is a Kiosk-X change:
- the reader's current actor makes the profile change (and keeps the money),
- Nayax support moves the device between actors into the operator's organisation — the only path that migrates both profile control and settlement,
- swap in a VPOS commissioned under the operator's own Nayax account, or
- skip Nayax: run cards through Scan & Pay (Stripe QR), which already settles to the Kiosk-X operator and needs no Core cooperation at all.
ZHZN machines: the cloud drives the vend
On a ZHZN cabinet the reader has no MDB harness into the Android head
unit, so a tap cannot drive the motor locally. The webhook therefore books
the money and queues the physical vend on that machine's /zhzn/pending
queue (dispenseId = nayax-<transactionId>, so a replay never double-vends).
On Reyeah the VMC has already vended over MDB before the webhook fires, so
there the webhook stays ledger-only.
Two settlements are recorded but deliberately not queued for the motor:
| Situation | What happens |
|---|---|
boardInit is a known-bad state (e.g. no_ack) |
Order books as paid (never shipped), dispenseFailed + needsRefund + undispensableReason; no vend queued. Same shared guard (zhzn.board_blocked) that refuses QR checkout. |
| The cabinet's recent sales tripped the money threshold | Same booking as above. boardInit reading ok does not exempt a cabinet from this: a serial link that came up is not a product that came out, and treating a healthy probe as proof of dispense capability left 1A8B08520A50 selling through eight consecutive undelivered sales (kiosk-x#32). |
The binding is still provisional |
Order books with provisionalTerminal + needsReview; no vend queued — an unverified placeholder terminal never drives a motor. |
Refunding: who can actually move the money back
POST /api/v1/orders/{orderNumber}/refund attempts the payout first and
only books the ledger (payState 4, settlement refunded, needsRefund
cleared) once money actually moved. A failed payout returns non-200, records
refundError, and keeps needsRefund set — the order stays in the queue,
because a refund that didn't happen is still owed.
| Order paid via | What the endpoint does |
|---|---|
Scan & Pay (processor: stripe) |
Real POST /v1/refunds against the order's paymentIntent, with an Idempotency-Key derived from the order number — a double-clicked Refund button cannot double-refund. |
Nayax reader (processor: nayax) |
Refused (501) with the terminal and amount to refund. Kiosk-X holds no outbound Nayax refund credentials, so there is nothing to call. Issue it in Nayax Core; Nayax's own refund/void webhook then flips the order here (record_nayax_refund) — the loop closes without anyone faking a payout. |
| Cash | Refused (501) — the money is in the coin/bill mech. Hand it back. |
| $0 promo / comp vend | Booked immediately; nothing was ever charged. |
For anything Kiosk-X cannot pay out itself, an operator who has returned the
money elsewhere closes the loop with {"external": true, "externalReference":
"<processor ref>"}. The reference is mandatory and the flag is never implied —
it is stored as refundVia / refundReference so a payout stays traceable.
Work the queue with GET /api/v1/orders?needsRefund=true (operator-scoped;
admin keys can add operatorEmail).
Stranded charges: paid, never dispensed
If a machine drops off the network between settlement and its vend report,
that report never arrives — so none of the failure paths above fire and the
order sits at paid with no outcome at all. The reconciliation sweep is the
missing timeout: past KIOSKX_NO_VEND_TIMEOUT (default 30 min) such an order
gains dispenseFailed + needsRefund + undispensableReason:
"no-vend-report" and joins the refund queue. It runs every
KIOSKX_NO_VEND_SWEEP_INTERVAL seconds (0 disables) and on demand via
POST /api/v1/orders/reconcile-stranded (admin). It is idempotent and never
touches an order that reached a terminal state (shipped, refunded, cancelled).
The same pass then returns the money on every order carrying needsRefund,
not just the ones it flagged on that pass. Flagging alone was a defect: the flag
has five writers and one clearer, and the clearer was only reached by the
180-second decision loop, so anything flagged by another path stayed charged.
Stripe sales are refunded or voided automatically and retried until they
succeed; Nayax and cash sales cannot be paid out from here at all, so they stay
flagged and are reported as awaiting a human rather than re-attempted every
pass. GET /sandbox/refund-sweep carries returnedTotal and stillOwed next
to flaggedTotal, so "swept, nothing owed" is distinguishable from "plenty
owed, none of it returned".
Stock that left with the money: stockAtRisk
A vend can fail in a way that returns the money and drops the product. When
the board never denied running the coil — no_ack, or the decision deadline
ruling on silence — the order gains stockAtRisk, stockAtRiskReason,
stockAtRiskAisle, stockAtRiskQty and stockAtRiskAt, and
GET /api/v1/machines/{machineNo} sums them per aisle under stockAtRisk.
A board that affirms it did not vend (failed) is a genuine no-sale and is
deliberately not counted; so is no-vend-report, where nothing establishes
the deliver frame reached a live port, and so is any $0 vend, because an
operator test-dropping a product already knows where it went. The record is a
claim, never a stock level: the planogram is untouched and an operator counting
the shelf remains the authority.
And the authority now settles it. For a while nothing acted on that last
sentence: the flag was set by one path and cleared by none, so a claim stood
until it aged out of the 24h window — including on a cabinet somebody had since
opened, counted and refilled. So an aisle write that says how many units are in
the tube (POST .../restock, or a currentStock write through
PUT .../aisles/{aisleNo}) stamps countedAt, and a claim whose aisle was
counted after it was raised leaves units and reappears under
resolvedByCount. Ordering is the whole rule: a count from this morning says
nothing about a unit that may have walked this afternoon.
Three things are deliberately not counts. A sale's own decrement — that is the
platform's bookkeeping, not somebody looking at the coil, and it would let the
next ordinary purchase close the question. A reprice or a rename — neither says
how many units are in the tube. And a missing countedAt — read as "never
counted", so a claim stands rather than the whole existing backlog retiring
itself on deploy.
The order keeps its stockAtRisk either way. A vend failed with money in flight
and that happened, whatever the shelf says afterwards; what a count settles is
the open question of whether product left unrecorded, and that question is the
roll-up.
Go-live checklist for a new reader
- Bind the real Device Number —
POST /api/v1/machines/{machineNo}/nayax(machines:write, owner-only). Confirmprovisional: falseonGET /api/v1/machines/{machineNo}/nayax. - Provision
KIOSKX_NAYAX_WEBHOOK_SECRET(kiosk-x-secrets) and set the same value on the Nayax notification endpoint. With it unset the webhook fails closed with503— cards get charged and nothing is recorded. - Check machine health — the
nayaxcheck must not readPROVISIONAL, and theboardcheck must beok(a dead board means a card tap is a guaranteed refund). Anayaxcheck readingdown("nothing here can take a card") orwarn("not enabled") also means the kiosk is not rendering the card tile at all — fix the reader, and the tile comes back on the cabinet's nextgetEquipment. - First live tap: the sale appears in
GET /api/v1/paymentswith the rightsettlementAccountId, the order is shipped, and thenayaxhealth check flips took · last transaction ….
Sandbox specifics
- Two seeded Nayax merchant accounts (one per operator), four terminals
(one per machine), all deterministic across restarts. Seeded terminals are
provisionalplaceholders — they are unique per machine, but they are not real readers. - Seeded orders, kiosk vends (
/apk/createOrder), emulator purchases, andapi_dropdispenses all attach a realisticpaymentblock automatically. - There is no usable default webhook secret: an unset value, or the in-code
placeholder
kiosk-x-nayax-sandbox-secret, both make the endpoint fail closed with503. SetKIOSKX_NAYAX_WEBHOOK_SECRETto a real secret in every environment that must ingest transactions.