@munchi_oy/pay-terminal-protocol
v0.2.5
Published
Shared wire contract for Munchi Pay terminals: device pairing, session identity, and card-charge messages exchanged between the POS and a paired pay terminal over Ably.
Readme
@munchi_oy/pay-terminal-protocol
Shared wire contract for Munchi Pay terminals — a card-payment terminal device paired strictly 1:1 to one POS cashier device, acting as a thin Viva app-to-app proxy for that POS over Ably.
Consumed by three sides:
- POS (
mun-pos/app) — pairs a terminal, then publishes card-charge triggers. - Pay terminal app (
mun-pos/pay) — receives charges, drives Viva, reports state + result. - Backend (
munchi-super-server) — mints pairing + session Ably tokens and persists the binding.
This package is types + a few pure helpers only — no runtime dependencies (no React / Nest). A shape change becomes a compile error in every consumer instead of a silent runtime break.
Identity model
Three distinct identifiers, kept separate on purpose:
terminalId— server-generated pairing identity (the pairing/session key).deviceHardwareId— the physical device id (kiosk/hardware id) the terminal app reports; stable across re-pairings, used for device management.terminalName— a human label the terminal sets in its own settings, surfaced to the POS and in logs.
Pairing links one POS device (posDeviceId) to one pay terminal (terminalId) via a session (sessionId) that owns a dedicated Ably channel. businessId scopes auth only (POS + terminal must share a business).
Pay terminal (terminalId, deviceHardwareId, terminalName) ──┐
├── pairing ──▶ session ──▶ payterm:session:{sessionId}
POS device (posDeviceId, businessId) ──────────────────────┘Pairing (code-based)
- Terminal calls
POST /pay-terminal/initwith{ deviceHardwareId, terminalName? }, subscribes to the backend-returned pairing channel (payterm:pair:{terminalId}.{env}), and shows a short code (and QR carrying{terminalId, nonce}). - POS enters the code →
POST /pay-terminal/pairwith{ code, posDeviceId, businessId? }. - Backend validates, binds 1:1 (replacing any prior binding), returns
{ sessionId, sessionChannel, terminalId, terminalName? }, and acks the terminal on its pairing channel. - Both devices request session-scoped tokens (
POST /pay-terminal/session-token, rolepos|terminal) and join the session channel (payterm:session:{sessionId}.{env}).
Messages
Envelope: { type, protocolVersion, seq, sentAt, payload }, type ∈ charge_requested | charge_state | charge_result.
| type | Direction | Payload |
| --- | --- | --- |
| charge_requested | POS → terminal | chargeId, sessionId, orderRef, amountCents, currency, displayId?, tipAmountCents? |
| charge_state | terminal → POS | chargeId, sessionId, orderRef, state (connecting/requires_input/processing/verifying) + terminalName? / deviceHardwareId? |
| charge_result | terminal → POS | success/failure discriminated on outcome; transactionId? on success, errorCode?/errorReference? on failure + terminalName? / deviceHardwareId? |
chargeId correlates a request with its state + result across the pair. sessionId is the backend Viva payment-session id (the source of truth); the Ably channel carries only the low-latency trigger + live state mirror. Use isNewerSeq() to drop stale/out-of-order messages.
Channels
Channels are environment-scoped and server-authoritative — the backend returns pairingChannel / sessionChannel in its responses and mints token capability against them, so clients should use the returned strings rather than rebuild names.
Helpers
payTerminalPairingChannelName(terminalId, environment)/payTerminalSessionChannelName(sessionId, environment)isNewerSeq(incoming, lastApplied)
