Physical POS
API Reference
Devices

Devices

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.

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

MethodPathScope
GET/api/v1/physical-pos/devicesphysical_pos:view
POST/api/v1/physical-pos/devicesphysical_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}/assignmentphysical_pos:manage
POST/api/v1/physical-pos/devices/{deviceId}/assignment/closephysical_pos:manage
POST/api/v1/physical-pos/devices/{deviceId}/retirephysical_pos:manage
GET/api/v1/physical-pos/devices/{deviceId}/assignmentsphysical_pos:view
GET/api/v1/physical-pos/devices/{deviceId}/auditphysical_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.

FieldTypeDescription
idstring (UUID)Device ID; used as deviceId on the device link.
displayNamestring | nullDisplay name.
vendorCodestringVendor code (e.g. PAVO).
modelIdstring (UUID)ID of the catalog model.
modelDisplayNamestringModel name.
modelImageobject | nullModel image, { assetId, url }. → Image object
vendorFallbackImageobject | nullThe vendor's fallback image.
deviceTypestringPHYSICAL or SOFTPOS.
formFactorstringCOUNTERTOP, PORTABLE, MOBILE, UNATTENDED or PINPAD.
serialNumberstringSerial number, as you registered it.
environmentstringTEST or LIVE; your key's environment.
lifecycleStatusstringPENDING, ACTIVE, SUSPENDED, MAINTENANCE or RETIRED. A newly registered device is PENDING.
ownershipStatusstringUNVERIFIED: it is not verified that the device is yours. VERIFIED: verified. A newly registered device is UNVERIFIED.
integrationChannelstringChannel used to talk to the device: APP2APP, LOCAL_REST, VENDOR_CLOUD or UNSET.
appModestring | nullMode of the app on the device: BRIDGE, CASHIER, KIOSK, MINIBUS or TAXI. Only on the APP2APP and LOCAL_REST channels.
storeobject | nullAssigned store { id, code, name }; null when in storage.
registerobject | nullAssigned register { id, code, name }.
logicalTerminalobject | nullLinked terminal { id, code }.
createdAtstringCreation time.

Detail fields

FieldTypeDescription
vendorDeviceRefstringThe ID the vendor gives the device.
channelVariantstring | nullVendor-specific variant of the channel.
externalRefstring | nullThe device ID in your own system.
hardwareManufacturerstring | nullHardware manufacturer.
hardwareModelstring | nullHardware model.
verifiedAtstring | nullWhen ownership was verified.
verifiedViastring | nullSource of the verification: ACTIVATION, VENDOR_ACCOUNT or OPERATOR.
vendorAccountobject | nullThe vendor account the device belongs to, { id, label }.
firstActivatedAtstring | nullWhen the device first became ACTIVE.
retiredAtstring | nullWhen the device was retired.
sourcestringWhere the record came from: MANUAL (panel or API) or VENDOR_ACCOUNT.
fieldSourcesobjectField name → that field's source (MANUAL, VENDOR_ACCOUNT, DEVICE_REPORTED).
driftarrayFields that differ between sources; item shape { field, source, observedAt }.
activeKeyobject | nullThe device's active identity key, { keyId, activatedAt }.
openActivationCodeobject | nullAn open activation code that has not expired, { expiresAt }. The code itself is never returned.
rowVersionintegerConcurrency version. → rowVersion
updatedAtstringLast change time.

List devices

GET /api/v1/physical-pos/devices

Query parameterTypeDescription
storeIdstring (UUID)Devices assigned to this store.
registerIdstring (UUID)Devices assigned to this register.
vendorCodestringVendor code.
deviceModelIdstring (UUID)Catalog model.
deviceTypestringPHYSICAL or SOFTPOS.
lifecycleStatusstringPENDING, ACTIVE, SUSPENDED, MAINTENANCE or RETIRED.
ownershipStatusstringUNVERIFIED or VERIFIED.
integrationChannelstringAPP2APP, LOCAL_REST, VENDOR_CLOUD or UNSET.
qstringDevices whose serial number, vendor ID, external reference or display name starts with this value, at most 64 characters. Searches only your devices.
limitintegerRecords per page, 1–100. Default 25.
cursorstringpageInfo.nextCursor from the previous response.
includeTotalbooleanWhen 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

FieldTypeRequiredDescription
vendorCodestringYesCode of an active vendor in the catalog. → Catalog › Vendors
deviceModelIdstring (UUID)YesAn active model of the same vendor. → Catalog › Device models
serialNumberstringYesThe device's serial number; the format depends on the vendor, see below.
displayNamestringNoDisplay name, at most 120 characters. Follows the text rules.
externalRefstringNoThe ID in your own system: printable ASCII without spaces, at most 128 characters. Unique in your environment.
storeIdstring (UUID)NoIf given, the device is assigned to this store in the same operation (reason INITIAL_SETUP).
registerIdstring (UUID)NoA register of the store; cannot be sent without storeId.
integrationChannelstringYesAPP2APP, LOCAL_REST, VENDOR_CLOUD or UNSET.
channelVariantstringNoChannel variant: starts with a letter, A-Z, 0-9, _; at most 32 characters. Upper-cased.
appModestringNoBRIDGE, CASHIER, KIOSK, MINIBUS or TAXI. Only on the APP2APP and LOCAL_REST channels.

