Physical POS
API Reference
Logical Terminals

Logical Terminals

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 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

MethodPathScope
GET/api/v1/physical-pos/terminalsphysical_pos:view
POST/api/v1/physical-pos/terminalsphysical_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}/closephysical_pos:manage
GET/api/v1/physical-pos/terminals/{terminalId}/auditphysical_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

FieldTypeDescription
idstring (UUID)Terminal ID.
terminalCodestringTerminal code; upper-case, unique within the environment. Does not change after creation.
displayNamestringDisplay name.
environmentstringTEST or LIVE; your key's environment.
vendorCodestring | nullThe POS vendor's code (e.g. PAVO). null if not assigned.
virtualSerialstring | nullThe virtual serial number the vendor gives this terminal.
vendorTerminalRefstring | nullThis terminal's ID in the vendor's system.
ownershipStatusstringUNVERIFIED: it is not yet verified that the terminal is yours. VERIFIED: verified.
terminalStatusstringACTIVE, SUSPENDED (temporarily stopped) or CLOSED (closed; irreversible).
linkedDeviceobject | nullThe device linked to the terminal; null if none. See below.
rowVersionintegerConcurrency version. → rowVersion
createdAtstringCreation time.
updatedAtstringLast change time.
closedAtstring | nullClosing time.

linkedDevice

FieldTypeDescription
linkIdstring (UUID)ID of the open device link.
deviceIdstring (UUID)ID of the linked device.
displayNamestring | nullThe device's display name.
serialNumberstringThe device's serial number.
lifecycleStatusstringDevice status: PENDING, ACTIVE, SUSPENDED, MAINTENANCE or RETIRED.
linkedAtstringWhen the link was opened.

List terminals

GET /api/v1/physical-pos/terminals

Query parameterTypeDescription
statusstringACTIVE, SUSPENDED or CLOSED. If omitted, all are returned. Any other value gets 400 physical_pos.input.filter_invalid.
qstringRecords whose terminal code or display name starts with this value, at most 64 characters.
limitintegerRecords per page, 1–100. Default 25.
cursorstringpageInfo.nextCursor from the previous response.
includeTotalbooleanWhen 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

FieldTypeRequiredDescription
codestringYesTerminal code: A-Z, 0-9, ., _, -; at most 64 characters. Upper-cased. Unique within the environment; closed terminals keep their codes.
displayNamestringYesDisplay name, at most 120 characters. Follows the text rules.
vendorCodestringConditionalThe code of an active vendor in the catalog. Required when you send virtualSerial or vendorTerminalRef. → Catalog › Vendors
virtualSerialstringNoThe virtual serial from the vendor: A-Z, 0-9; at most 64 characters. Upper-cased.
vendorTerminalRefstringNoThe 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"
}
BlockerError
A terminal with the same code exists in the same environment409 physical_pos.terminal.code_taken
You have a terminal that is not closed with the same vendor and vendorTerminalRef409 physical_pos.terminal.vendor_ref_taken
You have a terminal that is not closed with the same virtualSerial409 physical_pos.terminal.virtual_serial_taken
virtualSerial or vendorTerminalRef given without vendorCode400 physical_pos.terminal.vendor_required
The vendor is not in the catalog or is disabled400 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"
}
FieldDescription
terminal.verifiedAtWhen ownership was verified; null if not verified.
terminal.verifiedViaSource of the verification: DEVICE, VENDOR_ACCOUNT or OPERATOR; null if not verified.
acquirersThe 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}

FieldTypeRequiredDescription
rowVersionintegerYesThe rowVersion you last read.
displayNamestringNoNew display name, at most 120 characters.
vendorCodestringNoAssigns 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

FieldTypeRequiredDescription
rowVersionintegerYesThe rowVersion you last read.
reasonCodestringYesMERCHANT_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"
}
entityTypeEvents (action)
logical_terminalphysical_pos.audit.logical_terminal_created, …logical_terminal_updated, …logical_terminal_closed, …logical_terminal_device_changed, …logical_terminal_device_unlinked
terminal_acquirerphysical_pos.audit.terminal_acquirer_added, …terminal_acquirer_updated, …terminal_acquirer_disabled (when the terminal is closed)

Field meanings: Common Rules › Change history.

Errors

HTTPcodeWhen
400physical_pos.idempotency.key_required / physical_pos.idempotency.key_invalidIdempotency-Key missing or malformed on create.
400physical_pos.input.code_invalidcode is malformed.
400physical_pos.input.text_invaliddisplayName breaks the rules.
400physical_pos.input.filter_invalidInvalid list filter (status, q).
400physical_pos.input.vendor_code_invalidvendorCode is malformed.
400physical_pos.input.virtual_serial_invalidvirtualSerial is malformed.
400physical_pos.input.ref_invalidvendorTerminalRef is malformed.
400physical_pos.input.reason_code_invalidreasonCode is not on the list.
400physical_pos.device.vendor_unknownThe vendor is not in the catalog or is disabled.
400physical_pos.terminal.vendor_requiredA vendor identifier was given without vendorCode.
400physical_pos.terminal.device_vendor_mismatchThe assigned vendor differs from the linked device's vendor.
400physical_pos.input.empty_patchEmpty update.
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.
409physical_pos.terminal.code_takenThe code is in use in this environment.
409physical_pos.terminal.vendor_ref_takenvendorTerminalRef is in use on an open terminal.
409physical_pos.terminal.virtual_serial_takenvirtualSerial is in use on an open terminal.
409physical_pos.terminal.closedTried to change a closed terminal.
409physical_pos.record.field_immutableThe vendor is already assigned or the terminal has bank rows.
409physical_pos.version_conflictrowVersion is not current.
409physical_pos.idempotency_mismatchThe same Idempotency-Key was used with a different request.
429RATE_LIMITEDTerminal family write limit exceeded. → Rate limits

All codes: Error Codes.