Physical POS
API Reference
Device Link

Device Link

Who calls these endpoints?External API (s2s)
Base path
/api/v1/physical-pos
Identity
Ed25519 request signature on every request — X-Key-Id, X-Date, X-Nonce, X-Signature, plus Digest on requests with a body. ±5 min clock window, single-use nonce. A Bearer token or a panel session is not accepted here.
Authorization
The key must carry the Physical POS product (PHYSICAL_POS) and the scope for the endpoints on this page: physical_pos:view · physical_pos:manage. Without the product the call gets 403 PRODUCT_NOT_ENABLED; without the scope 403 API_INSUFFICIENT_SCOPE. See Authentication and Scopes.
Cross-surface
These endpoints are the s2s surface — call them from your own server with a signed request. The same records have screens in the panel, but panel endpoints cannot be called with an API key, and these endpoints cannot be called with a panel session.

The device link shows which physical device a logical terminal runs on. When the device changes, the terminal, its code and its bank rows stay the same; only the link changes. → Core concepts

  • A terminal has at most one linked device at a time; a device is linked to at most one terminal at a time.
  • Every link has a start (validFrom) and, once closed, an end (validTo); old links are not deleted and stay in the link history.
  • The terminal and the device must be in the same environment. Link operations take the terminal's rowVersion, and every successful operation increases it.
  • When a device is retired, its link is closed too.
  • A test key can only read the link history on these endpoints; write requests get 403 physical_pos.environment.test_key_read_only.
MethodPathScope
POST/api/v1/physical-pos/terminals/{terminalId}/device-linkphysical_pos:manage
POST/api/v1/physical-pos/terminals/{terminalId}/device-link/closephysical_pos:manage
GET/api/v1/physical-pos/terminals/{terminalId}/linksphysical_pos:view

The cURL examples compute the signature with lunixi-sign.sh: Authentication › Signing helper.

Link object

FieldTypeDescription
idstring (UUID)Link ID.
terminalIdstring (UUID)Terminal.
deviceIdstring (UUID)Device.
validFromstringWhen the link was opened.
validTostring | nullWhen the link was closed; null for an open link.
reasonCodestringReason code of the operation that opened the link.
createdByTypestringWho opened the link: USER, API_KEY, OPERATOR or SYSTEM.
createdByIdstringID of who opened the link; kid:<key id> for an API key.
createdAtstringRecord creation time.

Link or replace a device

POST /api/v1/physical-pos/terminals/{terminalId}/device-link

Links a device to the terminal. If the terminal has an open link, the old link is closed and a new one is opened, both in a single operation.

FieldTypeRequiredDescription
rowVersionintegerYesThe terminal's rowVersion you last read.
deviceIdstring (UUID)YesID of the device to link: the id from the device list or detail. The device must be in the same environment as the terminal.
reasonCodestringYesSee the table below.
SituationAccepted reasonCode
The terminal has no linked device (first link)INITIAL_SETUP, RELOCATION, CORRECTION, OTHER
The terminal has a linked device (replacement)DEVICE_REPLACEMENT, DEVICE_FAULT, DEVICE_LOST, DEVICE_STOLEN, RELOCATION, CORRECTION, OTHER

A code that does not fit the situation (e.g. INITIAL_SETUP on a replacement) gets 400 physical_pos.input.reason_code_invalid.

source lunixi-sign.sh
REQ_PATH="/api/v1/physical-pos/terminals/00000000-0000-0000-0000-000000000301/device-link"
BODY='{"rowVersion":3,"deviceId":"00000000-0000-0000-0000-000000000402","reasonCode":"DEVICE_FAULT"}'
lunixi_sign POST "$REQ_PATH" "$BODY"
 
curl -X POST "https://api-gateway.lunixi.com$REQ_PATH" \
  -H "Content-Type: application/json" \
  -H "X-Key-Id: $LUNIXI_KEY_ID" \
  -H "X-Date: $DATE" \
  -H "X-Nonce: $NONCE" \
  -H "Digest: $DIGEST" \
  -H "X-Signature: $SIGNATURE" \
  --data-raw "$BODY"
{
  "status": "success",
  "code": "SUCCESS",
  "data": {
    "terminal": {
      "id": "00000000-0000-0000-0000-000000000301",
      "terminalCode": "IST-KADIKOY-01",
      "terminalStatus": "ACTIVE",
      "linkedDevice": {
        "linkId": "00000000-0000-0000-0000-000000000502",
        "deviceId": "00000000-0000-0000-0000-000000000402",
        "displayName": "Register 1 POS (spare)",
        "serialNumber": "SN0000000002",
        "lifecycleStatus": "ACTIVE",
        "linkedAt": "2026-01-10T10:00:00.000Z"
      },
      "rowVersion": 4
    },
    "link": {
      "id": "00000000-0000-0000-0000-000000000502",
      "terminalId": "00000000-0000-0000-0000-000000000301",
      "deviceId": "00000000-0000-0000-0000-000000000402",
      "validFrom": "2026-01-10T10:00:00.000Z",
      "validTo": null,
      "reasonCode": "DEVICE_FAULT",
      "createdByType": "API_KEY",
      "createdById": "kid:mk_live_00000000",
      "createdAt": "2026-01-10T10:00:00.000Z"
    },
    "previousLinkId": "00000000-0000-0000-0000-000000000501"
  },
  "statusCode": 200,
  "requestId": "00000000-0000-0000-0000-000000000000"
}

