LAN Integration — Hub ⇄ Terminal
Trigger payments on another PayUs terminal on the same local network— no cloud roundtrip, no WAN latency, works when the broadband drops. Typical use: one tablet acts as a POS controller (“hub”) and tells a nearby card terminal to charge; the terminal processes locally and streams the result back.
When to use LAN vs intent vs cloud
Pick by where your POS code runs relative to the card terminal:
| Where your code runs | Reach | |
|---|---|---|
| Intent API | Same Android device as PayUs | 0 hops |
| LAN (this page) | Different device, same Wi-Fi / LAN | 1 hop, no internet needed |
| REST / WebSocket | Backend, anywhere on the internet | Routed through PayUs |
Role model
Each PayUs terminal picks a LanRole at startup. Set it from Settings → Communication → LAN.
| Role | Behaviour |
|---|---|
| hub | POS Controller — sends commands to peer terminals. |
| terminal | Payment Processor — listens on the LAN HTTP + WebSocket server. |
| standalone | Off — LAN disabled on this device. |
Transport
Every terminal in terminal role runs a tiny HTTP + WebSocket server on first-free port in range 18230–18239. Three endpoints, no path params.
TLS: the transport runs over TLS using a per-terminal leaf certificate the PayUs CA issues at activation (fetch via GET /api/v1/terminal/leafCert). Peers verify each handshake against the PayUs CA cert — no trust-on-first-use. HMAC (below) remains as integrity defence-in-depth on top.
| Endpoint | Transport | Used for |
|---|---|---|
| GET /api/health | HTTP | Liveness probe. Auth not required. |
| POST /api/command | HTTP | Request/response commands (cancel, get_*, print_receipt). |
| GET /api/ws | WebSocket | Streaming commands (pay, refund, settle) — intermediate updates + final result. |
Discovery
Terminals advertise themselves on the LAN via two mechanisms running in parallel. Hubs subscribe to both and merge the result into a single list.
- NSD / mDNS — the ideal path. Works on most networks and is instant. Breaks on networks that block multicast (common on captive-portal / guest Wi-Fi).
- UDP broadcast (reliable fallback) on port
18231:- Hub sends
DISCOVER(broadcast) → every terminal broadcastsANNOUNCEback (to the subnet broadcast255.255.255.255, for reusePort reasons). - Terminals also broadcast
ANNOUNCEevery 30 s so hubs that join late pick them up.
- Hub sends
Authentication
Every POST /api/command and WebSocket message carries an auth block keyed on a per-branch secret the server mints at activation and delivers over the authenticated channel — shared by every terminal in the branch so peers verify each other.
Branch secret (server-minted)
// The server returns lanSecret — a base64-encoded 256-bit key — on the
// activation response and on GET /api/v1/terminal/info. Decode it to the raw
// HMAC key bytes; the same value is shared by every terminal in the branch:
branchSecret = base64Decode(lanSecret)Legacy fallback: terminals provisioned before server-minted secrets derive one as HMAC_SHA256(key = utf8(configId), data = utf8(branchId)). This is being retired — it keys off non-secret identifiers, so always prefer the server-minted lanSecret when present.
Signing a request
The canonical input is the UTF-8 concatenation of three envelope fields in order, with no separator:
canonicalBody = utf8(correlationId) || utf8(command.wireName) || utf8(timestamp)
// e.g. "7f3a-...pay2026-10-06T10:00:00Z"
// \__corr___/\cmd/\___timestamp___/
token = HMAC_SHA256(key = branchSecret, data = canonicalBody).hex()
// Attach to the request:
request.auth = { token, branchId }Fields covered: correlationId, command (wire name — pay, refund, etc.), timestamp. Fields not covered: payload, sourceTerminalId, the auth block itself. Token is the lowercase-hex SHA-256 HMAC digest.
Worked test vector
A fully-worked example to check an implementation against. The key is the base64-decodedlanSecret; the token is HMAC_SHA256(key, canonicalBody) in lowercase hex.
lanSecret = "MDEyMzQ1Njc4OWFiY2RlZjAxMjM0NTY3ODlhYmNkZWY=" // base64
key = base64Decode(lanSecret) = "0123456789abcdef0123456789abcdef" (32 bytes)
correlationId = "7f3a9c2e-1b4d-4a8e-9f0c-2d3e4f5a6b7c"
command = "pay"
timestamp = "2026-10-07T10:00:00Z"
canonicalBody = "7f3a9c2e-1b4d-4a8e-9f0c-2d3e4f5a6b7cpay2026-10-07T10:00:00Z"
token = "4c953a2b78158b2d6fd05a8f72c54554fa40566efcff2565c326f7f737a08672"Verifying (receiver side)
The receiver checks that request.auth.branchId matches its own branch. Cross-branch requests return AUTH_FAILED; empty branchId is accepted (reserved for manually-added peers).
HMAC_SHA256 over the canonical body and compares it to request.auth.token in constant time, and checks the timestamp against a ±30-second freshness window to reject replays. This runs on both transports (POST /api/command and /api/ws); a bad signature or a stale timestamp returns AUTH_FAILED / HTTP 403. The only bypass is a manually-paired peer with an empty branchId (noted above).Request envelope
Every command — HTTP or WebSocket — uses the same JSON shape. Per-command fields go in payload.
{
"command": "pay", // one of the wire names below
"correlationId": "uuid-v4", // echoed on every response
"timestamp": "2026-10-06T10:00:00Z", // ISO 8601 UTC
"sourceTerminalId": "T-123", // who's calling
"auth": {
"token": "<HMAC-SHA256 hex>",
"branchId": "B-456"
},
"payload": { "amount": 1250, "currency": "NZD" }
}Response envelope
// Success
{
"correlationId": "uuid-v4",
"status": "ok",
"timestamp": "2026-10-06T10:00:01Z",
"sourceTerminalId": "T-recipient",
"payload": { /* command-specific */ }
}
// Error
{
"correlationId": "uuid-v4",
"status": "error",
"timestamp": "2026-10-06T10:00:01Z",
"sourceTerminalId": "T-recipient",
"payload": {},
"error": { "code": "AUTH_FAILED", "message": "Bad signature" }
}Streaming commands (pay, refund, settle) emit multiple response messages on the WebSocket — intermediate progress + a final one — all sharing the same correlationId.
Commands
| Wire name | Transport | Purpose |
|---|---|---|
| pay | WebSocket | Charge a card / QR / wallet. Streams intermediate statuses, final = settled result. |
| refund | WebSocket | Refund a prior transaction. Streams interim statuses then the terminal receipt. |
| settle | WebSocket | End-of-day settlement — closes the batch. |
| cancel | HTTP | Cancel the in-flight payment on this terminal. Idempotent. |
| get_status | HTTP | terminalId + activation state + configured gateways + current time. |
| get_transaction_details | HTTP | One transaction by id. |
| get_transaction_history | HTTP | Paged recent transactions (default limit 50). |
| print_receipt | HTTP | Reprint arbitrary text / receipt data on this terminal's printer. |
| change_settings | HTTP | Push config to a remote terminal. ⚠ Planned — currently returns NOT_SUPPORTED. |
Per-command payloads
pay
WebSocket// REQUEST
{
"command": "pay",
"payload": {
"amount": 1250, // minor units (cents) — required, > 0
"currency": "NZD", // ISO 4217, default "NZD"
"gateway": "wechat", // optional — null → terminal picks / shows picker
"orderId": "ORD-42", // optional partner reference — the id a refund resolves against; always send one
"tipAmount": 100, // minor units, default 0
"lineItems": [ // optional itemised basket (integer cents). Sum of lineTotalCents must
// equal merchandise = amount − tipAmount on LAN (here 1150). The pay
// payload carries only amount + tipAmount; fee/donation/discount exist
// only on the cloud / Intent surfaces, not on LAN. Malformed →
// INVALID_PAYLOAD pre-send; server reconciles and rejects a mismatch.
// Persisted + keyed to the sale by orderId.
{ "name": "Flat White", "quantity": "2", "unitPriceCents": 400, "lineTotalCents": 800, "sku": "FW" },
{ "name": "Muffin", "quantity": "1", "unitPriceCents": 350, "lineTotalCents": 350, "taxCents": 46 }
]
}
}
// RESPONSES — streaming, same correlationId on every frame.
// 1. Immediate ack while the dialog opens on the terminal.
{ "status": "ok", "payload": {
"status": "PENDING",
"message": "Payment dialog shown on terminal",
"amount": 1250, "currency": "NZD", "gateway": "wechat"
}}
// 2. Final result when the operator / cardholder completes or dismisses.
{ "status": "ok", "payload": {
"status": "captured", // "captured" | "cancelled" | "failed"
"transactionId": "TXN-123",
"tradeNo": "TR-456", // gateway's own id (may be "")
"referenceId": "ORD-42", // echoes orderId, or server-generated
"gateway": "wechat",
"amount": 1250, // minor units
"tipAmount": 100,
"feeAmount": 0,
"totalAmount": 1350,
"currency": "NZD",
"paymentMethod": "tap", // tap | insert | qr | manual
"timestamp": "2026-10-06T10:00:00Z",
"cardType": "visa", // "" when non-card
"maskedCard": "**** 1234", // "" when non-card
"gatewayMessage": "Success"
}}
// On timeout (5 min with no completion):
{ "status": "error", "error": { "code": "TIMEOUT", "message": "..." } }refund
WebSocket// REQUEST
{
"command": "refund",
"payload": {
"referenceId": "ORD-42", // primary key (same as REST + Cloud) — the id read first
"originalTransactionId": "TXN-123", // legacy fallback — used only when referenceId is absent
"amount": 1250, // minor units, required > 0
"currency": "NZD",
"refundReason": "customer_return" // default "LAN refund"
}
}
// RESPONSES — streaming.
{ "status": "ok", "payload": {
"status": "PROCESSING",
"message": "Refund dispatched to provider",
"referenceId": "ORD-42",
"refundAmount": 1250
}}
{ "status": "ok", "payload": {
"status": "REFUNDED",
"message": "Refund captured",
"referenceId": "ORD-42",
"refundAmount": 1250,
"transaction": { /* the refunded transaction record */ }
}}✓ Real streaming command — the terminal dispatches the refund to the gateway and streams the interim status then the final result (Q-012 closed).
cancel
HTTP// REQUEST
{ "command": "cancel", "payload": {} }
// RESPONSE
{ "status": "ok", "payload": { "cancelled": true } }
// Idempotent — cancels the in-flight payment on the receiving
// terminal, if any. Returns ok even when nothing was running.settle
WebSocket// REQUEST
{ "command": "settle", "payload": {} }
// RESPONSES — streaming.
{ "status": "ok", "payload": { "status": "PROCESSING", "message": "Settlement initiated" } }
{ "status": "ok", "payload": { "status": "SETTLE_COMPLETE" } }✓ Real streaming command — the terminal runs settlement and streams PROCESSING then the final result (Q-013 closed).
get_status
HTTP// Request
{ "command": "get_status", "payload": {} }
// Response payload
{
"terminalId": "T-123",
"activated": true,
"merchantName": "Ahura Consulting",
"currency": "NZD",
"gateways": ["card", "wechat", "alipay"],
"timestamp": "2026-10-06T10:00:00Z"
}get_transaction_details
HTTP{
"command": "get_transaction_details",
"payload": { "transactionId": "TXN-123" }
}get_transaction_history
HTTP{
"command": "get_transaction_history",
"payload": { "limit": 50 } // default 50
}print_receipt
HTTP{
"command": "print_receipt",
"payload": {
"printData": "===== RECEIPT =====\n…"
// or legacy key "receiptData" — both are accepted
}
}change_settings
HTTP{
"command": "change_settings",
"payload": { "field": "...", "value": "..." }
}
// An empty / absent "field" returns:
// { "error": { "code": "INVALID_PAYLOAD",
// "message": "change_settings requires a `field` and `value` payload" } }
// A field that isn't on the allowlist (currently empty) returns:
// { "error": { "code": "NOT_SUPPORTED", "message": "..." } }Error codes
Stable error.code values on status: "error" responses.
AUTH_FAILEDTERMINAL_BUSYUNKNOWN_COMMANDINVALID_PAYLOADCOMMAND_FAILEDTIMEOUTNOT_SUPPORTEDPrerequisites
- All participating terminals paired to the same branch (so they share the same branch secret — the server-minted
lanSecret, or the legacy derived fallback). - Devices on the same LAN segment — no NAT between them.
- For reliable discovery on captive-portal Wi-Fi, allow UDP broadcast on port
18231. - Allow TCP on ports
18230–18239.
See also
- On-device integration (Intent API) — same device.
- REST API — reach a terminal over the internet.