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 runsReach
Intent APISame Android device as PayUs0 hops
LAN (this page)Different device, same Wi-Fi / LAN1 hop, no internet needed
REST / WebSocketBackend, anywhere on the internetRouted through PayUs

Role model

Each PayUs terminal picks a LanRole at startup. Set it from Settings → Communication → LAN.

RoleBehaviour
hubPOS Controller — sends commands to peer terminals.
terminalPayment Processor — listens on the LAN HTTP + WebSocket server.
standaloneOff — 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.

EndpointTransportUsed for
GET /api/healthHTTPLiveness probe. Auth not required.
POST /api/commandHTTPRequest/response commands (cancel, get_*, print_receipt).
GET /api/wsWebSocketStreaming 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 broadcasts ANNOUNCE back (to the subnet broadcast 255.255.255.255, for reusePort reasons).
    • Terminals also broadcast ANNOUNCE every 30 s so hubs that join late pick them up.

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).

Verified + anti-replay. Same-branch LAN requests are fully authenticated: the receiver recomputes the 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 nameTransportPurpose
payWebSocketCharge a card / QR / wallet. Streams intermediate statuses, final = settled result.
refundWebSocketRefund a prior transaction. Streams interim statuses then the terminal receipt.
settleWebSocketEnd-of-day settlement — closes the batch.
cancelHTTPCancel the in-flight payment on this terminal. Idempotent.
get_statusHTTPterminalId + activation state + configured gateways + current time.
get_transaction_detailsHTTPOne transaction by id.
get_transaction_historyHTTPPaged recent transactions (default limit 50).
print_receiptHTTPReprint arbitrary text / receipt data on this terminal's printer.
change_settingsHTTPPush 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_SUPPORTED

Prerequisites

  • 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