The terminal object is shortened in the example; the response contains every field. previousLinkId is the ID of the closed old link; null on a first link.

BlockerError
The device does not exist, is not yours or is in the other environment404 physical_pos.device.not_found
The device is already linked to this terminal409 physical_pos.terminal.device_already_linked
The device is linked to another terminal409 physical_pos.terminal.link_conflict — unlink it from that terminal first.
The device is retired (RETIRED)409 physical_pos.device.retired
The terminal's vendor differs from the device's vendor400 physical_pos.terminal.device_vendor_mismatch
The terminal is closed409 physical_pos.terminal.closed

This endpoint does not use Idempotency-Key; the terminal's rowVersion protects against repeats. After a timeout, read the terminal before repeating the request: if linkedDevice.deviceId shows the new device, the operation has completed.

Unlink the device

POST /api/v1/physical-pos/terminals/{terminalId}/device-link/close · Idempotency-Key required

Closes the terminal's open link. The terminal stays open; bank rows do not change. You can link a new device later.

FieldTypeRequiredDescription
rowVersionintegerYesThe terminal's rowVersion you last read.
reasonCodestringYesDEVICE_REPLACEMENT, DEVICE_FAULT, DEVICE_LOST, DEVICE_STOLEN, RELOCATION, CORRECTION, OTHER.
source lunixi-sign.sh
REQ_PATH="/api/v1/physical-pos/terminals/00000000-0000-0000-0000-000000000301/device-link/close"
BODY='{"rowVersion":4,"reasonCode":"DEVICE_LOST"}'
lunixi_sign POST "$REQ_PATH" "$BODY"
 
curl -X POST "https://api-gateway.lunixi.com$REQ_PATH" \
  -H "Content-Type: application/json" \
  -H "X-Key-Id: $LUNIXI_KEY_ID" \
  -H "X-Date: $DATE" \
  -H "X-Nonce: $NONCE" \
  -H "Digest: $DIGEST" \
  -H "X-Signature: $SIGNATURE" \
  -H "Idempotency-Key: 4f9b2c1e-0000-4000-8000-000000000005" \
  --data-raw "$BODY"
{
  "status": "success",
  "code": "SUCCESS",
  "data": {
    "terminal": {
      "id": "00000000-0000-0000-0000-000000000301",
      "terminalCode": "IST-KADIKOY-01",
      "terminalStatus": "ACTIVE",
      "linkedDevice": null,
      "rowVersion": 5
    },
    "link": {
      "id": "00000000-0000-0000-0000-000000000502",
      "terminalId": "00000000-0000-0000-0000-000000000301",
      "deviceId": "00000000-0000-0000-0000-000000000402",
      "validFrom": "2026-01-10T10:00:00.000Z",
      "validTo": "2026-02-01T12:00:00.000Z",
      "reasonCode": "DEVICE_FAULT",
      "createdByType": "API_KEY",
      "createdById": "kid:mk_live_00000000",
      "createdAt": "2026-01-10T10:00:00.000Z"
    },
    "replayed": false
  },
  "statusCode": 200,
  "requestId": "00000000-0000-0000-0000-000000000000"
}

link is the closed link; its reasonCode is the code of the operation that opened it, and the unlink reason is written to the terminal's change history. A repeat with the same Idempotency-Key and the same body returns replayed: true. If the terminal has no linked device: 409 physical_pos.terminal.device_not_linked.

Link history

GET /api/v1/physical-pos/terminals/{terminalId}/links

Query parameterTypeDescription
limitintegerLinks per page, 1–100. Default 25.
cursorstringpageInfo.nextCursor from the previous response.

Response: { "items": [ <link object> ], "pageInfo": { … } }, newest link first.

Errors

HTTPcodeWhen
400VALIDATION_ERRORdeviceId is not a UUID; undefined body field.
400physical_pos.idempotency.key_required / physical_pos.idempotency.key_invalidIdempotency-Key missing or malformed on unlink.
400physical_pos.input.reason_code_invalidreasonCode is not on the list or does not fit the operation.
400physical_pos.terminal.device_vendor_mismatchThe terminal's and the device's vendors differ.
400physical_pos.cursor.invalidBroken cursor.
403physical_pos.environment.test_key_read_onlyWrite request made with a test key.
404physical_pos.terminal.not_foundThe terminal does not exist, is not yours or is in the other environment.
404physical_pos.device.not_foundThe device does not exist, is not yours or is in the other environment.
409physical_pos.terminal.closedThe terminal is closed.
409physical_pos.terminal.device_already_linkedThe device is already linked to this terminal.
409physical_pos.terminal.link_conflictThe device is linked to another terminal.
409physical_pos.terminal.device_not_linkedThere is no link to close.
409physical_pos.device.retiredThe device is retired.
409physical_pos.version_conflictThe terminal's rowVersion is not current.
409physical_pos.idempotency_mismatchThe same Idempotency-Key was used with a different request.
429RATE_LIMITEDWrite limit exceeded. → Rate limits

All codes: Error Codes.