Devices
- 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.
A device is the physical POS hardware in your stores: a record with a vendor, a catalog model and a serial number. You assign a device to a store (and optionally to a register); to link it to a logical terminal you use the device's id as deviceId on the device link endpoints. → Core concepts
Devices live in your key's environment; a device in the other environment returns 404. A test key can only read devices. → Environments
| Method | Path | Scope |
|---|---|---|
GET | /api/v1/physical-pos/devices | physical_pos:view |
POST | /api/v1/physical-pos/devices | physical_pos:manage |
GET | /api/v1/physical-pos/devices/{deviceId} | physical_pos:view |
PATCH | /api/v1/physical-pos/devices/{deviceId} | physical_pos:manage |
POST | /api/v1/physical-pos/devices/{deviceId}/assignment | physical_pos:manage |
POST | /api/v1/physical-pos/devices/{deviceId}/assignment/close | physical_pos:manage |
POST | /api/v1/physical-pos/devices/{deviceId}/retire | physical_pos:manage |
GET | /api/v1/physical-pos/devices/{deviceId}/assignments | physical_pos:view |
GET | /api/v1/physical-pos/devices/{deviceId}/audit | physical_pos:view |
The cURL examples compute the signature with lunixi-sign.sh: Authentication › Signing helper.
Device object
List rows carry the summary fields below; detail and write responses carry the summary fields plus the detail fields.
| Field | Type | Description |
|---|---|---|
id | string (UUID) | Device ID; used as deviceId on the device link. |
displayName | string | null | Display name. |
vendorCode | string | Vendor code (e.g. PAVO). |
modelId | string (UUID) | ID of the catalog model. |
modelDisplayName | string | Model name. |
modelImage | object | null | Model image, { assetId, url }. → Image object |
vendorFallbackImage | object | null | The vendor's fallback image. |
deviceType | string | PHYSICAL or SOFTPOS. |
formFactor | string | COUNTERTOP, PORTABLE, MOBILE, UNATTENDED or PINPAD. |
serialNumber | string | Serial number, as you registered it. |
environment | string | TEST or LIVE; your key's environment. |
lifecycleStatus | string | PENDING, ACTIVE, SUSPENDED, MAINTENANCE or RETIRED. A newly registered device is PENDING. |
ownershipStatus | string | UNVERIFIED: it is not verified that the device is yours. VERIFIED: verified. A newly registered device is UNVERIFIED. |
integrationChannel | string | Channel used to talk to the device: APP2APP, LOCAL_REST, VENDOR_CLOUD or UNSET. |
appMode | string | null | Mode of the app on the device: BRIDGE, CASHIER, KIOSK, MINIBUS or TAXI. Only on the APP2APP and LOCAL_REST channels. |
store | object | null | Assigned store { id, code, name }; null when in storage. |
register | object | null | Assigned register { id, code, name }. |
logicalTerminal | object | null | Linked terminal { id, code }. |
createdAt | string | Creation time. |
Detail fields
| Field | Type | Description |
|---|---|---|
vendorDeviceRef | string | The ID the vendor gives the device. |
channelVariant | string | null | Vendor-specific variant of the channel. |
externalRef | string | null | The device ID in your own system. |
hardwareManufacturer | string | null | Hardware manufacturer. |
hardwareModel | string | null | Hardware model. |
verifiedAt | string | null | When ownership was verified. |
verifiedVia | string | null | Source of the verification: ACTIVATION, VENDOR_ACCOUNT or OPERATOR. |
vendorAccount | object | null | The vendor account the device belongs to, { id, label }. |
firstActivatedAt | string | null | When the device first became ACTIVE. |
retiredAt | string | null | When the device was retired. |
source | string | Where the record came from: MANUAL (panel or API) or VENDOR_ACCOUNT. |
fieldSources | object | Field name → that field's source (MANUAL, VENDOR_ACCOUNT, DEVICE_REPORTED). |
drift | array | Fields that differ between sources; item shape { field, source, observedAt }. |
activeKey | object | null | The device's active identity key, { keyId, activatedAt }. |
openActivationCode | object | null | An open activation code that has not expired, { expiresAt }. The code itself is never returned. |
rowVersion | integer | Concurrency version. → rowVersion |
updatedAt | string | Last change time. |
List devices
GET /api/v1/physical-pos/devices
| Query parameter | Type | Description |
|---|---|---|
storeId | string (UUID) | Devices assigned to this store. |
registerId | string (UUID) | Devices assigned to this register. |
vendorCode | string | Vendor code. |
deviceModelId | string (UUID) | Catalog model. |
deviceType | string | PHYSICAL or SOFTPOS. |
lifecycleStatus | string | PENDING, ACTIVE, SUSPENDED, MAINTENANCE or RETIRED. |
ownershipStatus | string | UNVERIFIED or VERIFIED. |
integrationChannel | string | APP2APP, LOCAL_REST, VENDOR_CLOUD or UNSET. |
q | string | Devices whose serial number, vendor ID, external reference or display name starts with this value, at most 64 characters. Searches only your devices. |
limit | integer | Records per page, 1–100. Default 25. |
cursor | string | pageInfo.nextCursor from the previous response. |
includeTotal | boolean | When true, pageInfo.totalCount is filled. |
An invalid filter value (status outside the set, malformed ID, unknown vendor code, q breaking the rules or longer than 64 characters) gets 400 physical_pos.input.filter_invalid; details.field names the filter. Value sets are case-sensitive.
source lunixi-sign.sh
REQ_PATH="/api/v1/physical-pos/devices?storeId=00000000-0000-0000-0000-000000000101&lifecycleStatus=PENDING"
lunixi_sign GET "$REQ_PATH"
curl "https://api-gateway.lunixi.com$REQ_PATH" \
-H "X-Key-Id: $LUNIXI_KEY_ID" \
-H "X-Date: $DATE" \
-H "X-Nonce: $NONCE" \
-H "X-Signature: $SIGNATURE"{
"status": "success",
"code": "SUCCESS",
"data": {
"items": [
{
"id": "00000000-0000-0000-0000-000000000401",
"displayName": "Register 1 POS",
"vendorCode": "PAVO",
"modelId": "00000000-0000-4000-8000-000000000b01",
"modelDisplayName": "Example Model M1",
"modelImage": {
"assetId": "00000000-0000-4000-8000-000000000a02",
"url": "https://img.lunixi.com/00000000-0000-4000-8000-000000000a02"
},
"vendorFallbackImage": null,
"deviceType": "PHYSICAL",
"formFactor": "COUNTERTOP",
"serialNumber": "SN0000000001",
"environment": "LIVE",
"lifecycleStatus": "PENDING",
"ownershipStatus": "UNVERIFIED",
"integrationChannel": "APP2APP",
"appMode": "CASHIER",
"store": { "id": "00000000-0000-0000-0000-000000000101", "code": "IST-KADIKOY", "name": "Kadıköy Store" },
"register": { "id": "00000000-0000-0000-0000-000000000201", "code": "KASA-1", "name": "Front Register" },
"logicalTerminal": null,
"createdAt": "2026-01-02T08:00:00.000Z"
}
],
"pageInfo": { "hasMore": false, "nextCursor": null, "totalCount": null }
},
"pageInfo": { "hasMore": false, "nextCursor": null, "totalCount": null },
"statusCode": 200,
"requestId": "00000000-0000-0000-0000-000000000000"
}Register a device
POST /api/v1/physical-pos/devices · Idempotency-Key required
| Field | Type | Required | Description |
|---|---|---|---|
vendorCode | string | Yes | Code of an active vendor in the catalog. → Catalog › Vendors |
deviceModelId | string (UUID) | Yes | An active model of the same vendor. → Catalog › Device models |
serialNumber | string | Yes | The device's serial number; the format depends on the vendor, see below. |
displayName | string | No | Display name, at most 120 characters. Follows the text rules. |
externalRef | string | No | The ID in your own system: printable ASCII without spaces, at most 128 characters. Unique in your environment. |
storeId | string (UUID) | No | If given, the device is assigned to this store in the same operation (reason INITIAL_SETUP). |
registerId | string (UUID) | No | A register of the store; cannot be sent without storeId. |
integrationChannel | string | Yes | APP2APP, LOCAL_REST, VENDOR_CLOUD or UNSET. |
channelVariant | string | No | Channel variant: starts with a letter, A-Z, 0-9, _; at most 32 characters. Upper-cased. |
appMode | string | No | BRIDGE, CASHIER, KIOSK, MINIBUS or TAXI. Only on the APP2APP and LOCAL_REST channels. |
Serial number format:
| Vendor | Rule |
|---|---|
CASTLES | 12 or 16 digits. A 16-digit serial starting with 0000 counts as the same device as its last 12 digits. |
PAVO | A-Z and 0-9, 4–64 characters; case-insensitive. |
PLUSPAY | Printable ASCII without spaces, at most 128 characters; case is kept and the value is not trimmed. |
A malformed serial gets 400 physical_pos.input.serial_invalid. The serial number is unique in your account (regardless of vendor and environment); if you have a device with the same serial: 409 physical_pos.device.serial_taken. The serial of a retired device can be registered again.
A new device starts with lifecycleStatus: "PENDING", ownershipStatus: "UNVERIFIED" and source: "MANUAL".
source lunixi-sign.sh
REQ_PATH="/api/v1/physical-pos/devices"
BODY='{"vendorCode":"PAVO","deviceModelId":"00000000-0000-4000-8000-000000000b01","serialNumber":"SN0000000001","displayName":"Register 1 POS","storeId":"00000000-0000-0000-0000-000000000101","registerId":"00000000-0000-0000-0000-000000000201","integrationChannel":"APP2APP","appMode":"CASHIER"}'
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-000000000006" \
--data-raw "$BODY"A new device returns 201; a repeat with the same key and the same body returns 200. data: { "device": { … }, "replayed": false }; device is in the detail shape.
| Blocker | Error |
|---|---|
| The vendor is not in the catalog or is disabled | 400 physical_pos.device.vendor_unknown |
| The model does not exist | 404 physical_pos.device_model.not_found |
| The model belongs to another vendor | 400 physical_pos.device.model_vendor_mismatch |
The model is deprecated (DEPRECATED) | 409 physical_pos.device.model_deprecated |
appMode cannot be used on this channel | 400 physical_pos.device.app_mode_invalid |
registerId without storeId | 400 physical_pos.validation.invalid |
| The store or register does not exist, or the register is not in this store | 404 physical_pos.store.not_found / 404 physical_pos.register.not_found |
| The store or register is archived | 409 physical_pos.store.archived / 409 physical_pos.register.archived |
externalRef is in use | 409 physical_pos.device.external_ref_taken |
Retrieve a device
GET /api/v1/physical-pos/devices/{deviceId}
{
"status": "success",
"code": "SUCCESS",
"data": {
"device": {
"id": "00000000-0000-0000-0000-000000000401",
"displayName": "Register 1 POS",
"vendorCode": "PAVO",
"modelId": "00000000-0000-4000-8000-000000000b01",
"modelDisplayName": "Example Model M1",
"modelImage": null,
"vendorFallbackImage": null,
"deviceType": "PHYSICAL",
"formFactor": "COUNTERTOP",
"serialNumber": "SN0000000001",
"environment": "LIVE",
"lifecycleStatus": "PENDING",
"ownershipStatus": "UNVERIFIED",
"integrationChannel": "APP2APP",
"appMode": "CASHIER",
"store": { "id": "00000000-0000-0000-0000-000000000101", "code": "IST-KADIKOY", "name": "Kadıköy Store" },
"register": { "id": "00000000-0000-0000-0000-000000000201", "code": "KASA-1", "name": "Front Register" },
"logicalTerminal": null,
"createdAt": "2026-01-02T08:00:00.000Z",
"vendorDeviceRef": "SN0000000001",
"channelVariant": null,
"externalRef": null,
"hardwareManufacturer": null,
"hardwareModel": null,
"verifiedAt": null,
"verifiedVia": null,
"vendorAccount": null,
"firstActivatedAt": null,
"retiredAt": null,
"source": "MANUAL",
"fieldSources": { "displayName": "MANUAL" },
"drift": [],
"activeKey": null,
"openActivationCode": null,
"rowVersion": 1,
"updatedAt": "2026-01-02T08:00:00.000Z"
}
},
"statusCode": 200,
"requestId": "00000000-0000-0000-0000-000000000000"
}A device that does not exist, belongs to another organization or is in the other environment returns 404 physical_pos.device.not_found.
Update a device
PATCH /api/v1/physical-pos/devices/{deviceId}
| Field | Type | Required | Description |
|---|---|---|---|
rowVersion | integer | Yes | The rowVersion you last read. |
displayName | string | null | No | null clears the field. |
externalRef | string | null | No | null clears the field. |
integrationChannel | string | No | New channel; cannot be cleared. |
channelVariant | string | null | No | null clears the field. |
appMode | string | null | No | null clears the field. |
- Fields you do not send are not changed.
serialNumber,vendorCodeanddeviceModelIdcannot be changed; sending them in the body returns400 VALIDATION_ERROR. - A request with no field to change gets
400 physical_pos.input.empty_patch. If the values you send equal the current ones, the record does not change and200is returned. - The source (
fieldSources) of the fields you change becomesMANUAL. - A retired device cannot be updated:
409 physical_pos.device.retired.
Response: { "device": { … } }.
Assign to a store or register
POST /api/v1/physical-pos/devices/{deviceId}/assignment
| Field | Type | Required | Description |
|---|---|---|---|
rowVersion | integer | Yes | The device's rowVersion you last read. |
storeId | string (UUID) | Yes | Target store. |
registerId | string (UUID) | null | No | A register of the target store; without it the device is assigned to the store without a register. |
reasonCode | string | Yes | If the device has no open assignment: INITIAL_SETUP, CORRECTION, OTHER; if it has one (a move): RELOCATION, REGISTER_CHANGE, DEVICE_REPLACEMENT, CORRECTION, OTHER. |
If the device has an open assignment, it is closed and a new one is opened; the assignment history is kept.
source lunixi-sign.sh
REQ_PATH="/api/v1/physical-pos/devices/00000000-0000-0000-0000-000000000401/assignment"
BODY='{"rowVersion":1,"storeId":"00000000-0000-0000-0000-000000000102","reasonCode":"RELOCATION"}'
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"Response (200): { "device": { … }, "assignment": { … }, "previousAssignmentId": "…" }. assignment is the new assignment (assignment object); previousAssignmentId is the ID of the closed assignment, null on a first assignment.
| Blocker | Error |
|---|---|
| The store does not exist | 404 physical_pos.store.not_found |
| The register does not exist or is not in this store | 404 physical_pos.register.not_found |
| The store or register is archived | 409 physical_pos.store.archived / 409 physical_pos.register.archived |
| The device is already in the same store and register | 409 physical_pos.device.already_assigned |
| The device is retired | 409 physical_pos.device.retired |
Move to storage
POST /api/v1/physical-pos/devices/{deviceId}/assignment/close · Idempotency-Key required
Closes the device's open assignment; the device is left without a store (store and register are null). The device's terminal link does not change. This is how you take a device out of a store without deleting it — for example, for the devices of a store you want to archive.
| Field | Type | Required | Description |
|---|---|---|---|
rowVersion | integer | Yes | The device's rowVersion you last read. |
reasonCode | string | Yes | STORE_CLOSED, TEMPORARILY_CLOSED, MOVED_TO_STORAGE, AWAITING_RELOCATION, CORRECTION, OTHER. |
Response (200): { "device": { … }, "closedAssignment": { … }, "replayed": false }. A repeat with the same Idempotency-Key and the same body returns replayed: true. A device without an open assignment gets 409 physical_pos.device.not_assigned.
Retire a device
POST /api/v1/physical-pos/devices/{deviceId}/retire · Idempotency-Key required
| Field | Type | Required | Description |
|---|---|---|---|
rowVersion | integer | Yes | The device's rowVersion you last read. |
reasonCode | string | Yes | DEVICE_FAULT, DEVICE_LOST, DEVICE_STOLEN, END_OF_LIFE, RETURNED_TO_VENDOR, REPLACED, CREATED_IN_ERROR, DUPLICATE_RECORD, OTHER. |
Retirement is irreversible. In the same operation the device's open assignment and terminal link are closed. The rowVersion of the terminal whose link is closed increases, and a physical_pos.audit.logical_terminal_device_unlinked event is written to the terminal's change history. A retired device cannot be updated, assigned or linked to a terminal.
Response (200): { "device": { … }, "replayed": false } with lifecycleStatus: "RETIRED" and retiredAt set. If the device has an active identity key or an activation code that has not expired: 409 physical_pos.device.identity_active (details.reason: active_key or open_activation_code).
Assignment history
GET /api/v1/physical-pos/devices/{deviceId}/assignments — takes limit and cursor, newest assignment first.
| Field | Type | Description |
|---|---|---|
id | string (UUID) | Assignment ID. |
deviceId | string (UUID) | Device. |
store | object | null | { id, code, name } |
register | object | null | { id, code, name } |
validFrom | string | When the assignment started. |
validTo | string | null | When the assignment was closed; null for an open assignment. |
reasonCode | string | Reason of the operation that opened the assignment. |
createdByType | string | USER, API_KEY, OPERATOR or SYSTEM. |
createdById | string | ID of who opened the assignment; operator for platform-side operations. |
createdAt | string | Record creation time. |
Change history
GET /api/v1/physical-pos/devices/{deviceId}/audit — takes limit and cursor; the response shape is the same as the store history.
Device events: physical_pos.audit.device_created, …device_updated, …device_assigned, …device_unassigned, …device_lifecycle_changed, …device_retired, …device_ownership_verified; when the device link changes, …device_terminal_linked and …device_terminal_unlinked. Events of platform-side operations return actorId operator and requestId null.
Errors
| HTTP | code | When |
|---|---|---|
400 | physical_pos.idempotency.key_required / physical_pos.idempotency.key_invalid | Idempotency-Key missing or malformed on register, move to storage or retire. |
400 | physical_pos.input.filter_invalid | Invalid list filter. |
400 | physical_pos.input.serial_invalid | The serial number does not match the vendor's format. |
400 | physical_pos.input.code_invalid | integrationChannel or appMode outside the set. |
400 | physical_pos.input.channel_variant_invalid | channelVariant is malformed. |
400 | physical_pos.input.text_invalid / physical_pos.input.ref_invalid | displayName or externalRef breaks the rules. |
400 | physical_pos.input.reason_code_invalid | reasonCode is not on the list or does not fit the operation. |
400 | physical_pos.input.empty_patch | Empty update. |
400 | physical_pos.device.vendor_unknown | The vendor is not in the catalog or is disabled. |
400 | physical_pos.device.model_vendor_mismatch | The model belongs to another vendor. |
400 | physical_pos.device.app_mode_invalid | appMode cannot be used on this channel. |
403 | physical_pos.environment.test_key_read_only | Write request made with a test key. |
404 | physical_pos.device.not_found | The device does not exist, is not yours or is in the other environment. |
404 | physical_pos.device_model.not_found | The model does not exist. |
404 | physical_pos.store.not_found / physical_pos.register.not_found | The target store or register does not exist. |
409 | physical_pos.device.serial_taken | The serial number is in use. |
409 | physical_pos.device.external_ref_taken | externalRef is in use. |
409 | physical_pos.device.model_deprecated | The model is deprecated. |
409 | physical_pos.store.archived / physical_pos.register.archived | The target store or register is archived. |
409 | physical_pos.device.already_assigned | The device is already in this store and register. |
409 | physical_pos.device.not_assigned | There is no open assignment to close. |
409 | physical_pos.device.identity_active | Tried to retire a device with an active identity. |
409 | physical_pos.device.retired | Tried to change a retired device. |
409 | physical_pos.version_conflict | rowVersion is not current. |
409 | physical_pos.idempotency_mismatch | The same Idempotency-Key was used with a different request. |
429 | RATE_LIMITED | Device write limit exceeded. → Rate limits |
503 | physical_pos.service.busy | The record is busy with another operation; retry the same request. |
All codes: Error Codes.