Taking cards without MDB: Spark and Marshall
Everything else in this repo about Nayax assumes the reader is a peripheral on the VMC's MDB bus, which makes card payments hostage to a profile only the reader's Nayax actor can change. Nayax publishes two integrations that remove that dependency by letting a host drive the terminal instead. On a migrated cabinet — one whose vending cloud is now ours but whose reader has never taken a card — these are the paths that finish the migration in software we control.
This corrects an earlier claim in payments-nayax.md that no API could substitute for the Core profile change. That was true of our APIs. It is not true of Nayax's.
The pivot: we already dispense without MDB credit
The decisive fact is in the APK we ship, not in Nayax's documentation. The QR / scan-to-pay path never touches the cashless bus:
- On
payType 4,PayingPOP.lambda$createOrder$5guardsif (i != 4)aroundprocessingSerialParams(...)— the function that sends the cashless arm (FF00551106+ price) and starts balance polling. QR skips it entirely. - Payment confirmation arrives as MQTT type
"1"carryingorderNumberandaisleNo. The app then dispenses directly:FF0055A201FF(clear),FF00554102+ aisle + qty (deliver),FF0055E102+ aisle + qty with theHavePaid_Productkey. Completion isFF00AAE10101. - No credit is ever established on the VMC, and no
HavePaid(FF00AAE104) is waited for.
So the shipped firmware already implements "someone else took the money — now vend." Any approval we can obtain by any means plugs into it. The card problem stops being an MDB problem and becomes: how do we get an approval from that reader without the bus?
Option 1 — Spark (server-to-server, recommended)
Spark is Nayax's backend API, described by Nayax as being for cases "where direct communication with the terminal, such as through Marshall, is not possible or desired." Our cloud asks Nayax to wake a specific terminal for a specific amount; the customer taps; Nayax calls us back with the result and settles normally.
It fits our machinery almost suspiciously well:
| Step | Who | Call | Notes |
|---|---|---|---|
| 1 | kiosk → us | POST /apk/createOrder |
already exists; card tile |
| 2 | us → Nayax | StartAuthentication |
session setup, two-part cipher auth |
| 3 | us → Nayax | TriggerTransaction |
wakes the reader: TerminalId, TerminalIdType: 1, Amount, PosDisplay, TransactionTimeout |
| 4 | customer | tap | on the reader's own 4G — no bus, no cable |
| 5 | Nayax → us | TransactionCallback |
approval + transaction identifiers; we ACK |
| 6 | us → kiosk | MQTT type "1" |
orderNumber + aisleNo → existing dispense path |
| 7 | Nayax → us | Settlement |
money settles to the terminal's actor |
Three things make this the strongest option:
TerminalId is the identifier we already bind. Nayax's own example is
TerminalId: '0434334921100366' with TerminalIdType: 1 (HW Serial). Kiosk 0036's
device is 0434332923153297 — same 16-digit HW Serial form, and exactly what
POST /api/v1/machines/{machineNo}/nayax already stores. The long-standing rule in
this repo — bind the Device Number, never the short Machine ID — turns out to be
what Spark needs too.
It settles through Nayax unchanged, so the money lands in the same actor as before: the operator's MoMa account. Spark changes who starts the transaction, not who gets paid.
It ends the external_core_capture guesswork. TransactionCallback hands us
transaction identifiers inline, so orders carry a real transactionId without
anyone configuring a settlement webhook, and the ghost-sale alarm can be armed
strictly.
No APK release is needed to take the first Spark sale
The MQTT dispense handler in PayingPOP is guarded by one condition — that the
message's orderNumber equals the order on screen:
if (TextUtils.equals(IcyHeaders.REQUEST_HEADER_ENABLE_METADATA_VALUE, JsonUtils.getString(jSONObject2, "type"))) {
this.payState = PayState.HAVE_PAID;
...
if (TextUtils.equals(PayingPOP.this.orderNumber, string2)) { /* clear, deliver, vend */ }
There is no tempPayType check anywhere in that path. So on the existing shipped
build, a Spark approval published as type:"1" while the Credit card tile is
open flips the popup to "have paid" and vends. The firmware's MDB arm
(FF00551106) still goes out and nothing answers it, which is harmless — the tap
never needed the bus.
Two timing facts follow, and both are enforced in code:
- The card popup counts down 90 seconds, so
TransactionTimeoutis clamped to 85s maximum (nayax_spark.transaction_timeout_seconds). A reader still waiting for a card after the screen has given up would take money for an abandoned session. - Stock is decremented by the firmware's
ordersUpdate {state: 3}, exactly as on every other path, so the approval books money and the machine books stock. One decrement, no coordination needed.
The eventual APK cleanup — dropping the pointless MDB arm and the HavePaid wait
from payType 3 on Spark machines — is cosmetic, not a prerequisite. Do it in a
normal release when convenient.
What is implemented today
Behind credentials and a per-machine flag, so nothing changes for any machine that has not opted in:
| Piece | Where |
|---|---|
Spark client: trigger_transaction, cancel_transaction, signature verify, settle+dispense |
app/nayax_spark.py |
| Card tile wakes the reader on Spark machines | app/routes/device.py, payType 3 branch |
POST /api/v1/payments/nayax/spark/callback (HMAC-signed, fail-closed, idempotent) |
app/routes/payments.py |
Per-machine opt-in: cardMode: "mdb" \| "spark" |
bind_nayax_terminal, nayax_card_mode |
| Tests, including replay/decline/forged-callback safety | tests/test_nayax_spark.py |
Configuration — all unset by default, and configured() is False unless the first
three are present:
KIOSKX_NAYAX_SPARK_BASE_URL
KIOSKX_NAYAX_SPARK_TOKEN_ID
KIOSKX_NAYAX_SPARK_API_KEY
KIOSKX_NAYAX_SPARK_CALLBACK_SECRET # inbound TransactionCallback signing
KIOSKX_NAYAX_SPARK_TXN_TIMEOUT_SECONDS # default 60, clamped to 15..85
KIOSKX_NAYAX_SPARK_TERMINAL_ID_TYPE # default 1 = HW Serial
Flipping one machine over, once credentials are in place:
POST /api/v1/machines/866902661300036/nayax
{ "terminalId": "0434332923153297", "cardMode": "spark" }
Two deliberate refusals, because both alternatives are worse than an honest error:
- Spark mode with no credentials, or with no terminal bound, fails the tile rather than falling back to MDB. This reader is flagged Spark precisely because it does not answer the bus; "please tap" at a dark reader for 90 seconds is the behaviour we are trying to remove.
- An unsigned, wrongly-signed, or unconfigured-secret callback is refused. On this path a callback does not merely book revenue, it turns a motor — so it is fail-closed on its own secret, separate from the settlement webhook's.
The one thing left to confirm with Nayax is the exact header construction for
outbound calls, which is issued with the credentials. It is isolated in
nayax_spark._auth_headers() so it is a one-function change, not a rewrite.
What Nayax has to do first
Spark is an integrator programme, not a self-serve API, so this is the gating dependency and its timeline is theirs:
- Enable Spark on the operator's actor and issue credentials (
TokenIdand the cipher keys used byStartSession/StartAuthentication). - Confirm the machine's Core configuration permits remote-start on this device.
- Whitelist our egress IPs; we serve callbacks over HTTPS with TLS 1.2/1.3.
- Accept the Spark terms, and confirm any per-transaction pricing.
Option 2 — Marshall (local, we become the VMC)
Marshall
is Nayax's proprietary machine-controller protocol, offered as an SDK in C, C# and
Java — the last of which is directly usable from the Android app. Instead of
letting the Reyeah VMC be the master, we are: vmc_vend_session_start, then
onSessionBegin when a card is presented and approved, then vend_request, then we
report delivery.
The attraction is that the exact mismatch that broke this cabinet becomes ours to
declare. The SDK's own VMC configuration object carries always_idle,
reader_always_on, multi_vend_support, price_not_final_support and
explicit_vend_success as host-side flags. Nobody has to re-commission anything
to switch between "tap first" and "select first" — it is a field in our code.
The cost is transport, and this is where the setting in the screenshot matters:
- Marshall is RS232. Nayax's docs are explicit that Marshall over Ethernet is supported only on VPOS Media 5 and VPOS Media 4S (host connects to the device's IP on port 2025), and "Marshall over ETH is not supported for VPOST" — the VPOS Touch.
- The VPOS Touch's own LAN is for reaching Nayax's servers, not for peripherals, and it is not even RJ-45: it uses the RJ-11 port with a Nayax RJ-11-to-RJ-45 adapter, or a Wi-Fi range extender bridged to it.
So the Ethernet Listen Socket only helps if the reader is a VPOS Media 5 / 4S.
Kiosk 0036's reader is a VPOS Touch, so that route is closed there: Marshall on
this cabinet means a serial cable from the Android board to the reader. That is
plausible hardware-wise — the board exposes several UARTs and the app already
supports a multi-serial mode (sp_Equipment_type, payment on /dev/ttyS8,
dispensing on /dev/ttyS0) — but it is a site visit with a cable, not a config
change. Check the model per cabinet before assuming either way.
Marshall also still needs one Core setting: in the machine's General → MDB section, set the Cashless MDB address to "Marshall VMC", then Update Queue. It replaces a four-field profile argument with a single switch, but it does not remove Core access from the critical path.
Option 3 — make the controller speak the reader's dialect
The mirror image of re-profiling: leave the reader commissioned as it is and change the master. Four levers, and the first is free.
3a. Use the credit-first mode the controller already has
Start here, because the premise of the whole MDB argument may be wrong. This
cabinet's native money model is credit-first: coins and bills establish credit on
the VMC, the app polls that credit (FF0055E104…), the idle screen un-hides a
balance row when it goes above zero, and the purchase deducts from it
(FF0055E101). A reader in Always Idle authorises on tap and hands the machine
funds — which is structurally the same event as a coin dropping.
So the controller very likely already understands a reader-initiated session; we have simply never let one happen, because every attempt so far selected a product first and waited for the other flow. That is exactly what the tap-first test in Activating a Nayax reader checks, and why Default Credit matters: with no default the reader has no amount to authorise on a tap, so it stays silent and the question never gets asked.
Cost: one card tap. If a balance appears, there was never anything to change on either side.
3b. The VMC's own cashless setting
Controllers of this class commonly expose the cashless device type — and sometimes the MDB level — as a service-menu or DIP-switch parameter rather than something fixed in firmware. Nobody has looked. Worth asking Reyeah for the VMC's configuration parameter list, quoting the controller's firmware version.
We have that version already, for every board in the fleet: the app asks the VMC
over serial (FF00550101FE) on every boot and posts the reply to
/apk/validVmcVersion, which used to discard it and now records it as
software.vmcVersion on the machine (visible in GET /api/v1/machines/{machineNo}).
So the question can be asked from a desk. Note that this endpoint must keep
answering successfully whatever we do with the value — SplashFragment reads a
failure as "this controller needs a firmware update", and 3c has no rollback.
Cost: an email and a service-menu visit. Highest value per minute after 3a.
3c. New VMC firmware
installVMC over MQTT takes {url, version}, downloads the image and streams it to
the controller at 115200 baud. It is the one lever we hold that reaches the bus, and
it is a bad trade unless Reyeah hands us a build: we have no such image, no
documented flash protocol, and no rollback, while the VMC drives the motors and
locks. A failed flash is a site visit with a replacement board.
3d. An inline translator
Put a small board between the VMC and the reader that presents itself to the VMC as a Level 1 cashless peripheral and acts as the master toward the reader. Hardware capable of either side of MDB exists (Qibixx's MDB interfaces, for example), so this is buildable rather than hypothetical.
It is also strictly more work than every other option on this page: hardware to source, firmware to write, a per-cabinet cost, and a new part that can fail in a cabinet we do not visit often. It is essentially building our own Marshall bridge — if we are willing to do that, use Marshall, which Nayax already wrote and supports.
3e. Move the cashless master onto the board
Not to be confused with 3d. Nothing has to impersonate a peripheral, and nothing has to sit between the controller and the reader. Take the reader off the VMC altogether, hang it on an MDB-RS232 adapter, and let the Android board be the cashless master. The VMC keeps doing what it is good at — motors, coins, bills — and simply never sees a cashless device.
Three facts make this the cheapest option that depends on neither vendor:
- We already run this pattern. The ZHZN/CSM cabinets drive MDB peripherals
through a Waferstar MDB-RS232 adapter with our own agent as the host, which is why
/zhzn/*exists at all. The adapter's guide states plainly that any host with an RS232 or USB port can be the VMC. - The adapter does the hard part. It polls the peripherals itself, so the host
never implements MDB timing or the 9-bit mode bit. Bringing a Nayax reader up is
five commands (
11 00,11 01,17 00,17 04,14 01) and a vend is three (13 00,13 02,13 04). - The board can already talk to it. The Reyeah APK declares
android.hardware.usb.hostand bundlesusb-serial-for-androidwith the FTDI, CP210x, CH34x and PL2303 drivers — the exact chipsets these adapters use. The dormantUsbHelperis not MDB (it is an STX/LRC terminal protocol at 115200), so the state machine is ours to write, but the transport is already in the build.
And the dispense needs no new work: an approval hands off to the cloud, which
publishes the same MQTT type: "1" the QR path already uses, and that path vends
without MDB credit. This is the Spark architecture with a local trigger instead of a
remote one — no Core profile change, no Spark credentials, no Reyeah firmware, and it
keeps working when the site loses internet.
Cost: an adapter and a cable per cabinet, plus one APK release carrying an MDB cashless master. Settlement is unaffected — the reader is still the reader, and still settles to whatever actor owns it in Core.
Deciding between 2 and 3 without guessing
Everything above still rests on an assumption: that the reader is healthy and only mis-addressed. The reader has never been observed apart from the VMC, so that has never been tested — and the adapter is what tests it. Unplug the reader's MDB harness from the controller, plug it into the adapter, and run:
pip install pyserial
python3 scripts/nayax_mdb_bench.py --port /dev/tty.usbserial-1420
The script performs the bring-up above and prints a verdict rather than a hex dump.
The default run never requests a vend, so a tap authorises nothing and captures
nothing; --charge proves the full path when you want it. Three outcomes, and each
one closes off half of this document:
| Outcome | Meaning |
|---|---|
SETUP (11 00) never answers |
The reader is not in service. The adapter's guide is explicit that a reader not connected to Nayax's servers may not answer MDB at all — so this is Core registration, and Options 1 and 3 are both dead ends until it is fixed. |
Answers, but refuses 17 04 |
Alive on the bus, not commissioned for Always Idle. Re-profile (Option 2), or re-run with --level 1 to see which flow it does want. |
| Answers and opens a session on a tap | The reader and its profile are fine. The VMC is the fault, and 3e is the fix. |
Run it with --level 1 as well. Always Idle is enabled by 17 04, a command that
exists only at Level 3, so a controller that declares Level 1 in its SETUP config
byte cannot reach the feature at all — which turns the level question from a
preference into a wall, and is worth reproducing on the bench so the Reyeah
conversation starts from evidence.
Choosing
| Spark | Marshall | MDB re-profile | Board as master (3e) | |
|---|---|---|---|---|
| Removes the MDB dialect fight | yes | yes | no — it is the fight | yes — we pick the dialect |
| Needs Nayax Core access | for enablement | one setting | four settings, repeatedly | none |
| Needs Nayax commercial onboarding | yes | SDK terms | no | no |
| Needs on-site work | none | cable, unless VPOSM5/4S | none | adapter + cable |
| Needs an APK release | no — the existing build vends on our push | yes, plus the SDK | no | yes |
| Works if the site loses internet | no | yes, until settlement | yes | yes |
| Reusable across the fleet | one integration, every cabinet | per cabinet wiring | per cabinet commissioning | one integration, plus a part per cabinet |
Gives us transactionId natively |
yes | yes | only via settlement webhook | only via settlement webhook |
| Depends on a vendor saying yes | Nayax | Nayax | the Core actor | nobody |
Recommended sequence, cheapest first, and each step is useful even if the next never happens:
- Bench the reader on an adapter first —
scripts/nayax_mdb_bench.py, fifteen minutes, no Nayax or Reyeah cooperation, nothing captured. It is the only step that can eliminate options rather than add them, and every other item below is cheaper to argue once you know whether the reader answers MDB at all. - Try the MDB path anyway — Phases 1–3 of Activating a Nayax reader cost an evening and no integration work. A tap-first test and a Default Credit change may simply work.
- Open the Spark conversation with Nayax now, in parallel, because its lead time is external and it is the only option that scales to every cabinet we will ever buy second-hand.
- Prove Spark on the existing build. Set the credentials, flip 0036 to
cardMode: "spark", tap. No rollout, no site visit. - Build 3e if the bench says the reader is healthy and Nayax stalls. It is the only path with nobody to persuade, and the ZHZN fleet is the proof it works.
- Hold Marshall in reserve for sites with unreliable connectivity or where Spark is refused. On 0036 that means a serial cable, since its reader is a VPOS Touch.
What does not change
- Bind the Device Number (leading zero included), never the Machine ID. Spark
wants the same value as
TerminalIdType: 1. - Settlement follows the terminal's actor, under every option here. None of this moves money between accounts; see "Routing to the correct Nayax account".
payConfig.isCardwithdrawal still protects buyers from a dead reader.- A Kiosk-X emulated transaction (
nyx_emu_*) still proves nothing.
Questions to send Nayax
- Can Spark be enabled for our actor, and is device
0434332923153297eligible for Remote Start as currently configured? - Which flow suits pre-priced vending — Pre-Selection with
AmountonTriggerTransaction, or pre-authorisation? - What is the exact model of that device, and does it support Marshall over Ethernet (VPOS Media 5 / 4S) or RS232 only (VPOS Touch)?
- Per-transaction cost of Spark versus plain MDB cashless.
- Are there existing notification subscriptions on this actor pointing at the previous software vendor that should be removed?