Device Link
- Base path
/api/v1/physical-pos- Identity
- Ed25519 request signature on every request —
X-Key-Id,X-Date,X-Nonce,X-Signature, plusDigeston 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 gets403 PRODUCT_NOT_ENABLED; without the scope403 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.
| Method | Path | Scope |
|---|---|---|
POST | /api/v1/physical-pos/terminals/{terminalId}/device-link | physical_pos:manage |
POST | /api/v1/physical-pos/terminals/{terminalId}/device-link/close | physical_pos:manage |
GET | /api/v1/physical-pos/terminals/{terminalId}/links | physical_pos:view |
The cURL examples compute the signature with lunixi-sign.sh: Authentication › Signing helper.
Link object
| Field | Type | Description |
|---|---|---|
id | string (UUID) | Link ID. |
terminalId | string (UUID) | Terminal. |
deviceId | string (UUID) | Device. |
validFrom | string | When the link was opened. |
validTo | string | null | When the link was closed; null for an open link. |
reasonCode | string | Reason code of the operation that opened the link. |
createdByType | string | Who opened the link: USER, API_KEY, OPERATOR or SYSTEM. |
createdById | string | ID of who opened the link; kid:<key id> for an API key. |
createdAt | string | Record 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.
| Field | Type | Required | Description |
|---|---|---|---|
rowVersion | integer | Yes | The terminal's rowVersion you last read. |
deviceId | string (UUID) | Yes | ID of the device to link: the id from the device list or detail. The device must be in the same environment as the terminal. |
reasonCode | string | Yes | See the table below. |
| Situation | Accepted 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.
| Blocker | Error |
|---|---|
| The device does not exist, is not yours or is in the other environment | 404 physical_pos.device.not_found |
| The device is already linked to this terminal | 409 physical_pos.terminal.device_already_linked |
| The device is linked to another terminal | 409 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 vendor | 400 physical_pos.terminal.device_vendor_mismatch |
| The terminal is closed | 409 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.
| Field | Type | Required | Description |
|---|---|---|---|
rowVersion | integer | Yes | The terminal's rowVersion you last read. |
reasonCode | string | Yes | DEVICE_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 parameter | Type | Description |
|---|---|---|
limit | integer | Links per page, 1–100. Default 25. |
cursor | string | pageInfo.nextCursor from the previous response. |
Response: { "items": [ <link object> ], "pageInfo": { … } }, newest link first.
Errors
| HTTP | code | When |
|---|---|---|
400 | VALIDATION_ERROR | deviceId is not a UUID; undefined body field. |
400 | physical_pos.idempotency.key_required / physical_pos.idempotency.key_invalid | Idempotency-Key missing or malformed on unlink. |
400 | physical_pos.input.reason_code_invalid | reasonCode is not on the list or does not fit the operation. |
400 | physical_pos.terminal.device_vendor_mismatch | The terminal's and the device's vendors differ. |
400 | physical_pos.cursor.invalid | Broken cursor. |
403 | physical_pos.environment.test_key_read_only | Write request made with a test key. |
404 | physical_pos.terminal.not_found | The terminal does not exist, is not yours or is in the other environment. |
404 | physical_pos.device.not_found | The device does not exist, is not yours or is in the other environment. |
409 | physical_pos.terminal.closed | The terminal is closed. |
409 | physical_pos.terminal.device_already_linked | The device is already linked to this terminal. |
409 | physical_pos.terminal.link_conflict | The device is linked to another terminal. |
409 | physical_pos.terminal.device_not_linked | There is no link to close. |
409 | physical_pos.device.retired | The device is retired. |
409 | physical_pos.version_conflict | The terminal's rowVersion is not current. |
409 | physical_pos.idempotency_mismatch | The same Idempotency-Key was used with a different request. |
429 | RATE_LIMITED | Write limit exceeded. → Rate limits |
All codes: Error Codes.