Logical Terminals
- 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 logical terminal is the identity that accepts payments: the terminal code, the POS vendor, the vendor-side terminal identifiers and the merchant and terminal numbers at the banks live on this record. When the physical device changes, the terminal and its bank details stay the same. → Core concepts
Terminals live in your key's environment: a key sees only the terminals in its own environment. A test key can only read terminals; write requests get 403 physical_pos.environment.test_key_read_only. → Environments
| Method | Path | Scope |
|---|---|---|
GET | /api/v1/physical-pos/terminals | physical_pos:view |
POST | /api/v1/physical-pos/terminals | physical_pos:manage |
GET | /api/v1/physical-pos/terminals/{terminalId} | physical_pos:view |
PATCH | /api/v1/physical-pos/terminals/{terminalId} | physical_pos:manage |
POST | /api/v1/physical-pos/terminals/{terminalId}/close | physical_pos:manage |
GET | /api/v1/physical-pos/terminals/{terminalId}/audit | physical_pos:view |
Bank rows: Bank Rows · Device link: Device Link. The cURL examples compute the signature with lunixi-sign.sh: Authentication › Signing helper.
Terminal object
| Field | Type | Description |
|---|---|---|
id | string (UUID) | Terminal ID. |
terminalCode | string | Terminal code; upper-case, unique within the environment. Does not change after creation. |
displayName | string | Display name. |
environment | string | TEST or LIVE; your key's environment. |
vendorCode | string | null | The POS vendor's code (e.g. PAVO). null if not assigned. |
virtualSerial | string | null | The virtual serial number the vendor gives this terminal. |
vendorTerminalRef | string | null | This terminal's ID in the vendor's system. |
ownershipStatus | string | UNVERIFIED: it is not yet verified that the terminal is yours. VERIFIED: verified. |
terminalStatus | string | ACTIVE, SUSPENDED (temporarily stopped) or CLOSED (closed; irreversible). |
linkedDevice | object | null | The device linked to the terminal; null if none. See below. |
rowVersion | integer | Concurrency version. → rowVersion |
createdAt | string | Creation time. |
updatedAt | string | Last change time. |
closedAt | string | null | Closing time. |
linkedDevice
| Field | Type | Description |
|---|---|---|
linkId | string (UUID) | ID of the open device link. |
deviceId | string (UUID) | ID of the linked device. |
displayName | string | null | The device's display name. |
serialNumber | string | The device's serial number. |
lifecycleStatus | string | Device status: PENDING, ACTIVE, SUSPENDED, MAINTENANCE or RETIRED. |
linkedAt | string | When the link was opened. |
List terminals
GET /api/v1/physical-pos/terminals
| Query parameter | Type | Description |
|---|---|---|
status | string | ACTIVE, SUSPENDED or CLOSED. If omitted, all are returned. Any other value gets 400 physical_pos.input.filter_invalid. |
q | string | Records whose terminal code or display name starts with this value, at most 64 characters. |
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. |
source lunixi-sign.sh
REQ_PATH="/api/v1/physical-pos/terminals?status=ACTIVE&q=IST"
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-000000000301",
"terminalCode": "IST-KADIKOY-01",
"displayName": "Kadıköy Register 1",
"environment": "LIVE",
"vendorCode": "PAVO",
"virtualSerial": null,
"vendorTerminalRef": "PV-000123",
"ownershipStatus": "UNVERIFIED",
"terminalStatus": "ACTIVE",
"linkedDevice": null,
"rowVersion": 2,
"createdAt": "2026-01-01T07:20:00.000Z",
"updatedAt": "2026-01-02T08:00:00.000Z",
"closedAt": null
}
],
"pageInfo": { "hasMore": false, "nextCursor": null, "totalCount": null }
},
"pageInfo": { "hasMore": false, "nextCursor": null, "totalCount": null },
"statusCode": 200,
"requestId": "00000000-0000-0000-0000-000000000000"
}Create a terminal
POST /api/v1/physical-pos/terminals · Idempotency-Key required
| Field | Type | Required | Description |
|---|---|---|---|
code | string | Yes | Terminal code: A-Z, 0-9, ., _, -; at most 64 characters. Upper-cased. Unique within the environment; closed terminals keep their codes. |
displayName | string | Yes | Display name, at most 120 characters. Follows the text rules. |
vendorCode | string | Conditional | The code of an active vendor in the catalog. Required when you send virtualSerial or vendorTerminalRef. → Catalog › Vendors |
virtualSerial | string | No | The virtual serial from the vendor: A-Z, 0-9; at most 64 characters. Upper-cased. |
vendorTerminalRef | string | No | The terminal ID in the vendor's system: A-Z, a-z, 0-9, ., _, -; at most 128 characters. Case is kept. |
Do not send environment in the body; the terminal is created in your key's environment. A new terminal starts with terminalStatus: "ACTIVE" and ownershipStatus: "UNVERIFIED".
source lunixi-sign.sh
REQ_PATH="/api/v1/physical-pos/terminals"
BODY='{"code":"ist-kadikoy-01","displayName":"Kadıköy Register 1","vendorCode":"PAVO","vendorTerminalRef":"PV-000123"}'
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-000000000003" \
--data-raw "$BODY"{
"status": "success",
"code": "SUCCESS",
"data": {
"terminal": {
"id": "00000000-0000-0000-0000-000000000301",
"terminalCode": "IST-KADIKOY-01",
"displayName": "Kadıköy Register 1",
"environment": "LIVE",
"vendorCode": "PAVO",
"virtualSerial": null,
"vendorTerminalRef": "PV-000123",
"ownershipStatus": "UNVERIFIED",
"terminalStatus": "ACTIVE",
"linkedDevice": null,
"rowVersion": 1,
"createdAt": "2026-01-01T07:20:00.000Z",
"updatedAt": "2026-01-01T07:20:00.000Z",
"closedAt": null
},
"replayed": false
},
"statusCode": 201,
"requestId": "00000000-0000-0000-0000-000000000000"
}| Blocker | Error |
|---|---|
| A terminal with the same code exists in the same environment | 409 physical_pos.terminal.code_taken |
You have a terminal that is not closed with the same vendor and vendorTerminalRef | 409 physical_pos.terminal.vendor_ref_taken |
You have a terminal that is not closed with the same virtualSerial | 409 physical_pos.terminal.virtual_serial_taken |
virtualSerial or vendorTerminalRef given without vendorCode | 400 physical_pos.terminal.vendor_required |
| The vendor is not in the catalog or is disabled | 400 physical_pos.device.vendor_unknown |
Uniqueness of vendorTerminalRef and virtualSerial does not depend on the environment: you cannot use the same value on a test terminal and a live terminal at the same time. Closing a terminal frees these values.
Retrieve a terminal
GET /api/v1/physical-pos/terminals/{terminalId}
Returns the terminal object plus verification details and the first page of bank rows (at most 100 rows):
{
"status": "success",
"code": "SUCCESS",
"data": {
"terminal": {
"id": "00000000-0000-0000-0000-000000000301",
"terminalCode": "IST-KADIKOY-01",
"displayName": "Kadıköy Register 1",
"environment": "LIVE",
"vendorCode": "PAVO",
"virtualSerial": null,
"vendorTerminalRef": "PV-000123",
"ownershipStatus": "UNVERIFIED",
"terminalStatus": "ACTIVE",
"linkedDevice": null,
"rowVersion": 1,
"createdAt": "2026-01-01T07:20:00.000Z",
"updatedAt": "2026-01-01T07:20:00.000Z",
"closedAt": null,
"verifiedAt": null,
"verifiedVia": null
},
"acquirers": {
"items": [],
"pageInfo": { "hasMore": false, "nextCursor": null, "totalCount": null }
}
},
"statusCode": 200,
"requestId": "00000000-0000-0000-0000-000000000000"
}| Field | Description |
|---|---|
terminal.verifiedAt | When ownership was verified; null if not verified. |
terminal.verifiedVia | Source of the verification: DEVICE, VENDOR_ACCOUNT or OPERATOR; null if not verified. |
acquirers | The first page of bank rows. If acquirers.pageInfo.hasMore is true, read the rest from the bank row list with cursor. |
A terminal that does not exist, belongs to another organization or is in the other environment returns 404 physical_pos.terminal.not_found.
Update a terminal and assign a vendor
PATCH /api/v1/physical-pos/terminals/{terminalId}
| Field | Type | Required | Description |
|---|---|---|---|
rowVersion | integer | Yes | The rowVersion you last read. |
displayName | string | No | New display name, at most 120 characters. |
vendorCode | string | No | Assigns a vendor to a terminal created without one. See below. |
Send at least one of displayName and vendorCode; without either the request gets 400 physical_pos.input.empty_patch. If the values you send equal the current ones, the record does not change, rowVersion does not increase, no change-history event is written and 200 is returned.
A vendor is assigned once. You can assign a vendor with vendorCode to a terminal that has none; after that it does not change:
- The terminal must not have any bank rows yet.
- If a device is linked to the terminal, the assigned vendor must be the device's vendor; otherwise
400 physical_pos.terminal.device_vendor_mismatch. - Sending the same code to a terminal that already has that vendor is not a change. A different code gets
409 physical_pos.record.field_immutable. - Assigning a vendor to a vendor-less terminal that has bank rows also gets
409 physical_pos.record.field_immutable.
source lunixi-sign.sh
REQ_PATH="/api/v1/physical-pos/terminals/00000000-0000-0000-0000-000000000301"
BODY='{"rowVersion":1,"displayName":"Kadıköy Main Register"}'
lunixi_sign PATCH "$REQ_PATH" "$BODY"
curl -X PATCH "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: { "terminal": { … } }. A closed terminal cannot be updated: 409 physical_pos.terminal.closed.
Close a terminal
POST /api/v1/physical-pos/terminals/{terminalId}/close
| Field | Type | Required | Description |
|---|---|---|---|
rowVersion | integer | Yes | The rowVersion you last read. |
reasonCode | string | Yes | MERCHANT_REQUEST, CONTRACT_ENDED, BANK_TERMINATED, CREATED_IN_ERROR, DUPLICATE_RECORD, MIGRATED, OTHER. → Reason codes |
Closing is irreversible. In the same operation the terminal's active bank rows become DISABLED; if a device is linked to the terminal, the link is closed. A closed terminal cannot be updated, cannot get bank rows and cannot be linked to a device; its code stays in use.
source lunixi-sign.sh
REQ_PATH="/api/v1/physical-pos/terminals/00000000-0000-0000-0000-000000000301/close"
BODY='{"rowVersion":4,"reasonCode":"CONTRACT_ENDED"}'
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: { "terminal": { … } } with terminalStatus: "CLOSED", closedAt set and linkedDevice: null. If the terminal is already closed: 409 physical_pos.terminal.closed.
Change history
GET /api/v1/physical-pos/terminals/{terminalId}/audit — takes limit and cursor. Returns the terminal's own events together with the events of its bank rows:
{
"status": "success",
"code": "SUCCESS",
"data": {
"items": [
{
"id": "00000000-0000-0000-0000-000000000902",
"action": "physical_pos.audit.terminal_acquirer_added",
"entityType": "terminal_acquirer",
"entityId": "00000000-0000-0000-0000-000000000601",
"actorType": "API_KEY",
"actorId": "kid:mk_live_00000000",
"reasonCode": null,
"changes": {
"logicalTerminalId": "00000000-0000-0000-0000-000000000301",
"vendorAcquirerRef": "B01",
"acquirerNo": "0000",
"mappingStatus": "MAPPED",
"merchantNumber": "000000000000001",
"terminalNumber": "PS000001",
"currencyCount": 1,
"labelChanged": true,
"source": "MANUAL",
"verificationStatus": "UNVERIFIED",
"toStatus": "ACTIVE"
},
"environment": "LIVE",
"createdAt": "2026-01-01T07:30:00.000Z"
}
],
"pageInfo": { "hasMore": false, "nextCursor": null, "totalCount": null }
},
"pageInfo": { "hasMore": false, "nextCursor": null, "totalCount": null },
"statusCode": 200,
"requestId": "00000000-0000-0000-0000-000000000000"
}entityType | Events (action) |
|---|---|
logical_terminal | physical_pos.audit.logical_terminal_created, …logical_terminal_updated, …logical_terminal_closed, …logical_terminal_device_changed, …logical_terminal_device_unlinked |
terminal_acquirer | physical_pos.audit.terminal_acquirer_added, …terminal_acquirer_updated, …terminal_acquirer_disabled (when the terminal is closed) |
Field meanings: Common Rules › Change history.
Errors
| HTTP | code | When |
|---|---|---|
400 | physical_pos.idempotency.key_required / physical_pos.idempotency.key_invalid | Idempotency-Key missing or malformed on create. |
400 | physical_pos.input.code_invalid | code is malformed. |
400 | physical_pos.input.text_invalid | displayName breaks the rules. |
400 | physical_pos.input.filter_invalid | Invalid list filter (status, q). |
400 | physical_pos.input.vendor_code_invalid | vendorCode is malformed. |
400 | physical_pos.input.virtual_serial_invalid | virtualSerial is malformed. |
400 | physical_pos.input.ref_invalid | vendorTerminalRef is malformed. |
400 | physical_pos.input.reason_code_invalid | reasonCode is not on the list. |
400 | physical_pos.device.vendor_unknown | The vendor is not in the catalog or is disabled. |
400 | physical_pos.terminal.vendor_required | A vendor identifier was given without vendorCode. |
400 | physical_pos.terminal.device_vendor_mismatch | The assigned vendor differs from the linked device's vendor. |
400 | physical_pos.input.empty_patch | Empty update. |
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. |
409 | physical_pos.terminal.code_taken | The code is in use in this environment. |
409 | physical_pos.terminal.vendor_ref_taken | vendorTerminalRef is in use on an open terminal. |
409 | physical_pos.terminal.virtual_serial_taken | virtualSerial is in use on an open terminal. |
409 | physical_pos.terminal.closed | Tried to change a closed terminal. |
409 | physical_pos.record.field_immutable | The vendor is already assigned or the terminal has bank rows. |
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 | Terminal family write limit exceeded. → Rate limits |
All codes: Error Codes.