Common Rules
The rules on this page apply to every endpoint of the Physical POS management API. The endpoint pages link here.
Response envelope
On success the data is under data. In this API every endpoint that returns a single record wraps it under the entity name; related data sits in sibling fields:
| Endpoint type | Shape of data | Example |
|---|---|---|
| Read, update or status change of one record | { <entity> } plus related fields, if any | { "store": { … } }, { "terminal": { … }, "acquirers": { … } } |
| Create | { <entity>, replayed } | { "register": { … }, "replayed": false } |
| List | { items, pageInfo } | { "items": [ … ], "pageInfo": { … } } |
| Catalog list | Array | [ { … }, { … } ] |
{
"status": "success",
"code": "SUCCESS",
"data": {
"store": {
"id": "00000000-0000-0000-0000-000000000101",
"code": "IST-KADIKOY",
"name": "Kadıköy Store",
"storeStatus": "ACTIVE",
"rowVersion": 1
},
"replayed": false
},
"statusCode": 201,
"requestId": "00000000-0000-0000-0000-000000000000"
}The store object is shortened in the example; the response contains every field.
- Create endpoints return
201for a new record and200for a repeat with the sameIdempotency-Key. → Idempotency-Key - On list endpoints
pageInfois also at the top level of the envelope; both copies are identical. - Envelope fields not shown in the examples for brevity (
message,timestamp…) are also in the response. All envelope fields and the error envelope: Error Handling. - On errors, branch on
code, not onmessage. This product's codes: Error Codes. - For
physical_pos.*errors the response carries adocsUrlfield that links to that code's row on the Error Codes page. - Error details are in
details: single-field errors carrydetails.fieldanddetails.reason; a validation error covering several fields (physical_pos.validation.invalid) carries adetails.errorsarray, each item{ field, errors: [<reason>] }. The value you sent is never echoed indetails.
Field rules
| Rule | Detail |
|---|---|
| IDs | Every ID (id, storeId, terminalId …) is a UUID. A malformed ID in the path gets the same 404 as a record that does not exist. |
| Times | ISO 8601, UTC (2026-01-01T07:00:00.000Z). |
| Empty values | When an optional field has no value, the field is present as null. |
| Status fields | Status fields are named after the entity: storeStatus, registerStatus, terminalStatus, acquirerStatus, lifecycleStatus. |
| Unknown fields | An undefined body field or query parameter returns 400 VALIDATION_ERROR on every endpoint. |
| Environment | Sending environment in the query or the body gets 400 physical_pos.input.environment_invalid (details.reason: not_allowed). The organization and environment are resolved only from your key. → Environments |
| Empty update | A PATCH request with no field to change gets 400 physical_pos.input.empty_patch. A PATCH whose values equal the current ones is not an error: it returns 200, the record does not change, rowVersion does not increase and no change-history event is written. |
| List filters | An invalid filter value (status outside the set, malformed ID or 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. |
Pagination
List endpoints use cursor-based pagination:
| Parameter | Description |
|---|---|
limit | Records per page, 1–100. Default 25. Out-of-range values return 400 VALIDATION_ERROR. |
cursor | pageInfo.nextCursor from the previous response. Not sent on the first page. |
includeTotal | On record lists (stores, registers, terminals, bank rows, devices). When true, pageInfo.totalCount is filled. History lists (…/audit, …/assignments, …/links) do not take this parameter. |
"pageInfo": { "hasMore": true, "nextCursor": "eyJ0IjoiMjAyNi0…", "totalCount": null }- Every list is sorted newest first (
createdAtdescending, ties byiddescending); the order cannot be changed. - If
pageInfo.hasMoreistrue, request the next page withcursor=<nextCursor>; on the last pagenextCursorisnull. totalCountisnullunless you sendincludeTotal=true. Counting is expensive;hasMoreis enough to page through.- Do not build or modify the cursor yourself. A broken cursor returns
400 physical_pos.cursor.invalid; start again from the first page. - Reset the cursor when you change filters.
Catalog endpoints are not paginated; the whole list comes back as an array in one response.
Idempotency-Key
Endpoints that create something require an Idempotency-Key header so you can safely retry after a network failure:
| Endpoint | Operation |
|---|---|
POST /stores | Create a store |
POST /stores/{storeId}/registers | Create a register |
POST /terminals | Create a logical terminal |
POST /terminals/{terminalId}/acquirers | Add a bank row |
POST /terminals/{terminalId}/device-link/close | Unlink the device |
POST /devices | Register a device |
POST /devices/{deviceId}/assignment/close | Move a device to storage |
POST /devices/{deviceId}/retire | Retire a device |
Format: 8–200 printable ASCII characters without spaces. The value is not trimmed or corrected. A missing header gets 400 physical_pos.idempotency.key_required, a malformed one 400 physical_pos.idempotency.key_invalid.
Behaviour:
| Situation | Result |
|---|---|
| New key | The operation runs; create endpoints return 201, replayed: false. |
| Same key, same endpoint, same body | The operation is not repeated; the record of the first operation is returned in its current state, 200, replayed: true. |
| Same key, different body | 409 physical_pos.idempotency_mismatch. No new record is created. |
| Same key, another operation (e.g. create a store, then a terminal) | 409 physical_pos.idempotency_mismatch. |
- Bodies are compared after the field rules are applied; field order does not matter.
- For operations tied to another record, such as creating a register or adding a bank row, the path parameter is part of the comparison: using the same key for another store gets
409. - A key can be used for one operation only in your key's environment and is kept indefinitely. Do not reuse a used key for a new operation.
- Values used with test and live keys are kept apart.
Use a new UUID for every new operation and store it with the operation. After a timeout or a dropped connection, retry with the same key and the same body; if the first attempt had completed, you get the same record with replayed: true.
The other write endpoints (PATCH, archive, restore, close a terminal, assign a device, replace the device) do not use Idempotency-Key; the header is ignored there. These endpoints are protected by rowVersion: if the first attempt had completed, the retry gets 409 physical_pos.version_conflict (409 physical_pos.terminal.closed when closing a terminal). In that case read the record and check whether your change was applied.
Concurrency: rowVersion
Every changeable record (store, register, logical terminal, bank row) carries a rowVersion integer. In requests that change a record you send the rowVersion you last read in the body:
{ "rowVersion": 3, "name": "Kadıköy Store (New)" }rowVersionis required; it is a JSON integer between 1 and 2147483647. Sending a string ("3") gets400 VALIDATION_ERROR.- If the record was changed by another request after you read it, the request gets
409 physical_pos.version_conflictand nothing is changed. Read the record again, apply your change to its current state and resend with the newrowVersion. - After every successful change, use the current
rowVersionfrom the response. - A bank row update takes the row's own
rowVersion, device link operations take the terminal'srowVersion, and device operations take the device'srowVersion.
Code rules
Store, register and terminal codes are stored in canonical form:
| Field | Rule | Example |
|---|---|---|
code (store, register, terminal) | Leading and trailing spaces are trimmed and the value is upper-cased. Only A-Z, 0-9, ., _, -; starts with a letter or digit; at most 64 characters. | ist-kadikoy → IST-KADIKOY |
vendorCode | Upper-cased. Starts with a letter, only A-Z and 0-9, 2–32 characters. Must be the code of a vendor in the catalog. | pavo → PAVO |
externalRef (store) | The ID in your own system. Printable ASCII without spaces, at most 128 characters. Not trimmed; case is kept. | store_00042 |
- A code with non-ASCII characters is rejected, not converted:
KADIKÖYis not accepted, writeKADIKOY. - A malformed code gets
400 physical_pos.input.code_invalid, a malformed vendor code400 physical_pos.input.vendor_code_invalid, a malformed external reference400 physical_pos.input.ref_invalid. - Codes do not change after creation.
Text rules
These rules apply to free-text fields (name, displayName, city, district, addressLine, label, the q search parameter):
- The text is normalized to Unicode NFC and leading and trailing spaces are trimmed.
- Text that is empty after trimming is rejected.
- These characters are rejected: control characters, zero-width and invisible characters, characters that change text direction, space characters other than the normal space (no-break space etc.), variation selectors and tag characters.
- Length is counted in characters (Unicode code points); the limits are listed in each field table.
Text that breaks the rules is not corrected; the request gets 400 physical_pos.input.text_invalid. details.field gives the field name and details.reason the cause. The same violation in a list search (q) gets 400 physical_pos.input.filter_invalid.
details.reason | Meaning |
|---|---|
not_string | The value is not text. |
empty | Empty after trimming. |
malformed_unicode | Invalid Unicode sequence. |
forbidden_character | Contains a forbidden character. |
too_long | Exceeds the field's limit. |
Visible characters such as Turkish letters and emoji can be used in free text.
Time zone
A store's timezone field is an IANA time zone name (e.g. Europe/Istanbul). If you do not send it, Europe/Istanbul is used.
- The accepted names are the zone names of the IANA time zone database (tzdb) and
UTC. The name must match exactly, including case. - Old or renamed names are stored under their current name; responses always return the current name:
| Sent | Stored |
|---|---|
Europe/Kiev | Europe/Kyiv |
Asia/Calcutta | Asia/Kolkata |
Asia/Istanbul | Europe/Istanbul |
Etc/UTC, Universal, Zulu | UTC |
- Unknown names, and old names and abbreviations that do not map to a single zone (such as
EET,HST,Turkey), are rejected:400 physical_pos.validation.invalidwithdetails.errors[0]={ "field": "timezone", "errors": ["iana_unknown"] }.
Reason codes
Archiving, restoring, closing a terminal, device link and device operations require a reasonCode. The codes come from fixed lists and are written in upper case exactly as listed; a code that is not on the list or does not fit the operation gets 400 physical_pos.input.reason_code_invalid.
| Operation | Accepted codes |
|---|---|
| Archive a store | STORE_CLOSED, TEMPORARILY_CLOSED, RELOCATED, MERGED, CREATED_IN_ERROR, DATA_CORRECTION, OTHER |
| Archive a register | STORE_ARCHIVED, REGISTER_REMOVED, REGISTER_REPLACED, STORE_CLOSED, TEMPORARILY_CLOSED, CREATED_IN_ERROR, DATA_CORRECTION, OTHER |
| Restore a store or register | REOPENED, ARCHIVED_IN_ERROR, DATA_CORRECTION, OTHER |
| Close a terminal | MERCHANT_REQUEST, CONTRACT_ENDED, BANK_TERMINATED, CREATED_IN_ERROR, DUPLICATE_RECORD, MIGRATED, OTHER |
| Link the first device to a terminal | INITIAL_SETUP, RELOCATION, CORRECTION, OTHER |
| Replace the linked device | DEVICE_REPLACEMENT, DEVICE_FAULT, DEVICE_LOST, DEVICE_STOLEN, RELOCATION, CORRECTION, OTHER |
| Unlink the device | DEVICE_REPLACEMENT, DEVICE_FAULT, DEVICE_LOST, DEVICE_STOLEN, RELOCATION, CORRECTION, OTHER |
| Assign a device, move to storage, retire | The tables on the Devices page |
- When a store is archived, its active registers are archived in the same operation; their change history gets the
STORE_ARCHIVEDcode and the store's reason is kept inchanges.storeReason. - Use
OTHERonly for cases that have no match in the list.
Rate limits
Write endpoints (POST and PATCH) have a 60-second window per organization and API key. All writes of an endpoint family share one counter; reads are not limited:
| Endpoint group | At most per 60 seconds |
|---|---|
| Store and register writes (create, update, archive, restore) | 60 requests |
| Terminal family writes (terminal, bank row, device link) | 30 requests |
| Device writes (register, update, assign, move to storage, retire) | 30 requests |
Over the limit the request gets 429 RATE_LIMITED; the Retry-After header and details.retryAfterSeconds give the seconds to wait. Platform-wide limits: Rate Limits.
Change history
Every change to a store, register or terminal is written to the change history in the same operation. You read the history page by page from the GET …/audit endpoints.
| Field | Description |
|---|---|
id | Event ID. |
action | Event type, as physical_pos.audit.<entity>_<action> (e.g. physical_pos.audit.store_archived). |
actorType | Who made the change: USER (panel user), API_KEY (API key), or OPERATOR / SYSTEM for platform-side operations. |
actorId | auth-user:<user id> for a panel user, kid:<key id> for an API key. |
reasonCode | The reason code of the operation; null for operations without a reason. |
changes | A summary of the change. It carries only defined fields and simple values; the values of free text such as names and addresses are not written, only the fact that they changed. |
createdAt | Event time. |
Store, register and device events also carry requestId (the ID of the request that made the change); terminal events carry entityType, entityId and environment.
Retries
4xxerrors are not fixed by retrying; correct the request.503 physical_pos.service.busy(details.retryable: true) means the record was busy with another operation at that moment; retry the same request after a short wait.- For other
5xxerrors, retry with backoff. For create requests, reuse the sameIdempotency-Key.