Serial number format:

VendorRule
CASTLES12 or 16 digits. A 16-digit serial starting with 0000 counts as the same device as its last 12 digits.
PAVOA-Z and 0-9, 4–64 characters; case-insensitive.
PLUSPAYPrintable 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.

BlockerError
The vendor is not in the catalog or is disabled400 physical_pos.device.vendor_unknown
The model does not exist404 physical_pos.device_model.not_found
The model belongs to another vendor400 physical_pos.device.model_vendor_mismatch
The model is deprecated (DEPRECATED)409 physical_pos.device.model_deprecated
appMode cannot be used on this channel400 physical_pos.device.app_mode_invalid
registerId without storeId400 physical_pos.validation.invalid
The store or register does not exist, or the register is not in this store404 physical_pos.store.not_found / 404 physical_pos.register.not_found
The store or register is archived409 physical_pos.store.archived / 409 physical_pos.register.archived
externalRef is in use409 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}

FieldTypeRequiredDescription
rowVersionintegerYesThe rowVersion you last read.
displayNamestring | nullNonull clears the field.
externalRefstring | nullNonull clears the field.
integrationChannelstringNoNew channel; cannot be cleared.
channelVariantstring | nullNonull clears the field.
appModestring | nullNonull clears the field.
  • Fields you do not send are not changed. serialNumber, vendorCode and deviceModelId cannot be changed; sending them in the body returns 400 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 and 200 is returned.
  • The source (fieldSources) of the fields you change becomes MANUAL.
  • 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

FieldTypeRequiredDescription
rowVersionintegerYesThe device's rowVersion you last read.
storeIdstring (UUID)YesTarget store.
registerIdstring (UUID) | nullNoA register of the target store; without it the device is assigned to the store without a register.
reasonCodestringYesIf 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.

BlockerError
The store does not exist404 physical_pos.store.not_found
The register does not exist or is not in this store404 physical_pos.register.not_found
The store or register is archived409 physical_pos.store.archived / 409 physical_pos.register.archived
The device is already in the same store and register409 physical_pos.device.already_assigned
The device is retired409 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.

FieldTypeRequiredDescription
rowVersionintegerYesThe device's rowVersion you last read.
reasonCodestringYesSTORE_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

FieldTypeRequiredDescription
rowVersionintegerYesThe device's rowVersion you last read.
reasonCodestringYesDEVICE_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.

FieldTypeDescription
idstring (UUID)Assignment ID.
deviceIdstring (UUID)Device.
storeobject | null{ id, code, name }
registerobject | null{ id, code, name }
validFromstringWhen the assignment started.
validTostring | nullWhen the assignment was closed; null for an open assignment.
reasonCodestringReason of the operation that opened the assignment.
createdByTypestringUSER, API_KEY, OPERATOR or SYSTEM.
createdByIdstringID of who opened the assignment; operator for platform-side operations.
createdAtstringRecord 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

HTTPcodeWhen
400physical_pos.idempotency.key_required / physical_pos.idempotency.key_invalidIdempotency-Key missing or malformed on register, move to storage or retire.
400physical_pos.input.filter_invalidInvalid list filter.
400physical_pos.input.serial_invalidThe serial number does not match the vendor's format.
400physical_pos.input.code_invalidintegrationChannel or appMode outside the set.
400physical_pos.input.channel_variant_invalidchannelVariant is malformed.
400physical_pos.input.text_invalid / physical_pos.input.ref_invaliddisplayName or externalRef breaks the rules.
400physical_pos.input.reason_code_invalidreasonCode is not on the list or does not fit the operation.
400physical_pos.input.empty_patchEmpty update.
400physical_pos.device.vendor_unknownThe vendor is not in the catalog or is disabled.
400physical_pos.device.model_vendor_mismatchThe model belongs to another vendor.
400physical_pos.device.app_mode_invalidappMode cannot be used on this channel.
403physical_pos.environment.test_key_read_onlyWrite request made with a test key.
404physical_pos.device.not_foundThe device does not exist, is not yours or is in the other environment.
404physical_pos.device_model.not_foundThe model does not exist.
404physical_pos.store.not_found / physical_pos.register.not_foundThe target store or register does not exist.
409physical_pos.device.serial_takenThe serial number is in use.
409physical_pos.device.external_ref_takenexternalRef is in use.
409physical_pos.device.model_deprecatedThe model is deprecated.
409physical_pos.store.archived / physical_pos.register.archivedThe target store or register is archived.
409physical_pos.device.already_assignedThe device is already in this store and register.
409physical_pos.device.not_assignedThere is no open assignment to close.
409physical_pos.device.identity_activeTried to retire a device with an active identity.
409physical_pos.device.retiredTried to change a retired device.
409physical_pos.version_conflictrowVersion is not current.
409physical_pos.idempotency_mismatchThe same Idempotency-Key was used with a different request.
429RATE_LIMITEDDevice write limit exceeded. → Rate limits
503physical_pos.service.busyThe record is busy with another operation; retry the same request.

All codes: Error Codes.