by Intelliverse X

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:

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?

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

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:

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:

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:

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:

  1. Bench the reader on an adapter firstscripts/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.
  2. 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.
  3. 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.
  4. Prove Spark on the existing build. Set the credentials, flip 0036 to cardMode: "spark", tap. No rollout, no site visit.
  5. 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.
  6. 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

Questions to send Nayax

  1. Can Spark be enabled for our actor, and is device 0434332923153297 eligible for Remote Start as currently configured?
  2. Which flow suits pre-priced vending — Pre-Selection with Amount on TriggerTransaction, or pre-authorisation?
  3. What is the exact model of that device, and does it support Marshall over Ethernet (VPOS Media 5 / 4S) or RS232 only (VPOS Touch)?
  4. Per-transaction cost of Spark versus plain MDB cashless.
  5. Are there existing notification subscriptions on this actor pointing at the previous software vendor that should be removed?