{"openapi":"3.1.0","info":{"title":"POS Velocity API","version":"2.0.1","summary":"The merchant ledger and PAX terminal control, over HTTPS and JSON.","description":"The POS Velocity API connects your software to a merchant's ledger and to the PAX payment terminals on their counter — card and cash payments, refunds and voids, customer-facing screens, printing and batch close — over plain HTTPS and JSON.\n\n## Getting started\n\n### Base URL\nEvery path in this reference is relative to the environment's origin. In production that is:\n\n```text\nhttps://pax.posvelocity.com\n```\n\n### Credentials\nIn the POS Velocity portal open **Settings → Developers**, generate an API key, and copy the **Merchant ID** shown on the same page. Send the key on every request:\n\n```http\nx-api-key: <your-api-key>\n```\n\nA key belongs to one merchant account and carries a set of **scopes**. A call without the scope it needs is refused with `403 insufficient_scope`, and `detail.missing` names what was missing — see [Scopes](#description/scopes) for which endpoint needs which.\n\n### Two planes, one key\n- **Merchant data** — `/api/v2/merchants/{merchant}/…` — the account, its transactions and orders, employees, terminals and work orders. `{merchant}` is your Merchant ID (the raw 24-hex account id works too) and must be the account the key belongs to.\n- **ECR commands** — `/api/v2/ecr/…` — drive a card-present terminal in **SEMI** mode. There is no account in the path: it comes from the key. Address the terminal with `?serial=` (exact) or `?tag=` (its handle, set in the portal under Terminals → Edit → Tag, matched after normalizing to lowercase `a-z0-9_`) — exactly one of the two.\n\n### Your first payment\n1. **Switch a terminal to SEMI mode** — `PATCH /api/v2/merchants/{merchant}/devices/{terminal}` with `{ \"software\": \"semi\" }`, then `POST /api/v2/ecr/terminal/resync?serial=…` so the terminal picks the change up.\n2. **Check that it answers** — `GET /api/v2/ecr/terminal/ping?serial=…` returns its live state, or `503` when it is offline.\n3. **Charge it:**\n\n```bash\ncurl -X POST \"https://pax.posvelocity.com/api/v2/ecr/terminal/payment?serial=$SERIAL\" \\\n  -H \"x-api-key: $PAX_API_KEY\" \\\n  -H \"x-idempotency-key: $(uuidgen)\" \\\n  -H \"content-type: application/json\" \\\n  -d '{ \"amount\": 1130, \"options\": { \"taxAmount\": 130 } }'\n```\n\nThe call holds until the customer taps and the host answers. **A decline is still `200`** — read `result.status` (`approved` | `declined`).\n\n> [!TIP]\n> Ask for more scopes, or for help getting a terminal into SEMI mode, at [support@posvelocity.com](mailto:support@posvelocity.com).\n\n## Core concepts\n\n### Scopes\nEach operation below lists the scopes it needs next to its title. A key needs **all** of them.\n\n| Scope | Grants |\n| --- | --- |\n| `account:read` | Read the account profile, tenders and sites.<br>**Required by:** Get account details |\n| `account:write` | Reserved for account changes.<br>_No v2 endpoint needs it yet._ |\n| `device:read` | List terminals and read one.<br>**Required by:** List terminals · Get a terminal |\n| `device:write` | Update a terminal — including switching it into SEMI mode.<br>**Required by:** Update a terminal (partial) |\n| `employee:read` | List employees and read one.<br>**Required by:** List employees for the account · Get an employee by id |\n| `employee:write` | Create, update and delete employees.<br>**Required by:** Create a new employee · Update an employee · Soft-delete an employee |\n| `transaction:read` | Read transactions, receipts, orders and dispatched-command records.<br>**Required by:** List orders for the account · Get an order by id · Reconcile an ecr request by id · List transactions for the account · Get a transaction by id · Get a transaction receipt |\n| `transaction:write` | Drive terminals at all. Every ECR command needs it, on top of its own scope.<br>**Required by:** Payment request · Print payment receipt · Refund payment · Void transaction · Blind refund · Cash payment · Cash refund · Cancel payment request · Read card · Get card details · Ask for customer inputs · Reset screen · Tip request · Ping terminal · Device print text · Set device screen / message · Close batch · Get latest batch · Resync terminal · Reboot terminal · Reconcile a dispatched command |\n| `workorder:read` | List work orders and read one.<br>**Required by:** List work orders for the account · Get a work order by id |\n| `workorder:write` | Create, update and delete work orders.<br>**Required by:** Create a new work order · Update a work order · Delete a work order |\n| `ecr:payment:sale` | Take a card or cash payment.<br>**Required by:** Payment request · Cash payment |\n| `ecr:payment:void` | Void a card payment.<br>**Required by:** Void transaction |\n| `ecr:payment:refund` | Refund a payment, linked or blind.<br>**Required by:** Refund payment · Blind refund · Cash refund |\n| `ecr:payment:authorize` | Reserved for pre-authorizations.<br>_No v2 endpoint needs it yet._ |\n| `ecr:batch:close` | Close the terminal's batch.<br>**Required by:** Close batch |\n| `ecr:terminal:reboot` | Reserved: the reboot command answers 501 coming_soon for now.<br>**Required by:** Reboot terminal |\n\n### Amounts\nEvery monetary value is an **integer in minor units (cents)** — `1000` = `$10.00`. Never send decimals.\n\n`amount` on any command (payment, refund, cash) is always the **total to move, tax included** — the number the customer sees. Tax is never added to it: `options.taxAmount` only says *how much of that total is tax*, for the receipt and your reporting. A tip is the one thing added on top, and only when you ask for one (`options.tipBase` or `options.tipSuggestions`) — the customer picks it on the terminal, and the result comes back as `amount.approved` = `amount.requested` + `amount.tip`.\n\n```jsonc\n// $10.00 item + $1.30 tax, tip suggestions on the pre-tax amount\n{ \"amount\": 1130, \"options\": { \"taxAmount\": 130, \"tipBase\": \"net\" } }\n// customer taps 15% → tip = 15% of (1130 − 130) = 150 → approved 1280\n```\n\n### Invoice number\nSend your invoice number in `options.invoice` on `POST /ecr/terminal/payment` (1 to 12 digits). The terminal hands it to the card processor with the charge: it is printed on the terminal's receipt, kept in the terminal's batch and reports, and comes back as `processor.invoiceNumber` on the result.\n\n```json\n{ \"amount\": 1130, \"options\": { \"taxAmount\": 130, \"invoice\": \"9001\" } }\n```\n\n`externalId` is a different thing: it stays on our side for your reconciliation and never reaches the card terminal.\n\n### Idempotency\nMoney-movement endpoints require an idempotency key — a **UUID** (RFC 4122; anything else is `400 invalid_idempotency_key`):\n\n```http\nx-idempotency-key: 5c1f6a2e-9b4d-4c1e-a8f3-2d7e0b9c4a11\n```\n\n**Generate a fresh UUID for every new operation.** A key names ONE attempted operation, not the endpoint, and it is remembered per account, not per terminal: once the command is dispatched to the terminal, the key is consumed *whatever the outcome* — approved, declined, failed, timed out, or cancelled (a `/cancel` or `/reset` that aborted the sale at the tip prompt). Resending a consumed key:\n\n- with the **same body** answers `200` with the first attempt's stored record and `\"idempotent\": true` — even when that attempt was a `502`, `504` or cancel. A safe retry: no double charge, and no new attempt either;\n- with a **different body** is `422 idempotency_key_reused`;\n- while the first attempt is **still running** is `409 request_in_progress`.\n\nThe merchant data plane takes no idempotency key: retrying a `POST` there, such as creating a work order, creates a second record.\n\nA reversal that already took effect is checked before the replay: resending an approved void answers `409 payment_already_voided`, and a refund that used up the balance `422 refund_exceeds_refundable`.\n\nA request refused **before** dispatch leaves its key unused — a `400`, a `403`, a terminal that is inactive or busy (`409 terminal_busy`), or offline (`503`) — so retry it with the *same* key. To try a payment again after a decline or a cancel, send a **new** one. Keys are remembered for about **7 days**; after that the same key starts a new operation.\n\n`externalId` is separate: your own business id, up to 128 characters, stored with the command and the transaction for your reconciliation lookups.\n\n### Reversing a payment\nYou do **not** pick the tender — point at the payment and the server picks it:\n\n| | Endpoint | Tender | Limited by |\n|---|---|---|---|\n| **Refund** | `PUT /payment/{paymentId}/refund` | follows the original — a card sale credits the card, a cash sale pays out of the drawer | the payment's remaining refundable balance |\n| **Void** | `PUT /payment/{paymentId}/void` | card only — a cash sale has no host authorization to void (`409`; refund it instead) | same terminal, batch still open |\n\nBoth are **linked**: a refund decrements the original's refundable balance, so partial refunds can repeat until it is used up and a payment can never be over-refunded (`422 refund_exceeds_refundable`). A void is one-shot and excludes a refund.\n\n`POST /cash/refund` is the **blind** counterpart for cash (its card twin, `POST /credit`, is coming soon) — money out with no original transaction, so nothing is marked refunded and nothing caps the amount. Use them only when there is no payment to point at (a sale taken outside the system, an imported row).\n\n### Timeouts and reconciliation\nAn ECR call holds the connection while the terminal works:\n\n| Command | Server waits up to |\n|---|---|\n| Payment, refund | 90 s — or the body's `timeout` + 30 s, whichever is longer (`timeout`: 5 000–300 000 ms) |\n| Void, cash, cash refund | 90 s |\n| Customer inputs | 180 s |\n| Cancel, screen, print, reset | 10 s |\n| Print payment receipt | 30 s |\n| Ping | 5 s |\n| Batch close, resync | no wait — `{ \"accepted\": true }` once sent |\n\nA `504` means the window closed without an answer — **the payment may still have gone through.** Never retry blind: fetch `GET /api/v2/ecr/requests/{id}` with the `detail.requestId` from the error, and act on its `status` and `result`. The same record is how you pick up `transaction.id` for a sale: the command response usually arrives before the terminal's own report links the ledger row. Any key on the account can read any of its records, for about 7 days.\n\n> [!WARNING]\n> `/transactions` has no filter by `externalId` or idempotency key. Reconcile a command through its request record, then fetch the ledger row by `transaction.id`.\n\n### Response format\nReads use the **PAX-native** shape: nested by concern, ids as `id`, timestamps in unix milliseconds, fields that were never recorded left out rather than zeroed. Transactions and orders also come Clover-shaped with `?format=clover`. Every ECR command answers with a **request record** — `id`, `type`, lifecycle `status`, the outcome in `result` / `amount` / `card` / `processor`, the linked `transaction`, and `time` — never the terminal's raw response.\n\nWork orders, the employee returned by create and update, the account, and a receipt requested with `format=json` come back **as stored** instead: `_id` and ISO 8601 dates.\n\nResponse objects gain fields over time; ignore the ones you do not recognize.\n\n### Pagination\nLists answer `{ items, paging: { total, limit, offset } }`. Page with `limit` and `offset` and keep fetching while `offset + items.length < paging.total`:\n\n| List | `limit` default | max |\n|---|---|---|\n| Transactions | 1000 | 1000 |\n| Orders | 1000 | 5000 |\n| Terminals, employees | whole list | — |\n| Work orders | whole list, as `{ count, items }` | — |\n\n### Caching\nEvery response carries an `ETag`. Send it back as `If-None-Match` and an unchanged resource answers `304 Not Modified` with no body.\n\n### Using an AI assistant\nThis reference is also an MCP server, so an AI assistant (Claude, ChatGPT, Cursor, Copilot) can look up operations itself instead of guessing endpoints and field names. It is read-only: it serves this document and nothing else. It holds no merchant data and makes no API calls on your behalf, so it needs no key.\n\n```text\nhttps://pax.posvelocity.com/api/docs/v2/mcp\n```\n\nAdd it as a remote MCP server (Streamable HTTP) in your assistant. In Claude Code:\n\n```bash\nclaude mcp add --transport http pos-velocity-api https://pax.posvelocity.com/api/docs/v2/mcp\n```\n\nIt offers five tools: `search_operations` (find an operation by a task or a word), `list_operations`, `get_operation` (parameters, body, scopes, responses and examples of one operation), `get_schema` and `get_guide` (the chapters of this page). Your assistant still needs your API key to call the API itself; keep it out of the conversation and in your own code or environment. The same document is available as [JSON](/api/docs/v2/openapi.json) and [YAML](/api/docs/v2/openapi.yaml) for tools that do not speak MCP.\n\n### Errors\nEvery error carries a machine-readable `error` — branch on it, never on wording — and usually a `detail`:\n\n```json\n{ \"error\": \"terminal_busy\", \"detail\": { \"message\": \"Another command is already in progress on this terminal.\" } }\n```\n\nTwo older families say less: authentication failures put a sentence in `error` (`\"api key revoked\"`, `\"account mismatch\"`), and some employee and work-order failures carry only the HTTP reason phrase (`\"Conflict\"`, `\"Bad Request\"`, `\"Not Found\"`), often with nothing more.\n\nA request rejected **before** it reaches the handler — a missing required header, a field that breaks the schema, a body that is not JSON — is `400 invalid_request`, with `detail.message` saying why and `detail.field` naming the member:\n\n```json\n{ \"error\": \"invalid_request\", \"detail\": { \"message\": \"headers must have required property 'x-idempotency-key'\", \"field\": \"headers/x-idempotency-key\" } }\n```\n\n| `error` | Meaning |\n| --- | --- |\n| `invalid_request` | **400** — A header, query parameter, path segment or body field broke the schema, or the body is not JSON (`413` when too large, `415` for another media type). `detail.field` names the member, `detail.message` says why. |\n| `invalid_idempotency_key` | **400** — The idempotency key is not a UUID. |\n| `terminal_required` | **400** — No terminal addressed, or both `serial` and `tag` sent — send exactly one. |\n| `invalid_payment_id` | **400** — The `paymentId` — or, on `GET /ecr/requests/{id}`, the request id — is not a 24-hex id. |\n| `no_updatable_fields` | **400** — The update carried nothing that can be changed. |\n| `screen_empty` | **400** — A screen with no message, items or image. |\n| `screen_content_mismatch` | **400** — `screen=list` without `items`, or `screen=image` without `image`. |\n| `content_required` | **400** — `/print` with nothing to print. |\n| `insufficient_scope` | **403** — The key lacks a scope the call needs; `detail.missing` lists every one. |\n| `terminal_not_found` | **404** — No terminal on the account with that serial or tag. |\n| `payment_not_found` | **404** — No payment with that id on the account. |\n| `employee_not_found` | **404** — No employee with that id on the account. A former employee (one who has left the staff) counts as none. |\n| `not_found` | **404** — No such resource on the account. |\n| `terminal_inactive` | **409** — The terminal exists but is inactive. |\n| `terminal_name_ambiguous` | **409** — More than one active terminal matched. |\n| `terminal_not_in_semi_mode` | **409** — The terminal does not run SEMI — switch it with `PATCH /devices/{terminal}`, then resync it. When its terminal profile sets the mode, `detail.message` names the profile: change that in the portal instead. |\n| `batch_host_managed` | **409** — The host closes this terminal's batch; it cannot be closed from the API. |\n| `terminal_releasing` | **409** — The terminal is being released from its account. Until the platform finishes or cancels the release it takes no command except a cancel or a resync; nothing was sent to it and the key was not used. |\n| `terminal_has_no_printer` | **409** — The terminal has nowhere to print: its model has no built-in printer and no network printer is set up. Nothing was printed. A payment is never refused for this — it goes through and its receipt is simply not printed. |\n| `request_in_progress` | **409** — The same key is still in flight. Wait for it, then fetch its request record. |\n| `terminal_busy` | **409** — Another API payment command holds the terminal. The key was not used. |\n| `cancelled` | **409** — The terminal aborted the command before it completed — a `/cancel` or `/reset`, e.g. at the tip prompt. No money moved; retry with a new key. |\n| `payment_not_reversible` | **409** — The payment cannot be reversed this way: not an approved sale, already voided (for a refund), or a cash sale (for a void — refund it). |\n| `payment_wrong_terminal` | **409** — A void runs on the terminal that took the payment. |\n| `payment_batch_closed` | **409** — The payment's batch is closed — refund it instead. |\n| `payment_already_voided` | **409** — Already voided — including a resend of an approved void with its own key, and a void tried after a refund or while one is in flight. |\n| `payment_gateway_only` | **409** — The payment settled through a payment link or another gateway, not the terminal's rail — reverse it from the portal. |\n| `payment_not_printable` | **409** — The terminal has no receipt to print for this payment: it was not approved, or it did not settle on the terminal's rail (a payment link has its own hosted receipt). |\n| `employee_wrong_site` | **409** — The employee belongs to a different site than the terminal. |\n| `conflict` | **409** — A work order that can no longer be deleted — it has left `pending`, its time has passed, or a payment is linked. |\n| `invalid_site` | **409** — That site does not belong to this account. |\n| `terminal_paired_to_station` | **409** — `PATCH /devices/{terminal}`: the terminal is paired to a POS station, which keeps it in SEMI mode. Unpair it in the portal first; nothing was changed. |\n| `terminal_profile_manages_software` | **409** — `PATCH /devices/{terminal}`: the terminal's profile sets another software mode. Change the profile in the portal, or detach the terminal from it. |\n| `release_pending` | **409** — `PATCH /devices/{terminal}`: the platform is releasing this terminal from the account, so `active`, `passcode` and taking its site away wait until the release is finished or cancelled; nothing was changed. |\n| `idempotency_key_reused` | **422** — The key was used before for a different request. Every new operation needs a new UUID. |\n| `payment_missing_reference` | **422** — The original card payment carries no terminal reference to reverse. |\n| `refund_exceeds_refundable` | **422** — More than is left to refund on the payment — including a resend of a refund that used the balance up. |\n| `coming_soon` | **501** — The command is not implemented yet. |\n| `device_error` | **502** — The terminal received the command and failed it — including when it was busy with a cashier's own sale. Usually no money moved: when `detail.message` begins `PARTIAL APPROVAL`, the card may have been charged, so check the request record and the transaction before retrying. |\n| `device_unreachable` | **503** — The terminal is offline — the command never reached it, and the key was not used. Retry with the same key. |\n| `terminal_offline` | **503** — `/ping` only: the terminal did not answer within 5 s. |\n| `outcome_unknown` | **504** — No answer within the wait window. The payment may have gone through: fetch the request record in `detail.requestId` before anything else. |\n","contact":{"name":"Support","email":"support@posvelocity.com"},"x-scalar-links":[{"name":"Developers","url":"https://pax.posvelocity.com/developers"},{"name":"Changelog","url":"https://pax.posvelocity.com/changelog"},{"name":"Security","url":"https://pax.posvelocity.com/trust"}]},"components":{"securitySchemes":{"ApiKeyAuth":{"type":"apiKey","in":"header","name":"x-api-key","description":"Your API key, from **Settings → Developers** in the POS Velocity portal. Each operation lists the scopes it needs."}},"schemas":{"Account":{"additionalProperties":true,"description":"The account","type":"object","required":["_id","name","email","phone","currency","address","tenders","sites"],"properties":{"_id":{"type":"string"},"name":{"type":"string"},"email":{"anyOf":[{"type":"string"},{"type":"null"}]},"phone":{"anyOf":[{"type":"string"},{"type":"null"}]},"currency":{"anyOf":[{"type":"string"},{"type":"null"}]},"address":{"anyOf":[{"additionalProperties":true,"type":"object","properties":{"country":{"type":"string"},"province":{"type":"string"},"city":{"type":"string"},"street":{"type":"string"},"postalcode":{"type":"string"}}},{"type":"null"}]},"tenders":{"type":"array","items":{"type":"object","required":["key","enabled","refund"],"properties":{"id":{"description":"Row id. Stable across renames; `key` is the rail, not the row.","type":"string"},"key":{"description":"Tender: `cash` | `debit` | `credit` | `qrpay` | `external` (a payment link) | `gift` | `loyalty`. `external` is only `enabled` while its gateway is live, and never offers `refund`.","type":"string"},"label":{"description":"Account-authored name, or \"\" for none. Not translated.","type":"string"},"enabled":{"type":"boolean"},"refund":{"type":"boolean"},"optional":{"type":"boolean"},"min":{"type":"number"},"max":{"type":"number"}}}},"sites":{"type":"array","items":{"additionalProperties":true,"type":"object","required":["id","name","email","phone","address","timezone","currency","workingHours","rules"],"properties":{"id":{"type":"string"},"name":{"type":"string"},"email":{"anyOf":[{"type":"string"},{"type":"null"}]},"phone":{"anyOf":[{"type":"string"},{"type":"null"}]},"address":{"description":"The site's own address, or `null` when the site uses the account's address. `lat` and `lng` appear once the address has been confirmed on the map.","anyOf":[{"additionalProperties":true,"type":"object","properties":{"country":{"type":"string"},"province":{"type":"string"},"city":{"type":"string"},"street":{"type":"string"},"postalcode":{"type":"string"}}},{"type":"null"}]},"timezone":{"anyOf":[{"type":"string"},{"type":"null"}]},"currency":{"anyOf":[{"type":"string"},{"type":"null"}]},"workingHours":{"type":"array","items":{"type":"object","required":["day","closed"],"properties":{"day":{"minimum":0,"maximum":6,"type":"integer"},"open":{"type":"string"},"close":{"type":"string"},"closed":{"type":"boolean"},"lockAfterHours":{"type":"boolean"},"lockWhenClosed":{"type":"boolean"}}}},"rules":{"additionalProperties":true,"type":"object","properties":{"lockDelay":{"type":"number"},"limits":{"anyOf":[{"additionalProperties":true,"type":"object","properties":{}},{"type":"null"}]},"notifications":{"anyOf":[{"additionalProperties":true,"type":"object","properties":{}},{"type":"null"}]},"tipSettings":{"anyOf":[{"additionalProperties":true,"type":"object","properties":{}},{"type":"null"}]},"mapZones":{"type":"array","items":{"additionalProperties":true,"type":"object","properties":{}}}}}}}}}},"Card":{"description":"The card used — never the card number. Absent on cash.","type":"object","required":["last4"],"properties":{"brand":{"description":"Network, upper-case: `VISA`, `MASTERCARD`, `AMEX`, `DISCOVER`, `INTERAC`, `DINERS`, `JCB`, `UNIONPAY`, `UNKNOWN` — or the network's own name.","type":"string"},"last4":{"description":"Last four digits. The number itself is never returned.","type":"string"},"entryMode":{"description":"`contactless` | `chip` | `swipe` | `manual`.","type":"string"},"holder":{"description":"Cardholder name, when the card carried one.","type":"string"}}},"Employee":{"description":"An employee. The PIN is never part of this shape.","type":"object","required":["id"],"properties":{"id":{"pattern":"^[0-9a-f]{24}$","description":"Employee id.","type":"string"},"name":{"type":"string"},"nickname":{"type":"string"},"role":{"enum":["admin","manager","cashier"],"type":"string"},"site":{"description":"The site it belongs to.","type":"object","required":["id"],"properties":{"id":{"pattern":"^[0-9a-f]{24}$","description":"Site id.","type":"string"}}}}},"EmployeeRecord":{"additionalProperties":true,"description":"The employee as stored — returned by create and update. Unlike the read endpoints it carries `_id`, ISO dates and the PIN.","type":"object","required":["_id","pin","name","role","deleted","deletedAt","account","creator","createdAt","updatedAt"],"properties":{"_id":{"pattern":"^[0-9a-f]{24}$","description":"Employee id.","type":"string"},"pin":{"description":"The PIN, as stored.","type":"string"},"name":{"type":"string"},"nickname":{"type":"string"},"email":{"type":"string"},"phone":{"description":"E.164.","type":"string"},"notes":{"type":"string"},"role":{"enum":["admin","manager","cashier"],"type":"string"},"deleted":{"type":"boolean"},"deletedAt":{"anyOf":[{"format":"date-time","description":"Deleted — ISO 8601.","type":"string"},{"type":"null"}]},"site":{"anyOf":[{"pattern":"^[0-9a-f]{24}$","description":"Site id.","type":"string"},{"type":"null"}]},"claims":{"anyOf":[{"additionalProperties":true,"type":"object","properties":{}},{"type":"null"}]},"account":{"pattern":"^[0-9a-f]{24}$","description":"Account id.","type":"string"},"creator":{"pattern":"^[0-9a-f]{24}$","description":"The API key's user.","type":"string"},"createdAt":{"format":"date-time","description":"Created — ISO 8601.","type":"string"},"updatedAt":{"format":"date-time","description":"Last changed — ISO 8601.","type":"string"}}},"Error":{"type":"object","title":"Error","description":"Every error carries a machine-readable `error`; branch on it, never on wording. `detail` adds context when there is some: a string, or an object such as `{ message, field }` on `invalid_request`, `{ missing }` on `insufficient_scope`, `{ requestId, message }` on `outcome_unknown`. Two families of responses predate the codes and say less: authentication failures put a sentence in `error` (`\"api key revoked\"`, `\"account mismatch\"`), and some employee and work-order failures carry only the HTTP reason phrase (`\"Conflict\"`, `\"Bad Request\"`, `\"Not Found\"`), often with nothing more.","required":["error"],"properties":{"error":{"type":"string","description":"Machine-readable code — see **Errors** under Core concepts."},"detail":{"description":"Context for the code, or null."},"message":{"type":"string","description":"Explanation, on the older employee and work-order responses only."}}},"Order":{"description":"An order, rolled up from the transactions that paid it.","type":"object","required":["id","items"],"properties":{"id":{"pattern":"^[0-9a-f]{24}$","description":"Order id.","type":"string"},"state":{"description":"`draft` | `open` | `partially_paid` | `paid` | `abandoned` | `cancelled` | `void` | `partially_refunded` | `refunded`.","type":"string"},"source":{"description":"Where it was rung up, e.g. `pos`.","type":"string"},"clientOrderId":{"type":"string"},"site":{"description":"The site it belongs to.","type":"object","required":["id"],"properties":{"id":{"pattern":"^[0-9a-f]{24}$","description":"Site id.","type":"string"}}},"amount":{"type":"object","properties":{"currency":{"type":"string"},"subtotal":{"description":"Subtotal, in cents.","type":"integer"},"discount":{"description":"Discount, in cents.","type":"integer"},"tax":{"description":"Tax, in cents.","type":"integer"},"serviceCharge":{"description":"Service charge, in cents.","type":"integer"},"total":{"description":"Total, in cents.","type":"integer"},"paid":{"description":"Paid, in cents.","type":"integer"},"remaining":{"description":"Still owed, in cents.","type":"integer"},"refunded":{"description":"Refunded, in cents.","type":"integer"}}},"counts":{"type":"object","properties":{"payments":{"type":"integer"},"refunds":{"type":"integer"}}},"items":{"type":"array","items":{"type":"object","properties":{"lineNumber":{"type":"integer"},"name":{"description":"The line's receipt name.","type":"string"},"sku":{"type":"string"},"product":{"description":"The product it belongs to.","type":"object","required":["id"],"properties":{"id":{"pattern":"^[0-9a-f]{24}$","description":"Product id.","type":"string"}}},"quantity":{"description":"Decimal as a string (`\"2\"`, `\"0.75\"`) so no precision is lost.","type":"string"},"amount":{"type":"object","properties":{"unit":{"description":"Unit price, in cents.","type":"integer"},"discount":{"description":"Discount, in cents.","type":"integer"},"tax":{"description":"Tax, in cents.","type":"integer"},"total":{"description":"Line total, in cents.","type":"integer"}}},"modifiers":{"type":"array","items":{"type":"object","required":["options"],"properties":{"group":{"type":"string"},"options":{"type":"array","items":{"type":"object","properties":{"name":{"type":"string"},"amount":{"description":"Price, in cents.","type":"integer"}}}}}}}}}},"time":{"type":"object","properties":{"opened":{"description":"Opened — unix epoch milliseconds.","type":"integer"},"firstPaid":{"description":"First payment — unix epoch milliseconds.","type":"integer"},"paid":{"description":"Paid in full — unix epoch milliseconds.","type":"integer"},"closed":{"description":"Closed — unix epoch milliseconds.","type":"integer"},"cancelled":{"description":"Cancelled — unix epoch milliseconds.","type":"integer"},"updated":{"description":"Last changed — unix epoch milliseconds.","type":"integer"}}}}},"Paging":{"description":"Where this page sits. Keep fetching with `offset += limit` while `offset + items.length < total`.","type":"object","required":["total","limit","offset"],"properties":{"total":{"description":"Rows matching the query, across every page.","type":"integer"},"limit":{"description":"The page size applied.","type":"integer"},"offset":{"description":"Rows skipped before this page.","type":"integer"}}},"Processor":{"description":"Processor references for the transaction — what you quote to the acquirer. Absent on cash.","type":"object","properties":{"authorizationCode":{"type":"string"},"referenceNumber":{"description":"The terminal's reference for the transaction.","type":"string"},"hostReferenceNumber":{"description":"The acquirer's reference.","type":"string"},"batchNumber":{"type":"string"},"invoiceNumber":{"type":"string"},"traceNumber":{"type":"string"}}},"RequestRecord":{"description":"One command sent to a terminal, in the PAX shape — never the terminal's raw response. Kept for about 7 days, after which `GET` answers 404 and its idempotency key is free again.","type":"object","required":["id","type","status","time"],"properties":{"idempotent":{"description":"On the response to payment, refund, void, cash, cash refund and cancel: `true` when this is an earlier attempt's stored outcome, replayed for a resend with the same key.","type":"boolean"},"id":{"pattern":"^[0-9a-f]{24}$","description":"The request id — for `GET /api/v2/ecr/requests/{id}`.","type":"string"},"type":{"enum":["semi_sale","semi_refund","semi_void","semi_cash","semi_cash_refund","semi_cancel","semi_reset","semi_print","semi_reprint","semi_screen","semi_inputs"],"description":"The command. A refund of a cash sale is `semi_cash_refund`.","type":"string"},"status":{"enum":["in_progress","completed","failed","unknown","cancelled","timed_out"],"description":"Where the command is, not whether money moved — a decline is `completed`; read `result.status`.","x-enumDescriptions":{"in_progress":"Sent; the terminal has not answered.","completed":"The terminal answered with a result — approved or declined.","failed":"The terminal answered with an error. No money moved.","unknown":"The wait window closed with no answer. It may have gone through — fetch this record again.","cancelled":"Aborted on the terminal before it completed. No money moved.","timed_out":"Never finished — the platform retired it."},"type":"string"},"externalId":{"description":"Your business id, as sent.","type":"string"},"result":{"additionalProperties":true,"description":"The outcome, once the terminal answered with one. Absent while in flight, after a timeout or failure, and on screen, print, reset and inputs.","type":"object","properties":{"status":{"enum":["approved","declined"],"type":"string"},"code":{"description":"PAX response code. `000000`–`000003` are all approvals (offline, partial, without surcharge).","type":"string"},"hostCode":{"description":"The host's own response code, e.g. `00` or `51` — a different code space; never compare it with `000000`.","type":"string"},"message":{"type":"string"},"cancelled":{"description":"On a cancel: `true` once the abort was sent, which does not prove a payment was running or stopped; `false` only if the terminal could not send it, with `reason`.","type":"boolean"},"reason":{"description":"On a cancel that could not be carried out: why.","type":"string"}}},"amount":{"description":"Money, when the terminal reported it.","type":"object","required":["partial"],"properties":{"partial":{"description":"The approved amount differs from the requested one. Read it together with `result.status`.","type":"boolean"},"requested":{"description":"What the terminal reports it asked for — not an echo of your `amount`; `0` on cash, in cents.","type":"integer"},"approved":{"description":"What moved, tip included, in cents.","type":"integer"},"tax":{"description":"Tax inside the total, in cents.","type":"integer"},"tip":{"description":"Tip the customer added, in cents.","type":"integer"},"surcharge":{"description":"Surcharge, in cents.","type":"integer"},"cashback":{"description":"Cashback, in cents.","type":"integer"},"tendered":{"description":"Cash handed over — cash only, in cents.","type":"integer"},"change":{"description":"Change given — cash only, in cents.","type":"integer"}}},"card":{"$ref":"#/components/schemas/Card"},"processor":{"$ref":"#/components/schemas/Processor"},"transaction":{"description":"The ledger row, linked once the terminal's own report arrives — usually after the command response. Fetch the record again to pick it up.","type":"object","required":["id"],"properties":{"id":{"pattern":"^[0-9a-f]{24}$","description":"Ledger transaction id.","type":"string"}}},"error":{"description":"Why it did not complete: `semi_timeout`, `lease_expired`, or the terminal's message.","type":"string"},"time":{"type":"object","properties":{"sent":{"description":"Sent to the terminal — unix epoch milliseconds.","type":"integer"},"completed":{"description":"Answered — unix epoch milliseconds.","type":"integer"}}}}},"Terminal":{"description":"A terminal on the account, in any mode.","type":"object","required":["id","features","status"],"properties":{"id":{"pattern":"^[0-9a-f]{24}$","description":"Terminal id — what `{terminal}` takes on the merchant plane.","type":"string"},"name":{"type":"string"},"serial":{"description":"Hardware serial — what `?serial=` takes on the ECR plane.","type":"string"},"model":{"type":"string"},"features":{"description":"How the terminal runs — its terminal profile's settings, where that manages them.","type":"object","required":["quickAccess"],"properties":{"software":{"description":"The mode the app runs in: `pos` | `semi` | `payment` | `workorder` | `payboard` | `restaurant`. Only `semi` takes ECR commands. A PATCH can set `pos`, `semi` or `payment`.","type":"string"},"quickAccess":{"description":"`true`: the terminal can also be used with no employee signed in; what is done that way is attributed to the terminal.","type":"boolean"}}},"family":{"description":"The terminal's family. Absent when the terminal has none on record.","type":"object","required":["name"],"properties":{"name":{"description":"The terminal family's name, as the platform's administrators gave it. Not the same as `model`, which the terminal reports about itself: two terminals of one reported model can belong to different families.","type":"string"},"hardware":{"description":"What this model physically has: `printer` (built-in), `keyboard`, `bluetooth`, `wifi`, `second_screen` (a customer-facing display besides the operator's), `battery`. Something not listed is not there. A terminal with no built-in printer can still print on a network printer set up on it, which this list cannot show. Read-only: not changed by `PATCH`.","type":"array","items":{"enum":["printer","keyboard","bluetooth","wifi","second_screen","battery"],"type":"string"}}}},"settings":{"additionalProperties":true,"description":"The terminal's own settings, as stored: what `settings` on `PATCH /devices/{terminal}` changes. Where its terminal profile manages a section, the profile's values are what the terminal runs, so these can differ from `features`. Absent when none are stored.","type":"object","properties":{}},"status":{"type":"object","required":["active","online"],"properties":{"active":{"type":"boolean"},"online":{"description":"Connected right now.","type":"boolean"}}},"site":{"description":"The site it belongs to.","type":"object","required":["id"],"properties":{"id":{"pattern":"^[0-9a-f]{24}$","description":"Site id.","type":"string"}}},"battery":{"type":"object","required":["charging"],"properties":{"level":{"description":"Percent.","type":"integer"},"charging":{"type":"boolean"},"updatedAt":{"description":"Reported — unix epoch milliseconds.","type":"integer"}}},"location":{"type":"object","required":["lat","lng"],"properties":{"lat":{"type":"number"},"lng":{"type":"number"},"updatedAt":{"description":"Reported — unix epoch milliseconds.","type":"integer"}}},"employees":{"description":"Present when the terminal is limited to named employees — its terminal profile's, when that assigns any: only these may sign in. Removed employees are left out, so `[]` means none may. Absent: not limited — any employee of the terminal's site, or of no site, may sign in.","type":"array","items":{"type":"object","required":["id"],"properties":{"id":{"pattern":"^[0-9a-f]{24}$","description":"Employee id.","type":"string"},"name":{"type":"string"},"role":{"type":"string"}}}}}},"Transaction":{"description":"A ledger row in the PAX-native shape. Rows are never overwritten: a refund or void is its own row pointing at the original.","type":"object","required":["id","operation","method","result","voided","amount","offline","time"],"properties":{"id":{"pattern":"^[0-9a-f]{24}$","description":"Transaction id — the `paymentId` a refund or void points at.","type":"string"},"externalId":{"description":"Your business id, when the command that took it carried one.","type":"string"},"operation":{"enum":["sale","void","refund","authorize"],"type":"string"},"method":{"enum":["cash","debit","credit","qrpay","external","gift","loyalty"],"description":"How it was paid. `external` is a payment link.","type":"string"},"result":{"description":"The outcome. Declined attempts are ledger rows too.","type":"object","required":["status"],"properties":{"status":{"enum":["approved","declined","unknown"],"type":"string"},"code":{"description":"The terminal's response code, e.g. `000000`.","type":"string"},"message":{"type":"string"}}},"voided":{"description":"True once a void has been claimed against it — including a void still in flight.","type":"boolean"},"amount":{"description":"Money, in cents. Fields that were never recorded are omitted rather than zero.","type":"object","required":["subtotal"],"properties":{"currency":{"description":"ISO 4217, upper-case — the account's currency.","type":"string"},"requested":{"description":"What was asked for, in cents.","type":"integer"},"approved":{"description":"What moved, tip included, in cents.","type":"integer"},"subtotal":{"description":"`approved` less tax, tip, surcharge and cashback, in cents.","type":"integer"},"tax":{"description":"Tax inside the total, in cents.","type":"integer"},"tip":{"description":"Tip, in cents.","type":"integer"},"surcharge":{"description":"Surcharge, in cents.","type":"integer"},"cashback":{"description":"Cashback, in cents.","type":"integer"},"refunded":{"description":"Refunded so far — present once anything is, in cents.","type":"integer"},"tendered":{"description":"Cash handed over — cash only, in cents.","type":"integer"},"change":{"description":"Change given — cash only, in cents.","type":"integer"}}},"card":{"$ref":"#/components/schemas/Card"},"processor":{"$ref":"#/components/schemas/Processor"},"employee":{"description":"Who took it.","type":"object","required":["id"],"properties":{"id":{"pattern":"^[0-9a-f]{24}$","description":"Employee id.","type":"string"},"name":{"type":"string"}}},"device":{"description":"The terminal it ran on.","type":"object","required":["id"],"properties":{"id":{"pattern":"^[0-9a-f]{24}$","description":"Terminal id.","type":"string"},"name":{"type":"string"},"serial":{"type":"string"}}},"site":{"description":"The site it belongs to.","type":"object","required":["id"],"properties":{"id":{"pattern":"^[0-9a-f]{24}$","description":"Site id.","type":"string"}}},"kind":{"enum":["order","workorder"],"description":"What it paid for, when it paid for something.","type":"string"},"parentTransaction":{"description":"On a void or refund: the payment it reverses.","type":"object","required":["id"],"properties":{"id":{"pattern":"^[0-9a-f]{24}$","description":"The payment reversed.","type":"string"}}},"reason":{"description":"On a void or refund, when one was given.","type":"object","properties":{"code":{"description":"`customer_request` | `wrong_amount` | `duplicate` | `item_returned` | `operator_error` | `other`, or the id of the account's own reason.","type":"string"},"message":{"type":"string"}}},"original":{"$ref":"#/components/schemas/Transaction","description":"On a void or refund: the payment it reverses, one level deep."},"location":{"description":"The terminal's GPS fix when this happened.","type":"object","required":["lat","lng"],"properties":{"lat":{"type":"number"},"lng":{"type":"number"}}},"offline":{"description":"Approved offline by the terminal and submitted later.","type":"boolean"},"time":{"type":"object","properties":{"initiated":{"description":"Started on the terminal — unix epoch milliseconds.","type":"integer"},"created":{"description":"Recorded on the platform — unix epoch milliseconds.","type":"integer"},"captured":{"description":"Captured on the terminal — unix epoch milliseconds.","type":"integer"},"updated":{"description":"Last changed — unix epoch milliseconds.","type":"integer"},"voided":{"description":"Voided — unix epoch milliseconds.","type":"integer"}}}}},"Workorder":{"additionalProperties":true,"description":"A work order as stored: `_id` and ISO dates, not the PAX-native shape.","type":"object","required":["_id","name","status","scheduledAt","anytime","durationMinutes","site","employee","notes","routeOrder","invoiceNumber","currency","priceCents","taxIncluded","taxAddOnTop","customer","location","signatureMode","transactions","completedAt","canceledAt","createdAt","updatedAt"],"properties":{"_id":{"pattern":"^[0-9a-f]{24}$","description":"Work order id.","type":"string"},"name":{"type":"string"},"status":{"enum":["pending","en_route","completed","canceled"],"type":"string"},"scheduledAt":{"format":"date-time","description":"Scheduled start, as floating wall-clock time — ISO 8601.","type":"string"},"anytime":{"description":"Date-only: no fixed time.","type":"boolean"},"durationMinutes":{"type":"integer"},"site":{"anyOf":[{"pattern":"^[0-9a-f]{24}$","description":"Site id.","type":"string"},{"type":"null"}]},"employee":{"anyOf":[{"pattern":"^[0-9a-f]{24}$","description":"Assigned employee id.","type":"string"},{"type":"null"}]},"notes":{"anyOf":[{"type":"string"},{"type":"null"}]},"routeOrder":{"type":"number"},"invoiceNumber":{"anyOf":[{"type":"string"},{"type":"null"}]},"currency":{"description":"The account's currency.","type":"string"},"priceCents":{"anyOf":[{"type":"integer"},{"type":"null"}]},"taxIncluded":{"type":"boolean"},"taxAddOnTop":{"type":"boolean"},"customer":{"additionalProperties":true,"description":"`name`, `company`, `email`, `phone`, `address`.","type":"object","properties":{}},"location":{"description":"Always present; `lat` and `lng` are `null` until the job site has been geocoded.","type":"object","required":["lat","lng"],"properties":{"lat":{"anyOf":[{"type":"number"},{"type":"null"}]},"lng":{"anyOf":[{"type":"number"},{"type":"null"}]}}},"signatureMode":{"enum":["none","optional","mandatory"],"type":"string"},"transactions":{"description":"Payments taken against it.","type":"array","items":{"pattern":"^[0-9a-f]{24}$","description":"Transaction id.","type":"string"}},"completedAt":{"anyOf":[{"format":"date-time","description":"Completed — ISO 8601.","type":"string"},{"type":"null"}]},"canceledAt":{"anyOf":[{"format":"date-time","description":"Canceled — ISO 8601.","type":"string"},{"type":"null"}]},"createdAt":{"format":"date-time","description":"Created — ISO 8601.","type":"string"},"updatedAt":{"format":"date-time","description":"Last changed — ISO 8601.","type":"string"}}}}},"paths":{"/api/v2/merchants/{merchant}":{"get":{"operationId":"getAccount","summary":"Get account details","tags":["Account"],"description":"Returns safe account fields (name/email/phone/currency/address/tenders) plus active sites with working hours and per-site rules (limits, notifications, tip settings, lock delay, map zones).","parameters":[{"schema":{"pattern":"^[A-Za-z0-9]{1,64}$","type":"string"},"in":"path","name":"merchant","required":true,"description":"Account handle — merchantId or 24-hex ObjectId"}],"security":[{"ApiKeyAuth":["account:read"]}],"x-badges":[{"name":"account:read","position":"after"}],"responses":{"200":{"description":"The account","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Account"},"examples":{"account":{"summary":"Account","value":{"_id":"6ab6fa29a31a6c313c223b55","name":"Acme Coffee","email":"hello@acme.test","phone":"+14165550142","currency":"CAD","address":{"country":"CA","province":"ON","city":"Toronto","street":"123 King St W","postalcode":"M5H 1A1"},"tenders":[{"key":"credit","enabled":true,"refund":true,"id":"6ab6fa29a31a6c313c223b5a","label":"","optional":false,"min":0,"max":0},{"key":"cash","enabled":true,"refund":true,"id":"6ab6fa29a31a6c313c223b5c","label":"","optional":false,"min":0,"max":50000},{"key":"external","enabled":true,"refund":false,"id":"6ab6fa29a31a6c313c223b5d","label":"Online card","optional":true,"min":500,"max":0}],"sites":[{"id":"6ab6fa29a31a6c313c223b60","name":"Downtown","email":"downtown@acme.test","phone":"+14165550100","address":{"country":"CA","province":"ON","city":"Toronto","street":"123 King St W","postalcode":"M5H 1A1"},"timezone":"America/Toronto","currency":"CAD","workingHours":[{"day":1,"closed":false,"open":"08:00","close":"18:00","lockAfterHours":false,"lockWhenClosed":false},{"day":0,"closed":true,"lockAfterHours":false,"lockWhenClosed":true}],"rules":{"lockDelay":300,"limits":null,"notifications":null,"tipSettings":{"afterTax":false,"options":[{"label":"Good","value":1500,"type":"%"},{"label":"Great","value":1800,"type":"%"}],"customMessage":"Add a tip?"},"mapZones":[]}}]}}}}}},"401":{"description":"Unauthorized — the request carried no `x-api-key` header.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"403":{"description":"Forbidden — an unknown, revoked or expired key, an inactive account, a `{merchant}` that is not the key's account, a missing scope (`insufficient_scope`, with `detail.missing`), or the plan's employee limit.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"404":{"description":"Not found — no matching resource on this account.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}}}},"/api/v2/merchants/{merchant}/devices":{"get":{"operationId":"listTerminals","summary":"List terminals","tags":["Terminals"],"description":"Every terminal on the account that is not deleted, in any mode — `features.software` says which; only `semi` terminals take ECR commands. Unpaged.","parameters":[{"schema":{"pattern":"^[A-Za-z0-9]{1,64}$","type":"string"},"in":"path","name":"merchant","required":true,"description":"Account handle — merchantId or 24-hex ObjectId"}],"security":[{"ApiKeyAuth":["device:read"]}],"x-badges":[{"name":"device:read","position":"after"}],"responses":{"200":{"description":"Terminals for the account.","content":{"application/json":{"schema":{"description":"Every terminal on the account, in any mode — unpaged, so `paging.limit` equals `paging.total`.","type":"object","required":["items","paging"],"properties":{"items":{"type":"array","items":{"$ref":"#/components/schemas/Terminal"}},"paging":{"$ref":"#/components/schemas/Paging"}}},"examples":{"terminals":{"summary":"Two terminals","value":{"items":[{"id":"6ab6fa29a31a6c313c223b67","name":"Front Counter","serial":"1850123456","model":"A920Pro","features":{"software":"semi","quickAccess":false},"family":{"name":"A920 Pro","hardware":["printer","wifi","bluetooth","battery"]},"settings":{"void":true,"refund":true,"cashRefund":false,"authorization":false,"reprint":true,"askVoidReason":false,"askRefundReason":false,"quickAccess":false,"tipsDisabled":false,"manualEntry":false,"idleLock":0,"invoiceNumber":"off","invoiceMinLength":1,"invoiceMaxLength":12,"receiptCustomer":true,"receiptMerchant":false,"receiptManual":false,"receiptEmail":true,"receiptSms":false,"receiptLangs":["en"],"skipWorkingHours":false,"skipZones":false,"screenOffBattery":false,"screenOffCharging":false},"status":{"active":true,"online":true},"site":{"id":"6ab6fa29a31a6c313c223b60"},"battery":{"level":82,"charging":false,"updatedAt":1790346000000},"location":{"lat":43.6487,"lng":-79.3817,"updatedAt":1790345940000},"employees":[{"id":"6ab6fa29a31a6c313c223b63","name":"Alex Martin","role":"manager"},{"id":"6ab6fa29a31a6c313c223b64","name":"Sam Lee","role":"cashier"}]},{"id":"6ab6fa29a31a6c313c223b68","name":"Back Office","serial":"1850654321","model":"A920Pro Duo","features":{"software":"pos","quickAccess":true},"family":{"name":"A920 Pro Duo","hardware":["printer","wifi","bluetooth","battery","second_screen"]},"settings":{"void":true,"refund":true,"cashRefund":false,"authorization":false,"reprint":true,"askVoidReason":false,"askRefundReason":false,"quickAccess":true,"tipsDisabled":false,"manualEntry":false,"idleLock":0,"invoiceNumber":"off","invoiceMinLength":1,"invoiceMaxLength":12,"receiptCustomer":true,"receiptMerchant":false,"receiptManual":false,"receiptEmail":true,"receiptSms":false,"receiptLangs":["en"],"skipWorkingHours":false,"skipZones":false,"screenOffBattery":false,"screenOffCharging":false},"status":{"active":true,"online":false},"battery":{"charging":false}}],"paging":{"total":2,"limit":2,"offset":0}}}}}}},"401":{"description":"Unauthorized — the request carried no `x-api-key` header.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"403":{"description":"Forbidden — an unknown, revoked or expired key, an inactive account, a `{merchant}` that is not the key's account, a missing scope (`insufficient_scope`, with `detail.missing`), or the plan's employee limit.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}}}},"/api/v2/merchants/{merchant}/devices/{terminal}":{"get":{"operationId":"getTerminal","summary":"Get a terminal","tags":["Terminals"],"description":"One terminal, by its id. `404` when the id is not a terminal on this account, or the terminal was deleted.","parameters":[{"schema":{"pattern":"^[A-Za-z0-9]{1,64}$","type":"string"},"in":"path","name":"merchant","required":true,"description":"Account handle — merchantId or 24-hex ObjectId"},{"schema":{"type":"string"},"in":"path","name":"terminal","required":true,"description":"Terminal id — the 24-hex `id` from the terminal list. A serial or tag is not looked up here (that is the ECR plane's `?serial=` / `?tag=`)."}],"security":[{"ApiKeyAuth":["device:read"]}],"x-badges":[{"name":"device:read","position":"after"}],"responses":{"200":{"description":"Terminal summary view","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Terminal"},"examples":{"terminal":{"summary":"Terminal","value":{"id":"6ab6fa29a31a6c313c223b67","name":"Front Counter","serial":"1850123456","model":"A920Pro","features":{"software":"semi","quickAccess":false},"family":{"name":"A920 Pro","hardware":["printer","wifi","bluetooth","battery"]},"settings":{"void":true,"refund":true,"cashRefund":false,"authorization":false,"reprint":true,"askVoidReason":false,"askRefundReason":false,"quickAccess":false,"tipsDisabled":false,"manualEntry":false,"idleLock":0,"invoiceNumber":"off","invoiceMinLength":1,"invoiceMaxLength":12,"receiptCustomer":true,"receiptMerchant":false,"receiptManual":false,"receiptEmail":true,"receiptSms":false,"receiptLangs":["en"],"skipWorkingHours":false,"skipZones":false,"screenOffBattery":false,"screenOffCharging":false},"status":{"active":true,"online":true},"site":{"id":"6ab6fa29a31a6c313c223b60"},"battery":{"level":82,"charging":false,"updatedAt":1790346000000},"location":{"lat":43.6487,"lng":-79.3817,"updatedAt":1790345940000},"employees":[{"id":"6ab6fa29a31a6c313c223b63","name":"Alex Martin","role":"manager"},{"id":"6ab6fa29a31a6c313c223b64","name":"Sam Lee","role":"cashier"}]}}}}}},"401":{"description":"Unauthorized — the request carried no `x-api-key` header.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"403":{"description":"Forbidden — an unknown, revoked or expired key, an inactive account, a `{merchant}` that is not the key's account, a missing scope (`insufficient_scope`, with `detail.missing`), or the plan's employee limit.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"404":{"description":"Not found — no matching resource on this account.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}}},"patch":{"operationId":"updateTerminal","summary":"Update a terminal (partial)","tags":["Terminals"],"description":"Partial update of a terminal: `software` (switch it to `semi` to open the ECR plane to it, then `POST /api/v2/ecr/terminal/resync`), `active`, `site` (within this account), `name`, `passcode`, `settings`. Only the fields sent change, and within `settings` only the settings sent. The account, id and serial cannot be changed. A terminal paired to a POS station keeps its software mode: changing it answers 409 `terminal_paired_to_station` until it is unpaired in the portal. Answers with the terminal as `GET /devices/{terminal}` shows it after the change: what it runs, so a value its terminal profile sets is the profile's.","requestBody":{"required":true,"content":{"application/json":{"schema":{"additionalProperties":false,"description":"Partial device update — account / _id / serial are NOT writable.","type":"object","properties":{"software":{"enum":["pos","semi","payment"],"description":"Device app mode. Set 'semi' to enable ECR (self-onboarding), then POST /ecr/terminal/resync. A terminal whose terminal profile sets its software mode runs the profile's: sending that mode changes nothing, any other answers 409 `terminal_profile_manages_software`.","type":"string"},"active":{"description":"Activate / deactivate the terminal","type":"boolean"},"site":{"description":"Reassign to a site in THIS account (null = unassign)","anyOf":[{"pattern":"^[0-9a-fA-F]{24}$","type":"string"},{"type":"null"}]},"name":{"description":"Display name","type":"string"},"passcode":{"description":"Terminal passcode","type":"string"},"settings":{"additionalProperties":true,"description":"The terminal settings to change — only the ones sent change, the rest keep their values. A setting the terminal does not have, or a value it cannot hold, answers 400 `invalid_request` with `detail.field` naming it, and nothing is written.","type":"object","properties":{}}}},"examples":{"semi":{"summary":"Switch to SEMI mode","value":{"software":"semi"},"description":"Then `POST /api/v2/ecr/terminal/resync` so the terminal picks it up."},"rename":{"summary":"Rename and move","value":{"name":"Patio","site":"6ab6fa29a31a6c313c223b60"}}}}},"description":"Partial device update — account / _id / serial are NOT writable."},"parameters":[{"schema":{"pattern":"^[A-Za-z0-9]{1,64}$","type":"string"},"in":"path","name":"merchant","required":true,"description":"Account handle — merchantId or 24-hex ObjectId"},{"schema":{"pattern":"^[0-9a-fA-F]{24}$","type":"string"},"in":"path","name":"terminal","required":true,"description":"Terminal id — the 24-hex `id` from the terminal list."}],"security":[{"ApiKeyAuth":["device:write"]}],"x-badges":[{"name":"device:write","position":"after"}],"responses":{"200":{"description":"The terminal after the change, as `GET /devices/{terminal}` shows it.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Terminal"},"examples":{"updated":{"summary":"Switched to SEMI","value":{"id":"6ab6fa29a31a6c313c223b68","name":"Back Office","serial":"1850654321","model":"A920Pro Duo","features":{"software":"semi","quickAccess":true},"family":{"name":"A920 Pro Duo","hardware":["printer","wifi","bluetooth","battery","second_screen"]},"settings":{"void":true,"refund":true,"cashRefund":false,"authorization":false,"reprint":true,"askVoidReason":false,"askRefundReason":false,"quickAccess":true,"tipsDisabled":false,"manualEntry":false,"idleLock":0,"invoiceNumber":"off","invoiceMinLength":1,"invoiceMaxLength":12,"receiptCustomer":true,"receiptMerchant":false,"receiptManual":false,"receiptEmail":true,"receiptSms":false,"receiptLangs":["en"],"skipWorkingHours":false,"skipZones":false,"screenOffBattery":false,"screenOffCharging":false},"status":{"active":true,"online":false},"battery":{"charging":false}},"description":"The terminal as `GET /devices/{terminal}` shows it, after `{ \"software\": \"semi\" }`."}}}}},"400":{"description":"Bad request — a header, parameter or body field broke the schema (`invalid_request`, with `detail.field`).","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"401":{"description":"Unauthorized — the request carried no `x-api-key` header.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"403":{"description":"Forbidden — an unknown, revoked or expired key, an inactive account, a `{merchant}` that is not the key's account, a missing scope (`insufficient_scope`, with `detail.missing`), or the plan's employee limit.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"404":{"description":"Not found — no matching resource on this account.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"409":{"description":"Conflict — see the operation: a terminal setting that is not yours to change (`terminal_profile_manages_software`, `terminal_paired_to_station`, `release_pending`), `invalid_site`, an employee PIN or email already in use, or a work order that can no longer be deleted.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}}}},"/api/v2/merchants/{merchant}/employees":{"get":{"operationId":"listEmployees","summary":"List employees for the account","tags":["Employees"],"description":"Every employee who has not been removed. Unpaged.","parameters":[{"schema":{"pattern":"^[A-Za-z0-9]{1,64}$","type":"string"},"in":"path","name":"merchant","required":true,"description":"Account handle — merchantId or 24-hex ObjectId"}],"security":[{"ApiKeyAuth":["employee:read"]}],"x-badges":[{"name":"employee:read","position":"after"}],"responses":{"200":{"description":"Employees for the account.","content":{"application/json":{"schema":{"description":"Every employee who has not been removed — unpaged.","type":"object","required":["items","paging"],"properties":{"items":{"type":"array","items":{"$ref":"#/components/schemas/Employee"}},"paging":{"$ref":"#/components/schemas/Paging"}}},"examples":{"employees":{"summary":"Employees","value":{"items":[{"id":"6ab6fa29a31a6c313c223b63","name":"Alex Martin","nickname":"AJ","role":"manager","site":{"id":"6ab6fa29a31a6c313c223b60"}},{"id":"6ab6fa29a31a6c313c223b64","name":"Sam Lee","role":"cashier"}],"paging":{"total":2,"limit":2,"offset":0}}}}}}},"401":{"description":"Unauthorized — the request carried no `x-api-key` header.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"403":{"description":"Forbidden — an unknown, revoked or expired key, an inactive account, a `{merchant}` that is not the key's account, a missing scope (`insufficient_scope`, with `detail.missing`), or the plan's employee limit.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}}},"post":{"operationId":"createEmployee","summary":"Create a new employee","tags":["Employees"],"description":"PIN is required and must be unique within the account. Every PIN on an account has the same number of digits, 3 to 7, set by the platform (it can change the length later, which rewrites every PIN); a PIN of any other length, or with anything but digits in it, answers 400. A PIN may not be the start of another employee's PIN, or start with one — the terminal signs in as soon as the digits typed match a PIN. Either case answers 409, as does an email already used by a current employee.\n\nThe response is the employee as stored — `_id`, ISO dates and the PIN — not the shape the read endpoints return.","requestBody":{"required":true,"content":{"application/json":{"schema":{"additionalProperties":false,"type":"object","required":["name","pin","role"],"properties":{"name":{"description":"Employee full name","type":"string"},"pin":{"minLength":1,"description":"Employee PIN (digits only) — required, exactly as long as every other PIN on the account, unique within the account, and neither the start of another employee's PIN nor starting with one","type":"string"},"role":{"description":"Employee role — what it may do lives in the account's claims","anyOf":[{"type":"string","enum":["admin"]},{"type":"string","enum":["manager"]},{"type":"string","enum":["cashier"]}]},"email":{"format":"email","type":"string"},"phone":{"type":"string"},"nickname":{"type":"string"},"notes":{"type":"string"},"active":{"description":"Accepted for compatibility and ignored — an employee is always created active. Remove one with DELETE.","type":"boolean"},"site":{"pattern":"^[0-9a-fA-F]{24}$","description":"AccountSite id — must belong to this merchant","type":"string"},"comments":{"description":"Note recorded on the change's backup record","type":"string"},"claims":{"anyOf":[{"type":"null"},{"type":"object","properties":{"void":{"type":"boolean"},"refund":{"type":"boolean"},"cashRefund":{"type":"boolean"},"reprint":{"type":"boolean"},"manualEntry":{"type":"boolean"},"authorization":{"type":"boolean"},"priceOverride":{"type":"boolean"},"minTransactionAmount":{"minimum":0,"type":"number"},"maxTransactionAmount":{"minimum":0,"type":"number"},"mustClockIn":{"type":"boolean"},"historyScope":{"anyOf":[{"type":"string","enum":["self"]},{"type":"string","enum":["site"]},{"type":"string","enum":["account"]}]}}}]}}},"examples":{"cashier":{"summary":"A cashier","value":{"name":"Priya Shah","pin":"5512","role":"cashier","email":"priya@acme.test","phone":"+14165550199","site":"6ab6fa29a31a6c313c223b60"}}}}}},"parameters":[{"schema":{"pattern":"^[A-Za-z0-9]{1,64}$","type":"string"},"in":"path","name":"merchant","required":true,"description":"Account handle — merchantId or 24-hex ObjectId"}],"security":[{"ApiKeyAuth":["employee:write"]}],"x-badges":[{"name":"employee:write","position":"after"}],"responses":{"201":{"description":"The created employee.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/EmployeeRecord"},"examples":{"created":{"summary":"Created","value":{"pin":"5512","name":"Priya Shah","email":"priya@acme.test","phone":"+14165550199","role":"cashier","deleted":false,"deletedAt":null,"site":"6ab6fa29a31a6c313c223b60","tipRole":null,"account":"6ab6fa29a31a6c313c223b55","creator":"6ab6fa29a31a6c313c223b78","_id":"6ab6fa2aa31a6c313c223b80","createdAt":"2026-09-25T22:48:10.381Z","updatedAt":"2026-09-25T22:48:10.381Z"}}}}}},"400":{"description":"Bad request — a header, parameter or body field broke the schema (`invalid_request`, with `detail.field`).","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"401":{"description":"Unauthorized — the request carried no `x-api-key` header.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"403":{"description":"Forbidden — an unknown, revoked or expired key, an inactive account, a `{merchant}` that is not the key's account, a missing scope (`insufficient_scope`, with `detail.missing`), or the plan's employee limit.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"409":{"description":"Conflict — see the operation: a terminal setting that is not yours to change (`terminal_profile_manages_software`, `terminal_paired_to_station`, `release_pending`), `invalid_site`, an employee PIN or email already in use, or a work order that can no longer be deleted.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}}}},"/api/v2/merchants/{merchant}/employees/{employeeId}":{"get":{"operationId":"getEmployee","summary":"Get an employee by id","tags":["Employees"],"description":"One employee, by id. A removed employee answers `404`.","parameters":[{"schema":{"pattern":"^[A-Za-z0-9]{1,64}$","type":"string"},"in":"path","name":"merchant","required":true,"description":"Account handle — merchantId or 24-hex ObjectId"},{"schema":{"pattern":"^[0-9a-fA-F]{24}$","type":"string"},"in":"path","name":"employeeId","required":true,"description":"Employee ObjectId"}],"security":[{"ApiKeyAuth":["employee:read"]}],"x-badges":[{"name":"employee:read","position":"after"}],"responses":{"200":{"description":"The employee document.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Employee"},"examples":{"employee":{"summary":"Employee","value":{"id":"6ab6fa29a31a6c313c223b63","name":"Alex Martin","nickname":"AJ","role":"manager","site":{"id":"6ab6fa29a31a6c313c223b60"}}}}}}},"401":{"description":"Unauthorized — the request carried no `x-api-key` header.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"403":{"description":"Forbidden — an unknown, revoked or expired key, an inactive account, a `{merchant}` that is not the key's account, a missing scope (`insufficient_scope`, with `detail.missing`), or the plan's employee limit.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"404":{"description":"Not found — no matching resource on this account.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}}},"put":{"operationId":"updateEmployee","summary":"Update an employee","tags":["Employees"],"description":"Partial update — only fields present in the body are changed. `pin` may be changed but not cleared. The response is the employee as stored, like create's.","requestBody":{"required":true,"content":{"application/json":{"schema":{"additionalProperties":false,"type":"object","properties":{"name":{"description":"Employee full name","type":"string"},"pin":{"minLength":1,"description":"Employee PIN (digits only) — required, exactly as long as every other PIN on the account, unique within the account, and neither the start of another employee's PIN nor starting with one","type":"string"},"role":{"description":"Employee role — what it may do lives in the account's claims","anyOf":[{"type":"string","enum":["admin"]},{"type":"string","enum":["manager"]},{"type":"string","enum":["cashier"]}]},"email":{"format":"email","type":"string"},"phone":{"type":"string"},"nickname":{"type":"string"},"notes":{"type":"string"},"active":{"description":"Accepted for compatibility and ignored — an employee is always created active. Remove one with DELETE.","type":"boolean"},"site":{"pattern":"^[0-9a-fA-F]{24}$","description":"AccountSite id — must belong to this merchant","type":"string"},"comments":{"description":"Note recorded on the change's backup record","type":"string"},"claims":{"anyOf":[{"type":"null"},{"type":"object","properties":{"void":{"type":"boolean"},"refund":{"type":"boolean"},"cashRefund":{"type":"boolean"},"reprint":{"type":"boolean"},"manualEntry":{"type":"boolean"},"authorization":{"type":"boolean"},"priceOverride":{"type":"boolean"},"minTransactionAmount":{"minimum":0,"type":"number"},"maxTransactionAmount":{"minimum":0,"type":"number"},"mustClockIn":{"type":"boolean"},"historyScope":{"anyOf":[{"type":"string","enum":["self"]},{"type":"string","enum":["site"]},{"type":"string","enum":["account"]}]}}}]}}}}}},"parameters":[{"schema":{"pattern":"^[A-Za-z0-9]{1,64}$","type":"string"},"in":"path","name":"merchant","required":true,"description":"Account handle — merchantId or 24-hex ObjectId"},{"schema":{"pattern":"^[0-9a-fA-F]{24}$","type":"string"},"in":"path","name":"employeeId","required":true,"description":"Employee ObjectId"}],"security":[{"ApiKeyAuth":["employee:write"]}],"x-badges":[{"name":"employee:write","position":"after"}],"responses":{"200":{"description":"The updated employee.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/EmployeeRecord"}}}},"400":{"description":"Bad request — a header, parameter or body field broke the schema (`invalid_request`, with `detail.field`).","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"401":{"description":"Unauthorized — the request carried no `x-api-key` header.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"403":{"description":"Forbidden — an unknown, revoked or expired key, an inactive account, a `{merchant}` that is not the key's account, a missing scope (`insufficient_scope`, with `detail.missing`), or the plan's employee limit.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"404":{"description":"Not found — no matching resource on this account.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}}},"delete":{"operationId":"deleteEmployee","summary":"Soft-delete an employee","tags":["Employees"],"description":"Soft-delete: the employee leaves the staff and can no longer sign in; transactions keep the snapshot they were taken with. Their PIN and email are released at once. Afterwards GET and PUT answer `404` and the employee cannot be brought back through the API. Answers `204` whether or not the id existed.","parameters":[{"schema":{"pattern":"^[A-Za-z0-9]{1,64}$","type":"string"},"in":"path","name":"merchant","required":true,"description":"Account handle — merchantId or 24-hex ObjectId"},{"schema":{"pattern":"^[0-9a-fA-F]{24}$","type":"string"},"in":"path","name":"employeeId","required":true,"description":"Employee ObjectId"}],"security":[{"ApiKeyAuth":["employee:write"]}],"x-badges":[{"name":"employee:write","position":"after"}],"responses":{"204":{"description":"Employee soft-deleted; no content."},"401":{"description":"Unauthorized — the request carried no `x-api-key` header.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"403":{"description":"Forbidden — an unknown, revoked or expired key, an inactive account, a `{merchant}` that is not the key's account, a missing scope (`insufficient_scope`, with `detail.missing`), or the plan's employee limit.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}}}},"/api/v2/merchants/{merchant}/orders":{"get":{"operationId":"listOrders","summary":"List orders for the account","tags":["Orders"],"description":"Read-only — orders are generated from transactions, never created or edited directly. Newest first.\n\n`?format=clover` emits the Clover Order shape (`{ elements }`); the default `pax` emits a rolled-up `amount` and trimmed line `items` in `{ items, paging }`.\n\n**Paging:** `limit` (default 1000, max 5000) and `offset`. `filter=createdTime>=<ms>` and `filter=createdTime<=<ms>` narrow by creation time.","parameters":[{"schema":{"enum":["pax","clover"],"default":"pax","type":"string"},"in":"query","name":"format","required":false,"description":"Response shape — `pax` (default, trimmed native) or `clover` (Clover Order shape)."},{"schema":{"anyOf":[{"type":"string"},{"type":"array","items":{"type":"string"}}]},"in":"query","name":"filter","required":false,"description":"Clover-style filter(s), e.g. `createdTime>=1700000000000`"},{"schema":{"minimum":1,"maximum":5000,"type":"integer"},"in":"query","name":"limit","required":false,"description":"Max results (default 1000, capped 5000)"},{"schema":{"minimum":0,"type":"integer"},"in":"query","name":"offset","required":false,"description":"Offset for pagination (default 0)"},{"schema":{"pattern":"^[A-Za-z0-9]{1,64}$","type":"string"},"in":"path","name":"merchant","required":true,"description":"Account handle — merchantId or 24-hex ObjectId"}],"security":[{"ApiKeyAuth":["transaction:read"]}],"x-badges":[{"name":"transaction:read","position":"after"}],"responses":{"200":{"description":"Orders for the account, shaped per `format`.","content":{"application/json":{"schema":{"description":"Orders, newest first.","type":"object","required":["items","paging"],"properties":{"items":{"type":"array","items":{"$ref":"#/components/schemas/Order"}},"paging":{"$ref":"#/components/schemas/Paging"}}},"examples":{"page":{"summary":"A page","value":{"items":[{"id":"6ab6fa29a31a6c313c223b76","state":"paid","source":"pos","clientOrderId":"ord-7f3a","site":{"id":"6ab6fa29a31a6c313c223b60"},"amount":{"currency":"CAD","subtotal":1010,"discount":0,"tax":131,"total":1141,"paid":1141,"remaining":0,"refunded":0},"counts":{"payments":1,"refunds":0},"items":[{"lineNumber":1,"name":"LATTE 12OZ","sku":"LAT-12","product":{"id":"6ab6fa29a31a6c313c223b75"},"quantity":"2","amount":{"unit":475,"discount":0,"tax":131,"total":1141},"modifiers":[{"group":"Milk","options":[{"name":"Oat","amount":60}]}]}],"time":{"opened":1790337480000,"firstPaid":1790337600000,"paid":1790337600000,"closed":1790337601000,"updated":1790376489406}}],"paging":{"total":1,"limit":1000,"offset":0}}}}}}},"401":{"description":"Unauthorized — the request carried no `x-api-key` header.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"403":{"description":"Forbidden — an unknown, revoked or expired key, an inactive account, a `{merchant}` that is not the key's account, a missing scope (`insufficient_scope`, with `detail.missing`), or the plan's employee limit.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}}}},"/api/v2/merchants/{merchant}/orders/{orderId}":{"get":{"operationId":"getOrder","summary":"Get an order by id","tags":["Orders"],"description":"Read-only. `?format=clover` emits the Clover Order shape; default `pax` emits the rolled-up native shape.","parameters":[{"schema":{"enum":["pax","clover"],"default":"pax","type":"string"},"in":"query","name":"format","required":false,"description":"Response shape — `pax` (default, trimmed native) or `clover` (Clover Order shape)."},{"schema":{"pattern":"^[A-Za-z0-9]{1,64}$","type":"string"},"in":"path","name":"merchant","required":true,"description":"Account handle — merchantId or 24-hex ObjectId"},{"schema":{"pattern":"^[0-9a-fA-F]{24}$","type":"string"},"in":"path","name":"orderId","required":true,"description":"Order ObjectId"}],"security":[{"ApiKeyAuth":["transaction:read"]}],"x-badges":[{"name":"transaction:read","position":"after"}],"responses":{"200":{"description":"The order, shaped per `format`.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Order"}}}},"401":{"description":"Unauthorized — the request carried no `x-api-key` header.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"403":{"description":"Forbidden — an unknown, revoked or expired key, an inactive account, a `{merchant}` that is not the key's account, a missing scope (`insufficient_scope`, with `detail.missing`), or the plan's employee limit.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"404":{"description":"Not found — no matching resource on this account.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}}}},"/api/v2/merchants/{merchant}/requests/{id}":{"get":{"operationId":"getMerchantRequest","summary":"Reconcile an ecr request by id","tags":["Transactions"],"description":"Fetch any ECR command dispatched on this account — lifecycle status, PAX-shaped financial outcome (`result` / `amount` / `card` / `processor`), the linked transaction and error. Use it to resolve an unknown / timed-out outcome: the `504` error's `detail.requestId` is the id to pass here. Records are kept for about 7 days.","parameters":[{"schema":{"pattern":"^[A-Za-z0-9]{1,64}$","type":"string"},"in":"path","name":"merchant","required":true,"description":"Account handle — merchantId or 24-hex ObjectId"},{"schema":{"pattern":"^[0-9a-fA-F]{24}$","type":"string"},"in":"path","name":"id","required":true,"description":"Request id — the `id` returned by an ecr command (also `detail.requestId` on a 409/422/502/504 error)"}],"security":[{"ApiKeyAuth":["transaction:read"]}],"x-badges":[{"name":"transaction:read","position":"after"}],"responses":{"200":{"description":"The dispatched ecr request in the PAX shape.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/RequestRecord"},"examples":{"completed":{"summary":"Completed and linked","value":{"id":"6ab6fa2aa31a6c313c223b90","type":"semi_sale","status":"completed","externalId":"pos-inv-9001","result":{"status":"approved","code":"000000","hostCode":"00","message":"APPROVAL"},"amount":{"partial":false,"requested":1280,"approved":1280,"tax":130,"tip":150},"card":{"brand":"VISA","last4":"0119","entryMode":"contactless","holder":"JANE DOE"},"processor":{"authorizationCode":"OK4821","referenceNumber":"000118","hostReferenceNumber":"530612345678","batchNumber":"42","invoiceNumber":"8842","traceNumber":"118"},"time":{"sent":1790376490899,"completed":1790376490904},"transaction":{"id":"6ab6fa29a31a6c313c223b6c"}}}}}}},"400":{"description":"Bad request — a header, parameter or body field broke the schema (`invalid_request`, with `detail.field`).","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"401":{"description":"Unauthorized — the request carried no `x-api-key` header.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"403":{"description":"Forbidden — an unknown, revoked or expired key, an inactive account, a `{merchant}` that is not the key's account, a missing scope (`insufficient_scope`, with `detail.missing`), or the plan's employee limit.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"404":{"description":"Not found — no matching resource on this account.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}}}},"/api/v2/merchants/{merchant}/transactions":{"get":{"operationId":"listTransactions","summary":"List transactions for the account","tags":["Transactions"],"description":"Transactions, newest first. **No `type` behaves as `type=sale`**: sales not later voided or refunded — declined attempts included, so check `result.status`. Pass `type=void`, `type=refund` or `type=authorize` (comma-separated for several, e.g. `type=sale,void`) for other operations; `void` and `refund` rows carry `reason` and the `original` payment. Asking for more than `sale` drops the voided/refunded exclusion.\n\n**Paging:** `limit` (default and maximum 1000) and `offset`; `paging.total` counts every match — keep fetching while `offset + items.length < total`.\n\nThere is no filter by `externalId` or idempotency key: reconcile a command through its request record instead.","parameters":[{"schema":{"type":"string"},"in":"query","name":"type","required":false,"description":"Filter by operation type: `sale` | `void` | `refund` | `authorize`. Comma-separated for multiple (e.g. `sale,refund`). Omit for the default — equivalent to `type=sale` (excludes voided/refunded sales)."},{"schema":{"format":"date-time","type":"string"},"in":"query","name":"from","required":false,"description":"Inclusive lower bound on `createdAt` (ISO 8601)"},{"schema":{"format":"date-time","type":"string"},"in":"query","name":"to","required":false,"description":"Exclusive upper bound on `createdAt` (ISO 8601)"},{"schema":{"minimum":1,"maximum":1000,"type":"integer"},"in":"query","name":"limit","required":false,"description":"Page size (default 1000)."},{"schema":{"minimum":0,"type":"integer"},"in":"query","name":"skip","required":false,"description":"Alias of `offset`."},{"schema":{"minimum":0,"type":"integer"},"in":"query","name":"offset","required":false,"description":"Rows to skip (default 0). `skip` is accepted as an alias."},{"schema":{"enum":["true","false"],"type":"string"},"in":"query","name":"voided","required":false,"description":"Only voided (`true`) or only standing (`false`) rows."},{"schema":{"enum":["true","false"],"type":"string"},"in":"query","name":"refunded","required":false,"description":"Only rows with (`true`) or without (`false`) a refund against them."},{"schema":{"pattern":"^[0-9a-f]{24}$","type":"string"},"in":"query","name":"device","required":false,"description":"Only rows taken on this terminal."},{"schema":{"pattern":"^[0-9a-f]{24}$","type":"string"},"in":"query","name":"employee","required":false,"description":"Only rows taken by this employee."},{"schema":{"type":"string"},"in":"query","name":"tender","required":false,"description":"Only rows paid this way — a method (`credit`, `cash`, …) or a tender id (`PAX-CREDIT`)."},{"schema":{"enum":["pax","clover"],"default":"pax","type":"string"},"in":"query","name":"format","required":false,"description":"Response shape — `pax` (default) or `clover`."},{"schema":{"pattern":"^[A-Za-z0-9]{1,64}$","type":"string"},"in":"path","name":"merchant","required":true,"description":"Account handle — merchantId or 24-hex ObjectId"}],"security":[{"ApiKeyAuth":["transaction:read"]}],"x-badges":[{"name":"transaction:read","position":"after"}],"responses":{"200":{"description":"Paged transactions (newest first).","content":{"application/json":{"schema":{"description":"Transactions, newest first.","type":"object","required":["items","paging"],"properties":{"items":{"type":"array","items":{"$ref":"#/components/schemas/Transaction"}},"paging":{"$ref":"#/components/schemas/Paging"}}},"examples":{"page":{"summary":"A page","value":{"items":[{"id":"6ab6fa29a31a6c313c223b6c","externalId":"pos-inv-8842","operation":"sale","method":"credit","result":{"status":"approved","code":"000000","message":"APPROVED"},"voided":false,"amount":{"currency":"CAD","requested":1130,"approved":1280,"subtotal":1000,"tax":130,"tip":150},"card":{"brand":"VISA","last4":"0119","entryMode":"contactless","holder":"JANE DOE"},"processor":{"authorizationCode":"OK4821","referenceNumber":"000118","hostReferenceNumber":"530612345678","batchNumber":"42","invoiceNumber":"8842","traceNumber":"118"},"employee":{"id":"6ab6fa29a31a6c313c223b63","name":"Alex Martin"},"device":{"id":"6ab6fa29a31a6c313c223b67","name":"Front Counter","serial":"1850123456"},"site":{"id":"6ab6fa29a31a6c313c223b60"},"location":{"lat":43.6487,"lng":-79.3817},"offline":false,"time":{"initiated":1790346602000,"created":1790376489357,"captured":1790346612000,"updated":1790376489357}},{"id":"6ab6fa29a31a6c313c223b6f","operation":"sale","method":"cash","result":{"status":"approved","code":"000000","message":"OK"},"voided":false,"amount":{"currency":"CAD","requested":1130,"approved":1130,"subtotal":1000,"tax":130,"tendered":2000,"change":870},"employee":{"id":"6ab6fa29a31a6c313c223b63","name":"Alex Martin"},"device":{"id":"6ab6fa29a31a6c313c223b67","name":"Front Counter","serial":"1850123456"},"site":{"id":"6ab6fa29a31a6c313c223b60"},"kind":"order","offline":false,"time":{"created":1790376489378,"captured":1790337600000,"updated":1790376489378}}],"paging":{"total":2,"limit":1000,"offset":0}}},"refunds":{"summary":"type=refund","value":{"items":[{"id":"6ab6fa29a31a6c313c223b6e","operation":"refund","method":"debit","result":{"status":"approved","code":"000000","message":"APPROVED"},"voided":false,"amount":{"currency":"CAD","requested":565,"approved":565,"subtotal":500,"tax":65,"tip":0},"card":{"brand":"INTERAC","last4":"1234","entryMode":"chip"},"employee":{"id":"6ab6fa29a31a6c313c223b63","name":"Alex Martin"},"device":{"id":"6ab6fa29a31a6c313c223b67","name":"Front Counter","serial":"1850123456"},"site":{"id":"6ab6fa29a31a6c313c223b60"},"parentTransaction":{"id":"6ab6fa29a31a6c313c223b6d"},"reason":{"code":"item_returned","message":"Item returned"},"original":{"id":"6ab6fa29a31a6c313c223b6d","operation":"sale","method":"debit","result":{"status":"approved","code":"000000","message":"APPROVED"},"voided":false,"amount":{"currency":"CAD","requested":2260,"approved":2260,"subtotal":2000,"tax":260,"tip":0,"refunded":565},"card":{"brand":"INTERAC","last4":"1234","entryMode":"chip"},"device":{"id":"6ab6fa29a31a6c313c223b67","name":"Front Counter","serial":"1850123456"},"site":{"id":"6ab6fa29a31a6c313c223b60"},"offline":false,"time":{"initiated":1790341200000,"created":1790376489369,"captured":1790341209000,"updated":1790376489369}},"offline":false,"time":{"created":1790376489374,"captured":1790349000000,"updated":1790376489374}}],"paging":{"total":1,"limit":1000,"offset":0}}}}}}},"400":{"description":"Bad request — a header, parameter or body field broke the schema (`invalid_request`, with `detail.field`).","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"401":{"description":"Unauthorized — the request carried no `x-api-key` header.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"403":{"description":"Forbidden — an unknown, revoked or expired key, an inactive account, a `{merchant}` that is not the key's account, a missing scope (`insufficient_scope`, with `detail.missing`), or the plan's employee limit.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}}}},"/api/v2/merchants/{merchant}/transactions/{transactionId}":{"get":{"operationId":"getTransaction","summary":"Get a transaction by id","tags":["Transactions"],"description":"One transaction in the PAX-native shape. A void or refund carries `reason` and the `original` payment it reverses.","parameters":[{"schema":{"enum":["pax","clover"],"default":"pax","type":"string"},"in":"query","name":"format","required":false,"description":"Response shape — `pax` (default) or `clover`."},{"schema":{"pattern":"^[A-Za-z0-9]{1,64}$","type":"string"},"in":"path","name":"merchant","required":true,"description":"Account handle — merchantId or 24-hex ObjectId"},{"schema":{"pattern":"^[0-9a-fA-F]{24}$","type":"string"},"in":"path","name":"transactionId","required":true,"description":"Transaction ObjectId"}],"security":[{"ApiKeyAuth":["transaction:read"]}],"x-badges":[{"name":"transaction:read","position":"after"}],"responses":{"200":{"description":"The transaction.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Transaction"},"examples":{"sale":{"summary":"Card sale","value":{"id":"6ab6fa29a31a6c313c223b6c","externalId":"pos-inv-8842","operation":"sale","method":"credit","result":{"status":"approved","code":"000000","message":"APPROVED"},"voided":false,"amount":{"currency":"CAD","requested":1130,"approved":1280,"subtotal":1000,"tax":130,"tip":150},"card":{"brand":"VISA","last4":"0119","entryMode":"contactless","holder":"JANE DOE"},"processor":{"authorizationCode":"OK4821","referenceNumber":"000118","hostReferenceNumber":"530612345678","batchNumber":"42","invoiceNumber":"8842","traceNumber":"118"},"employee":{"id":"6ab6fa29a31a6c313c223b63","name":"Alex Martin"},"device":{"id":"6ab6fa29a31a6c313c223b67","name":"Front Counter","serial":"1850123456"},"site":{"id":"6ab6fa29a31a6c313c223b60"},"location":{"lat":43.6487,"lng":-79.3817},"offline":false,"time":{"initiated":1790346602000,"created":1790376489357,"captured":1790346612000,"updated":1790376489357}}},"refund":{"summary":"A refund, with its original","value":{"id":"6ab6fa29a31a6c313c223b6e","operation":"refund","method":"debit","result":{"status":"approved","code":"000000","message":"APPROVED"},"voided":false,"amount":{"currency":"CAD","requested":565,"approved":565,"subtotal":500,"tax":65,"tip":0},"card":{"brand":"INTERAC","last4":"1234","entryMode":"chip"},"employee":{"id":"6ab6fa29a31a6c313c223b63","name":"Alex Martin"},"device":{"id":"6ab6fa29a31a6c313c223b67","name":"Front Counter","serial":"1850123456"},"site":{"id":"6ab6fa29a31a6c313c223b60"},"parentTransaction":{"id":"6ab6fa29a31a6c313c223b6d"},"reason":{"code":"item_returned","message":"Item returned"},"original":{"id":"6ab6fa29a31a6c313c223b6d","operation":"sale","method":"debit","result":{"status":"approved","code":"000000","message":"APPROVED"},"voided":false,"amount":{"currency":"CAD","requested":2260,"approved":2260,"subtotal":2000,"tax":260,"tip":0,"refunded":565},"card":{"brand":"INTERAC","last4":"1234","entryMode":"chip"},"device":{"id":"6ab6fa29a31a6c313c223b67","name":"Front Counter","serial":"1850123456"},"site":{"id":"6ab6fa29a31a6c313c223b60"},"offline":false,"time":{"initiated":1790341200000,"created":1790376489369,"captured":1790341209000,"updated":1790376489369}},"offline":false,"time":{"created":1790376489374,"captured":1790349000000,"updated":1790376489374}}}}}}},"401":{"description":"Unauthorized — the request carried no `x-api-key` header.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"403":{"description":"Forbidden — an unknown, revoked or expired key, an inactive account, a `{merchant}` that is not the key's account, a missing scope (`insufficient_scope`, with `detail.missing`), or the plan's employee limit.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"404":{"description":"Not found — no matching resource on this account.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}}}},"/api/v2/merchants/{merchant}/transactions/{transactionId}/receipt":{"get":{"operationId":"getTransactionReceipt","summary":"Get a transaction receipt","tags":["Transactions"],"description":"Render a receipt for the transaction. `format=text` (default) returns the printer text the terminal prints; `format=html` returns the rendered HTML receipt; `format=json` returns the hosted-receipt `url` plus the `merchant`, the `location` (the site) and the ledger `transaction` behind it. text/html use the account's saved receipt template for the transaction's operation + `lang` (English fallback), or a built-in layout if none. The returned `url` is the link the portal's Copy URL gives and the QR on the terminal's paper receipt holds: `/pub/r/<clientTransactionId>`, the id the terminal minted for the sale (a UUID). `order`/`map`/`itinerary`/`layout` apply only to `format=json`: with none of them the `url` has no query; with any, they control what it unlocks on the hosted receipt page, encrypted into its `?o=` token server-side — editing that URL cannot add sections that weren't requested here.","parameters":[{"schema":{"enum":["text","html","json"],"default":"text","type":"string"},"in":"query","name":"format","required":false,"description":"Receipt format (default text)."},{"schema":{"enum":["en","fr"],"type":"string"},"in":"query","name":"lang","required":false,"description":"Template language for text/html (default en)."},{"schema":{"enum":["customer","merchant"],"type":"string"},"in":"query","name":"copy","required":false,"description":"Which copy to render for text/html, so a template block written with `{if receipt.merchant}` or `{if receipt.customer}` shows. Omitted, the copy is not known and neither block shows. Over the API `receipt.both` is never set, so a template's copy-marker block does not print. Ignored for format=json."},{"schema":{"type":"boolean"},"in":"query","name":"order","required":false,"description":"format=json only: include order items on the hosted receipt URL."},{"schema":{"type":"boolean"},"in":"query","name":"map","required":false,"description":"format=json only: include the location map on the hosted receipt URL."},{"schema":{"type":"boolean"},"in":"query","name":"itinerary","required":false,"description":"format=json only: include the work-order itinerary on the hosted receipt URL."},{"schema":{"enum":["split","collapse"],"type":"string"},"in":"query","name":"layout","required":false,"description":"format=json only: hosted receipt layout."},{"schema":{"pattern":"^[A-Za-z0-9]{1,64}$","type":"string"},"in":"path","name":"merchant","required":true,"description":"Account handle — merchantId or 24-hex ObjectId"},{"schema":{"pattern":"^[0-9a-fA-F]{24}$","type":"string"},"in":"path","name":"transactionId","required":true,"description":"Transaction ObjectId"}],"security":[{"ApiKeyAuth":["transaction:read"]}],"x-badges":[{"name":"transaction:read","position":"after"}],"responses":{"200":{"description":"Rendered receipt (text/html) or structured payload (json).","content":{"application/json":{"schema":{"description":"A rendered receipt, or the hosted-receipt link with the data behind it.","anyOf":[{"title":"Rendered receipt","description":"`format=text` or `format=html`.","type":"object","required":["format","content","type","id","lang","templated"],"properties":{"format":{"enum":["text","html"],"type":"string"},"content":{"description":"The receipt: printer text, or an HTML document.","type":"string"},"type":{"description":"Template type used, e.g. `receipt_sale`.","type":"string"},"id":{"pattern":"^[0-9a-f]{24}$","description":"Transaction id.","type":"string"},"lang":{"enum":["en","fr"],"type":"string"},"templated":{"description":"False when the account has no template for this language and the built-in English layout was used.","type":"boolean"}}},{"title":"Hosted receipt","description":"`format=json`.","type":"object","required":["url","merchant","location","transaction"],"properties":{"url":{"description":"The hosted receipt page: `/pub/r/<clientTransactionId>`, the same link as the QR on the terminal's paper receipt (the id the terminal minted for the sale; for a payment link, a station sale or an older sale it is the ledger id). Unlocks only the sections asked for, sealed in `?o=`; without any, there is no query.","type":"string"},"merchant":{"additionalProperties":true,"description":"`id`, `name`, `email`, `phone`, `address`, `currency`, `logo`.","type":"object","properties":{}},"location":{"description":"The site: `id`, `name`, `email`, `phone`, `address`, `timezone`.","anyOf":[{"additionalProperties":true,"type":"object","properties":{}},{"type":"null"}]},"transaction":{"additionalProperties":true,"description":"The ledger record behind the receipt, as stored (`_id`, `*AmountCents`, the terminal's host and trace blocks).","type":"object","properties":{}}}}]},"examples":{"text":{"summary":"format=text","value":{"format":"text","content":"Acme Coffee\nDowntown\n123 King St W, Toronto, ON, M5H 1A1\n\nSep 25, 2026  06:48 PM\nAMOUNT:   $10.00\nTIP:      $1.50\nTAX:      $1.30\nTOTAL:    $12.80\nCARD: VISA ****0119\nAUTH: OK4821\nREF:  000118\n\nServed by Alex Martin","type":"receipt_sale","id":"6ab6fa29a31a6c313c223b6c","lang":"en","templated":false}},"json":{"summary":"format=json","value":{"url":"https://pax.posvelocity.com/pub/r/0b9e6f6e-7d0c-4f0b-9a55-0f4e2c1d7a11","merchant":{"id":"6ab6fa29a31a6c313c223b55","name":"Acme Coffee","email":"hello@acme.test","phone":"+14165550142","currency":"CAD","logo":null},"location":{"id":"6ab6fa29a31a6c313c223b60","name":"Downtown","timezone":"America/Toronto"},"transaction":{"_id":"6ab6fa29a31a6c313c223b6c","type":"credit","operation":0,"approved":true,"requestedAmountCents":1130,"approvedAmountCents":1280,"taxAmountCents":130,"tipAmountCents":150,"currency":"CAD","cardLast4":"0119","cardBrand":"VISA"}}}}}}},"401":{"description":"Unauthorized — the request carried no `x-api-key` header.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"403":{"description":"Forbidden — an unknown, revoked or expired key, an inactive account, a `{merchant}` that is not the key's account, a missing scope (`insufficient_scope`, with `detail.missing`), or the plan's employee limit.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"404":{"description":"Not found — no matching resource on this account.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}}}},"/api/v2/merchants/{merchant}/workorders":{"get":{"operationId":"listWorkorders","summary":"List work orders for the account","tags":["Workorders"],"description":"Work orders by `scheduledAt`, earliest first. Filter with `from`/`to` (ISO 8601, on `scheduledAt`), `site`, `employee`, `status`. Unpaged, and in the stored shape (`_id`, ISO dates) rather than the PAX-native one.","parameters":[{"schema":{"format":"date-time","type":"string"},"in":"query","name":"from","required":false},{"schema":{"format":"date-time","type":"string"},"in":"query","name":"to","required":false},{"schema":{"pattern":"^[0-9a-fA-F]{24}$","type":"string"},"in":"query","name":"site","required":false},{"schema":{"pattern":"^[0-9a-fA-F]{24}$","type":"string"},"in":"query","name":"employee","required":false},{"schema":{"type":"string"},"in":"query","name":"status","required":false,"description":"`pending` | `en_route` | `completed` | `canceled` (one L — `cancelled` matches nothing)."},{"schema":{"pattern":"^[A-Za-z0-9]{1,64}$","type":"string"},"in":"path","name":"merchant","required":true,"description":"Account handle — merchantId or 24-hex ObjectId"}],"security":[{"ApiKeyAuth":["workorder:read"]}],"x-badges":[{"name":"workorder:read","position":"after"}],"responses":{"200":{"description":"Work orders for the account.","content":{"application/json":{"schema":{"description":"Work orders by scheduled time — unpaged.","type":"object","required":["count","items"],"properties":{"count":{"type":"integer"},"items":{"type":"array","items":{"$ref":"#/components/schemas/Workorder"}}}},"examples":{"day":{"summary":"Scheduled","value":{"count":1,"items":[{"_id":"6ab6fa2aa31a6c313c223b88","site":"6ab6fa29a31a6c313c223b60","employee":"6ab6fa29a31a6c313c223b63","name":"Furnace inspection","notes":null,"scheduledAt":"2026-10-02T15:00:00.000Z","anytime":false,"durationMinutes":60,"routeOrder":0,"invoiceNumber":null,"currency":"CAD","priceCents":12900,"taxIncluded":false,"taxAddOnTop":true,"autoSendInvoice":true,"customer":{"name":"Dana Ortiz","company":null,"email":null,"phone":"+14165550123","address":"77 Queen St E, Toronto"},"contactId":"6ab6fa2aa31a6c313c223b87","location":{"lat":43.6525,"lng":-79.3755},"navigation":{"avoidTolls":false},"signatureMode":"optional","signature":{"file":null,"capturedAt":null,"capturedBy":null},"photoRequired":false,"notesRequired":false,"status":"pending","canceledAt":null,"completedAt":null,"transitAt":null,"completionNotes":null,"creator":"6ab6fa29a31a6c313c223b78","account":"6ab6fa29a31a6c313c223b55","transactions":[],"createdAt":"2026-09-25T22:48:10.706Z","updatedAt":"2026-09-25T22:48:10.706Z"}]}}}}}},"401":{"description":"Unauthorized — the request carried no `x-api-key` header.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"403":{"description":"Forbidden — an unknown, revoked or expired key, an inactive account, a `{merchant}` that is not the key's account, a missing scope (`insufficient_scope`, with `detail.missing`), or the plan's employee limit.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}}},"post":{"operationId":"createWorkorder","summary":"Create a new work order","tags":["Workorders"],"description":"Creates a scheduled work order. `location` is geocoded from `customer.address` when omitted. A customer email or phone adds the customer to the account's Contacts when none matches. This operation takes no idempotency key: retrying it creates a second work order.","requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","required":["name","scheduledAt"],"properties":{"name":{"description":"Work order title","type":"string"},"scheduledAt":{"format":"date-time","description":"Scheduled start as the site's wall-clock time, written as if UTC: 11:00 at the site is `…T11:00:00Z`. No time-zone conversion.","type":"string"},"durationMinutes":{"minimum":15,"description":"Minutes, at least 15 (default 30).","type":"integer"},"site":{"pattern":"^[0-9a-fA-F]{24}$","description":"AccountSite ObjectId","type":"string"},"employee":{"pattern":"^[0-9a-fA-F]{24}$","description":"Assigned employee ObjectId","type":"string"},"notes":{"type":"string"},"invoiceNumber":{"type":"string"},"priceCents":{"type":"integer"},"currency":{"description":"Ignored — a work order takes the account's currency.","type":"string"},"taxIncluded":{"type":"boolean"},"taxAddOnTop":{"type":"boolean"},"customer":{"additionalProperties":true,"description":"Snapshot of the customer contact/address on this work order","type":"object","properties":{"name":{"type":"string"},"company":{"type":"string"},"email":{"format":"email","type":"string"},"phone":{"type":"string"},"address":{"type":"string"}}},"location":{"description":"GPS coordinates for the job site (geocoded from the address when omitted)","type":"object","required":["lat","lng"],"properties":{"lat":{"type":"number"},"lng":{"type":"number"}}},"contactId":{"pattern":"^[0-9a-fA-F]{24}$","description":"Explicit directory contact to link","type":"string"},"anytime":{"description":"No fixed time — schedule is date-only","type":"boolean"},"routeOrder":{"type":"number"},"autoSendInvoice":{"type":"boolean"},"signatureMode":{"enum":["none","optional","mandatory"],"description":"Proof-of-service signature requirement","type":"string"},"photoRequired":{"type":"boolean"},"notesRequired":{"type":"boolean"}}},"examples":{"visit":{"summary":"A site visit","value":{"name":"Furnace inspection","scheduledAt":"2026-10-02T15:00:00.000Z","durationMinutes":60,"site":"6ab6fa29a31a6c313c223b60","employee":"6ab6fa29a31a6c313c223b63","priceCents":12900,"taxAddOnTop":true,"customer":{"name":"Dana Ortiz","phone":"+14165550123","address":"77 Queen St E, Toronto"},"signatureMode":"optional"}}}}}},"parameters":[{"schema":{"pattern":"^[A-Za-z0-9]{1,64}$","type":"string"},"in":"path","name":"merchant","required":true,"description":"Account handle — merchantId or 24-hex ObjectId"}],"security":[{"ApiKeyAuth":["workorder:write"]}],"x-badges":[{"name":"workorder:write","position":"after"}],"responses":{"201":{"description":"The created work order.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Workorder"},"examples":{"created":{"summary":"Created","value":{"_id":"6ab6fa2aa31a6c313c223b88","site":"6ab6fa29a31a6c313c223b60","employee":"6ab6fa29a31a6c313c223b63","name":"Furnace inspection","notes":null,"scheduledAt":"2026-10-02T15:00:00.000Z","anytime":false,"durationMinutes":60,"routeOrder":0,"invoiceNumber":null,"currency":"CAD","priceCents":12900,"taxIncluded":false,"taxAddOnTop":true,"autoSendInvoice":true,"customer":{"name":"Dana Ortiz","company":null,"email":null,"phone":"+14165550123","address":"77 Queen St E, Toronto"},"contactId":"6ab6fa2aa31a6c313c223b87","location":{"lat":43.6525,"lng":-79.3755},"navigation":{"avoidTolls":false},"signatureMode":"optional","signature":{"file":null,"capturedAt":null,"capturedBy":null},"photoRequired":false,"notesRequired":false,"status":"pending","canceledAt":null,"completedAt":null,"transitAt":null,"completionNotes":null,"creator":"6ab6fa29a31a6c313c223b78","account":"6ab6fa29a31a6c313c223b55","transactions":[],"createdAt":"2026-09-25T22:48:10.706Z","updatedAt":"2026-09-25T22:48:10.706Z"}}}}}},"400":{"description":"Bad request — a header, parameter or body field broke the schema (`invalid_request`, with `detail.field`).","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"401":{"description":"Unauthorized — the request carried no `x-api-key` header.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"403":{"description":"Forbidden — an unknown, revoked or expired key, an inactive account, a `{merchant}` that is not the key's account, a missing scope (`insufficient_scope`, with `detail.missing`), or the plan's employee limit.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}}}},"/api/v2/merchants/{merchant}/workorders/{id}":{"get":{"operationId":"getWorkorder","summary":"Get a work order by id","tags":["Workorders"],"description":"Returns the full work order document.","parameters":[{"schema":{"pattern":"^[A-Za-z0-9]{1,64}$","type":"string"},"in":"path","name":"merchant","required":true,"description":"Account handle — merchantId or 24-hex ObjectId"},{"schema":{"pattern":"^[0-9a-fA-F]{24}$","type":"string"},"in":"path","name":"id","required":true,"description":"Work order ObjectId"}],"security":[{"ApiKeyAuth":["workorder:read"]}],"x-badges":[{"name":"workorder:read","position":"after"}],"responses":{"200":{"description":"The work order document.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Workorder"}}}},"401":{"description":"Unauthorized — the request carried no `x-api-key` header.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"403":{"description":"Forbidden — an unknown, revoked or expired key, an inactive account, a `{merchant}` that is not the key's account, a missing scope (`insufficient_scope`, with `detail.missing`), or the plan's employee limit.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"404":{"description":"Not found — no matching resource on this account.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}}},"put":{"operationId":"updateWorkorder","summary":"Update a work order","tags":["Workorders"],"description":"Partial update — only fields present in the body are changed, except that `customer`, `location`, `items` and `itinerary` are replaced as a whole: resend their unchanged parts. A `completed` work order takes a change to `customer.email` only: anything else answers HTTP `200` with `{ \"code\": 409, \"reason\": \"completed\" }` instead of the work order.","requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","properties":{"name":{"description":"Work order title","type":"string"},"scheduledAt":{"format":"date-time","description":"Scheduled start as the site's wall-clock time, written as if UTC: 11:00 at the site is `…T11:00:00Z`. No time-zone conversion.","type":"string"},"durationMinutes":{"minimum":15,"description":"Minutes, at least 15 (default 30).","type":"integer"},"site":{"pattern":"^[0-9a-fA-F]{24}$","description":"AccountSite ObjectId","type":"string"},"employee":{"pattern":"^[0-9a-fA-F]{24}$","description":"Assigned employee ObjectId","type":"string"},"notes":{"type":"string"},"invoiceNumber":{"type":"string"},"priceCents":{"type":"integer"},"currency":{"description":"Ignored — a work order takes the account's currency.","type":"string"},"taxIncluded":{"type":"boolean"},"taxAddOnTop":{"type":"boolean"},"customer":{"additionalProperties":true,"description":"Snapshot of the customer contact/address on this work order","type":"object","properties":{"name":{"type":"string"},"company":{"type":"string"},"email":{"format":"email","type":"string"},"phone":{"type":"string"},"address":{"type":"string"}}},"location":{"description":"GPS coordinates for the job site (geocoded from the address when omitted)","type":"object","required":["lat","lng"],"properties":{"lat":{"type":"number"},"lng":{"type":"number"}}},"contactId":{"pattern":"^[0-9a-fA-F]{24}$","description":"Explicit directory contact to link","type":"string"},"anytime":{"description":"No fixed time — schedule is date-only","type":"boolean"},"routeOrder":{"type":"number"},"autoSendInvoice":{"type":"boolean"},"signatureMode":{"enum":["none","optional","mandatory"],"description":"Proof-of-service signature requirement","type":"string"},"photoRequired":{"type":"boolean"},"notesRequired":{"type":"boolean"}}}}}},"parameters":[{"schema":{"pattern":"^[A-Za-z0-9]{1,64}$","type":"string"},"in":"path","name":"merchant","required":true,"description":"Account handle — merchantId or 24-hex ObjectId"},{"schema":{"pattern":"^[0-9a-fA-F]{24}$","type":"string"},"in":"path","name":"id","required":true,"description":"Work order ObjectId"}],"security":[{"ApiKeyAuth":["workorder:write"]}],"x-badges":[{"name":"workorder:write","position":"after"}],"responses":{"200":{"description":"The updated work order.","content":{"application/json":{"schema":{"anyOf":[{"$ref":"#/components/schemas/Workorder"},{"title":"Refused","description":"A completed work order takes a change to `customer.email` only — anything else answers this, with HTTP 200.","type":"object","required":["code","reason"],"properties":{"code":{"type":"number","enum":[409]},"reason":{"type":"string","enum":["completed"]}}}]}}}},"400":{"description":"Bad request — a header, parameter or body field broke the schema (`invalid_request`, with `detail.field`).","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"401":{"description":"Unauthorized — the request carried no `x-api-key` header.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"403":{"description":"Forbidden — an unknown, revoked or expired key, an inactive account, a `{merchant}` that is not the key's account, a missing scope (`insufficient_scope`, with `detail.missing`), or the plan's employee limit.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"404":{"description":"Not found — no matching resource on this account.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}}},"delete":{"operationId":"deleteWorkorder","summary":"Delete a work order","tags":["Workorders"],"description":"Only allowed while the work order is still `pending` and its scheduled time hasn't passed, and it has no linked transactions. Returns `409` otherwise.","parameters":[{"schema":{"pattern":"^[A-Za-z0-9]{1,64}$","type":"string"},"in":"path","name":"merchant","required":true,"description":"Account handle — merchantId or 24-hex ObjectId"},{"schema":{"pattern":"^[0-9a-fA-F]{24}$","type":"string"},"in":"path","name":"id","required":true,"description":"Work order ObjectId"}],"security":[{"ApiKeyAuth":["workorder:write"]}],"x-badges":[{"name":"workorder:write","position":"after"}],"responses":{"204":{"description":"Work order deleted; no content."},"401":{"description":"Unauthorized — the request carried no `x-api-key` header.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"403":{"description":"Forbidden — an unknown, revoked or expired key, an inactive account, a `{merchant}` that is not the key's account, a missing scope (`insufficient_scope`, with `detail.missing`), or the plan's employee limit.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"404":{"description":"Not found — no matching resource on this account.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"409":{"description":"Conflict — see the operation: a terminal setting that is not yours to change (`terminal_profile_manages_software`, `terminal_paired_to_station`, `release_pending`), `invalid_site`, an employee PIN or email already in use, or a work order that can no longer be deleted.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}}}},"/api/v2/ecr/terminal/payment":{"post":{"operationId":"createPayment","summary":"Payment request","tags":["ECR Commands"],"description":"Charge a card on the terminal. The call holds until the customer taps, inserts or swipes and the host answers — **a decline is still `200`**, so read `result.status`. Declare the tax inside `amount` with `options.taxAmount`; ask for a tip with `options.tipBase`.","requestBody":{"required":true,"content":{"application/json":{"schema":{"additionalProperties":false,"type":"object","required":["amount"],"properties":{"amount":{"minimum":1,"description":"TOTAL amount to charge, in cents (e.g. 1130 = $11.30) — your basket total WITH tax already in it. Do not send a pre-tax subtotal and expect tax to be added: nothing is ever added to this number except a tip. If a tip is collected (see `options.tipBase`), the terminal adds it on top, so the approved total = `amount` + tip. Send the tax portion in `options.taxAmount` so it lands on the receipt.","type":"integer"},"externalId":{"maxLength":128,"description":"Optional client business id — stored verbatim and indexed for YOUR reconciliation lookups; the server takes no action on it.","type":"string"},"employee":{"pattern":"^[0-9a-fA-F]{24}$","description":"Optional employee id (employee.id) to attribute this transaction to. Must be an employee of the account (`404 employee_not_found`) and, if bound to a site, of the terminal's site (`409 employee_wrong_site`); it need not be on the terminal's roster.","type":"string"},"timeout":{"minimum":5000,"maximum":300000,"description":"ms the terminal waits for this operation to complete (e.g. customer card tap) before aborting with a timeout. Omitted = terminal default. The server's own wait window stretches to cover it.","type":"integer"},"options":{"additionalProperties":false,"description":"Payment options","type":"object","properties":{"cardNotPresent":{"description":"Allow manual card entry on the terminal. true = allow manual input; false = no manual input. Default false.","type":"boolean"},"taxAmount":{"minimum":0,"description":"How much tax is ALREADY INCLUDED in `amount`, in cents — a breakdown, never an addition: the card is charged `amount` whether you send this or not. Example: `amount: 1130` with `taxAmount: 130` = a $10.00 item plus $1.30 of tax. Used for the receipt and reporting (`approvedTaxAmount` on the result), and as the basis for `tipBase: \"net\"` (net base = `amount` − `taxAmount`). Omit or `0` on a tax-free sale.","type":"integer"},"tipBase":{"enum":["net","gross"],"description":"What the tip percentages are calculated on — net (`amount` − `options.taxAmount`, i.e. pre-tax) or gross (the full `amount`; the default when tipSuggestions are sent without tipBase). Either way the chosen tip is added ON TOP of `amount`. Omit both `tipBase` and `tipSuggestions` = no tip prompt. The tip is collected on the terminal's own tip screen (options, custom amount, NO TIP) before the card flow starts — never the payment SDK's built-in tip entry. When set WITHOUT tipSuggestions, the site's configured tip options are shown.","type":"string"},"tipSuggestions":{"additionalProperties":false,"description":"Tip screen configuration — its heading and the options shown on it.","type":"object","properties":{"title":{"maxLength":60,"description":"Heading at the top of the tip screen, e.g. \"Would you like to add a tip?\". Renders on up to 2 lines and is truncated after that — keep it short. Omitted = the site's configured tip message, else \"Add a tip?\". Does NOT apply to the custom-amount screen, which always reads CUSTOM TIP.","type":"string"},"options":{"description":"The tip options to render. Omitted = the site's configured tip options are used, so you can send `{ \"title\": \"…\" }` alone to re-word the heading over the merchant's own tips.","type":"array","items":{"additionalProperties":false,"description":"One tip option on the terminal's tip screen — a percentage OR a fixed amount, with an optional display label and accent color.","type":"object","properties":{"label":{"description":"Display label of the tip option, shown under the value on the tip card (e.g. \"Good\", \"Great\"). Omit for a bare percentage/amount tile — a label that merely repeats the percentage (\"15%\") is dropped, since the value is already rendered.","type":"string"},"amount":{"description":"Fixed tip amount in cents (mutually exclusive with percentage)","type":"number"},"percentage":{"description":"Tip percentage applied to the tip base — the full `amount` on `tipBase: \"gross\"`, `amount` − `options.taxAmount` on `\"net\"` (mutually exclusive with amount)","type":"number"},"accent":{"pattern":"^#(?:[0-9a-fA-F]{3}|[0-9a-fA-F]{6})$","description":"Optional accent color (hex, e.g. #EA580C) for THIS option's tile — colors its border, underline, unit symbol and label. Omitted = the terminal's default accent. The amount itself stays white so every option reads with equal weight.","type":"string"}}}}}},"invoice":{"pattern":"^[0-9]{1,12}$","examples":["9001"],"description":"Your invoice number, 1 to 12 digits. Handed to the card terminal with the charge, so it is printed on the terminal's receipt and kept in its batch and reports, and it comes back as `processor.invoiceNumber` on the result. The terminal never asks the customer or the cashier for it on this path. Omit when you have none.","type":"string"}}},"screen":{"additionalProperties":false,"description":"Customer-screen behavior for this command's result — each key is a screen, each screen has a `duration` (ms)","type":"object","properties":{"approved":{"additionalProperties":false,"description":"APPROVED result screen","type":"object","properties":{"duration":{"minimum":0,"maximum":60000,"description":"ms to show the APPROVED result screen (default 5000; 0 = don't linger)","type":"integer"}}},"declined":{"additionalProperties":false,"description":"DECLINED/error result screen","type":"object","properties":{"duration":{"minimum":0,"maximum":60000,"description":"ms to show the DECLINED/error result screen (default 8000; 0 = don't linger)","type":"integer"}}},"completion":{"additionalProperties":false,"description":"Post-payment completion message (optional)","type":"object","properties":{"message":{"maxLength":120,"description":"Message shown to the customer after an approved payment (e.g. 'Thank you!'). Must be non-empty AND duration > 0 to appear.","type":"string"},"duration":{"minimum":0,"maximum":60000,"description":"ms to show the completion message. Must be > 0 AND message non-empty to appear; default 0 = no completion screen.","type":"integer"}}}}},"receipt":{"enum":["skip","customer","merchant","both"],"description":"**Optional.** What the terminal prints for this call. **Left out**, the terminal follows its own RECEIPTS settings (Customer copy, Merchant copy, Manual print, on the terminal's page in the portal): the copies switched on print right after approval, customer first; with Manual print on, nothing prints, because nobody is at the terminal to choose. **Sent**, it overrides those settings for this call only: `customer`, `merchant` or `both` print those copies whatever the settings say, and `skip` prints nothing — for a POS that prints its own receipt. Nothing prints unless the payment is approved. The receipt template can tell the copies apart with `{if receipt.customer}` and `{if receipt.merchant}`.","type":"string"}}},"examples":{"sale":{"summary":"Sale, tax inside the total","value":{"amount":1130,"externalId":"pos-inv-9001","options":{"taxAmount":130}},"description":"$10.00 of goods plus $1.30 tax: the card is charged $11.30."},"invoice":{"summary":"Sale with an invoice number","value":{"amount":1130,"options":{"taxAmount":130,"invoice":"9001"}},"description":"The invoice number goes to the card terminal with the charge: on its receipt, in its batch, and back as `processor.invoiceNumber`."},"tip":{"summary":"Sale with a tip prompt","value":{"amount":1130,"options":{"taxAmount":130,"tipBase":"net","tipSuggestions":{"title":"Add a tip?","options":[{"percentage":15},{"percentage":18,"label":"Great"},{"percentage":20,"label":"Amazing","accent":"#EA580C"}]}}},"description":"Percentages apply to the pre-tax $10.00; the tip the customer picks is added on top."},"attributed":{"summary":"Attributed, with a longer wait and custom screens","value":{"amount":2500,"employee":"6ab6fa29a31a6c313c223b63","timeout":120000,"screen":{"approved":{"duration":3000},"completion":{"message":"Thank you!","duration":4000}}}}}}}},"parameters":[{"schema":{"type":"string"},"in":"query","name":"serial","required":false,"description":"Address the terminal by device serial — exact, globally unique. Use this OR `tag`, not both."},{"schema":{"type":"string"},"in":"query","name":"tag","required":false,"description":"Address the terminal by its tag — a handle written like \"#tag\" without the \"#\". Normalized to lowercase ASCII [a-z0-9_] before matching (spaces/case/punctuation dropped), so \"Front Counter\" and \"frontcounter\" resolve the same terminal. Set per terminal in the portal. Use this OR `serial`, not both."},{"schema":{"type":"string"},"in":"header","name":"x-idempotency-key","required":true,"description":"Idempotency key — UUID (RFC-4122 v4/v7). REQUIRED on money-movement. Value is normalized to lowercase. Generate a FRESH UUID per new operation — the key is consumed by its first attempt whatever the outcome (approved, declined, failed, cancelled). Resend the SAME key only to safely retry that one operation (the stored outcome is replayed — no double charge, no new attempt); a new charge needs a new key."}],"security":[{"ApiKeyAuth":["transaction:write","ecr:payment:sale"]}],"x-badges":[{"name":"transaction:write","position":"after"},{"name":"ecr:payment:sale","position":"after"}],"responses":{"200":{"description":"The terminal answered — approved OR declined: a decline is still `200`, so read `result.status`. `amount.approved` is what moved, tip included; `id` is the request id for `GET /api/v2/ecr/requests/{id}`. A resend with a used key answers `200` too, with `idempotent: true` and the first attempt's stored outcome — whatever that was.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/RequestRecord"},"examples":{"approved":{"summary":"Approved, with a tip","value":{"idempotent":false,"id":"6ab6fa2aa31a6c313c223b90","type":"semi_sale","status":"completed","externalId":"pos-inv-9001","result":{"status":"approved","code":"000000","hostCode":"00","message":"APPROVAL"},"amount":{"partial":false,"requested":1280,"approved":1280,"tax":130,"tip":150},"card":{"brand":"VISA","last4":"0119","entryMode":"contactless","holder":"JANE DOE"},"processor":{"authorizationCode":"OK4821","referenceNumber":"000118","hostReferenceNumber":"530612345678","batchNumber":"42","invoiceNumber":"8842","traceNumber":"118"},"time":{"sent":1790376490899,"completed":1790376490904}}},"declined":{"summary":"Declined — still HTTP 200","value":{"idempotent":false,"id":"6ab6fa2aa31a6c313c223b91","type":"semi_sale","status":"completed","result":{"status":"declined","code":"100003","hostCode":"05","message":"DO NOT HONOR"},"amount":{"partial":true,"requested":1300,"approved":0},"card":{"brand":"VISA","last4":"0119","entryMode":"chip","holder":"JANE DOE"},"processor":{"referenceNumber":"000119"},"time":{"sent":1790376490931,"completed":1790376490936}},"description":"`status` is `completed`: the terminal answered. `result.status` says no."}}}}},"400":{"description":"Bad request — a field broke the schema (`invalid_request`, with `detail.field`), the idempotency key is not a UUID (`invalid_idempotency_key`), or no terminal was addressed (`terminal_required`).","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"401":{"description":"Unauthorized — the request carried no `x-api-key` header.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"403":{"description":"Forbidden — the key lacks a scope this command needs (`insufficient_scope`, listing them in `detail.missing`), or the key, its user or the account is no longer active.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"},"examples":{"scope":{"summary":"Missing scope","value":{"error":"insufficient_scope","detail":{"missing":["ecr:payment:sale"]}}}}}}},"404":{"description":"Not found — no terminal with that serial or tag (`terminal_not_found`), no payment with that id (`payment_not_found`), or no such employee (`employee_not_found`).","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"409":{"description":"Conflict — the terminal is inactive or not in SEMI mode, another command holds it (`terminal_busy`), the same key is still in flight (`request_in_progress`), the payment cannot be reversed this way (voided already, batch closed, another terminal, a cash sale to void), or the terminal cancelled the command before it completed (`cancelled` — no money moved; retry with a NEW key).","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"},"examples":{"busy":{"summary":"Terminal busy","value":{"error":"terminal_busy","detail":{"message":"Another command is already in progress on this terminal."}}},"inFlight":{"summary":"Same key in flight","value":{"error":"request_in_progress","detail":{"requestId":"6ab6fa2aa31a6c313c223b90","status":"in_progress"}}},"cancelled":{"summary":"Cancelled on the terminal","value":{"error":"cancelled","detail":{"requestId":"6ab6fa2aa31a6c313c223b90","message":"The command was cancelled on the terminal before completing — no money moved. Safe to retry with a new Idempotency-Key."}}},"notSemi":{"summary":"Not in SEMI mode","value":{"error":"terminal_not_in_semi_mode","detail":{"message":"Terminal 'Back Office' is not in SEMI mode.","terminal":"Back Office","current":"pos","required":"semi"}}}}}}},"422":{"description":"Unprocessable — the refund amount exceeds the payment's remaining refundable balance (`refund_exceeds_refundable`), the original has no terminal reference to reverse, or a consumed x-idempotency-key was replayed with a DIFFERENT body (`idempotency_key_reused`).","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"},"examples":{"reused":{"summary":"Key reused","value":{"error":"idempotency_key_reused","detail":{"requestId":"6ab6fa2aa31a6c313c223b90","message":"This Idempotency-Key was already used with a different request."}}}}}}},"502":{"description":"Device error — the terminal received the command and failed it, including when it was busy with a cashier's own operation. Usually no money moved: if `detail.message` begins `PARTIAL APPROVAL`, the card may have been charged — read the request record and the transaction before retrying.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"503":{"description":"Terminal offline or unreachable — the command never reached the device; safe to retry with the same key.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"},"examples":{"offline":{"summary":"Terminal offline","value":{"error":"device_unreachable","detail":{"message":"Terminal is offline or unreachable."}},"description":"The command never reached the terminal: retry with the same key."}}}}},"504":{"description":"No answer within the wait window (`outcome_unknown`) — the payment MAY have gone through. Fetch `GET /api/v2/ecr/requests/{id}` with `detail.requestId` before doing anything else.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"},"examples":{"unknown":{"summary":"No answer in time","value":{"error":"outcome_unknown","detail":{"requestId":"6ab6fa2aa31a6c313c223b90","message":"No result within the timeout — the operation may have completed. Reconcile via the requests endpoint."}},"description":"Fetch the request record before anything else — never charge again blind."}}}}}}}},"/api/v2/ecr/terminal/payment/{paymentId}/receipt":{"post":{"operationId":"printPaymentReceipt","summary":"Print payment receipt","tags":["ECR Commands"],"description":"Print the receipt of a prior payment on the terminal's printer, from the account's own receipt template. `copy` picks the copy: `customer` (default), `merchant`, or `both` (customer first, then merchant). A template can print a block for one copy only with `{if receipt.merchant}` or `{if receipt.customer}`. **Every call prints**: two calls are two receipts. To get the receipt as text instead of paper, use `GET /api/v2/merchants/{merchant}/transactions/{id}/receipt`.","parameters":[{"schema":{"type":"string"},"in":"query","name":"serial","required":false,"description":"Address the terminal by device serial — exact, globally unique. Use this OR `tag`, not both."},{"schema":{"type":"string"},"in":"query","name":"tag","required":false,"description":"Address the terminal by its tag — a handle written like \"#tag\" without the \"#\". Normalized to lowercase ASCII [a-z0-9_] before matching (spaces/case/punctuation dropped), so \"Front Counter\" and \"frontcounter\" resolve the same terminal. Set per terminal in the portal. Use this OR `serial`, not both."},{"schema":{"enum":["customer","merchant","both"],"default":"customer","type":"string"},"in":"query","name":"copy","required":false,"description":"Which copy to print: `customer` (default), `merchant`, or `both` — customer first, then merchant."},{"schema":{"type":"string"},"in":"path","name":"paymentId","required":true,"description":"Payment / transaction identifier returned by a prior PAYMENT REQUEST"}],"security":[{"ApiKeyAuth":["transaction:write"]}],"x-badges":[{"name":"transaction:write","position":"after"}],"responses":{"200":{"description":"The terminal printed the receipt. The body is the print's request record (`type: semi_reprint`).","content":{"application/json":{"schema":{"$ref":"#/components/schemas/RequestRecord"}}}},"400":{"description":"Bad request — invalid or missing parameters.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"401":{"description":"Unauthorized — the request carried no `x-api-key` header.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"403":{"description":"Forbidden — the API key's user is missing a required scope.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"404":{"description":"Not found — no terminal with that serial or tag, or no payment with that id (`payment_not_found`).","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"409":{"description":"Conflict — the terminal is inactive or not in SEMI mode, it has no receipt to print for this payment (`payment_not_printable`: not approved, or settled outside the terminal's rail), or it has nowhere to print (`terminal_has_no_printer`: no built-in printer and no network printer — answered at once, nothing is printed).","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"502":{"description":"Device error — the terminal could not print: out of paper, printer error, or busy with a cashier's own operation.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"503":{"description":"Terminal offline or unreachable — the command never reached it (`device_unreachable`).","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"504":{"description":"Timeout — no result within the window.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}}}},"/api/v2/ecr/terminal/payment/{paymentId}/refund":{"put":{"operationId":"refundPayment","summary":"Refund payment","tags":["ECR Commands"],"description":"Refund a prior payment (linked by paymentId). The TENDER follows the original — you don't declare it: a card sale credits the card, a CASH sale refunds out of the drawer. Either way the refund is linked: it decrements the original's refundable balance, so the same payment can never be over-refunded (unlike the blind POST /cash/refund, which records a standalone drawer payout).","requestBody":{"required":true,"content":{"application/json":{"schema":{"additionalProperties":false,"type":"object","required":["amount"],"properties":{"amount":{"minimum":1,"description":"TOTAL amount to refund, in cents — tax included, exactly as it was charged. Must not exceed the original payment's remaining refundable balance (`422` otherwise); partial refunds are allowed and can be repeated until that balance is used up. Goes back on the card, or out of the cash drawer when the original was a cash sale. Put the tax share of this number in `options.taxAmount`.","type":"integer"},"options":{"additionalProperties":false,"description":"Refund options","type":"object","properties":{"taxAmount":{"minimum":0,"description":"How much tax is ALREADY INCLUDED in the refunded `amount`, in cents — a breakdown, never an addition. Mirrors `options.taxAmount` on a sale, so a partial refund can carry back its own share of the tax (refunding $5.65 of an $11.30 sale → `amount: 565`, `taxAmount: 65`). Receipt / reporting only.","type":"integer"}}},"reason":{"maxLength":256,"description":"Optional free-text reason for the void/refund — recorded on the transaction as `reversalReason`, for your audit trail. Always optional: a reversal is never refused over a missing or unrecognised label.","type":"string"},"reasonCode":{"maxLength":64,"examples":["customer_request","wrong_amount","duplicate","item_returned","operator_error","other"],"description":"Optional machine-readable reason, recorded as `reversalReasonCode` for reporting. One of: `customer_request`, `wrong_amount`, `duplicate`, `item_returned`, `operator_error`, `other` — or the 24-character id of a row in the account's own reason list. Anything else, including a 24-character id that is not in that list, is ignored rather than rejected — the reversal still goes through. Independent of `reason`: send either, both, or neither.","type":"string"},"externalId":{"maxLength":128,"description":"Optional client business id — stored verbatim and indexed for YOUR reconciliation lookups; the server takes no action on it.","type":"string"},"employee":{"pattern":"^[0-9a-fA-F]{24}$","description":"Optional employee id (employee.id) to attribute this transaction to. Must be an employee of the account (`404 employee_not_found`) and, if bound to a site, of the terminal's site (`409 employee_wrong_site`); it need not be on the terminal's roster.","type":"string"},"timeout":{"minimum":5000,"maximum":300000,"description":"ms the terminal waits for this operation to complete (e.g. customer card tap) before aborting with a timeout. Omitted = terminal default. The server's own wait window stretches to cover it.","type":"integer"},"screen":{"additionalProperties":false,"description":"Customer-screen behavior for this command's result — each key is a screen, each screen has a `duration` (ms)","type":"object","properties":{"approved":{"additionalProperties":false,"description":"APPROVED result screen","type":"object","properties":{"duration":{"minimum":0,"maximum":60000,"description":"ms to show the APPROVED result screen (default 5000; 0 = don't linger)","type":"integer"}}},"declined":{"additionalProperties":false,"description":"DECLINED/error result screen","type":"object","properties":{"duration":{"minimum":0,"maximum":60000,"description":"ms to show the DECLINED/error result screen (default 8000; 0 = don't linger)","type":"integer"}}},"completion":{"additionalProperties":false,"description":"Post-payment completion message (optional)","type":"object","properties":{"message":{"maxLength":120,"description":"Message shown to the customer after an approved payment (e.g. 'Thank you!'). Must be non-empty AND duration > 0 to appear.","type":"string"},"duration":{"minimum":0,"maximum":60000,"description":"ms to show the completion message. Must be > 0 AND message non-empty to appear; default 0 = no completion screen.","type":"integer"}}}}},"receipt":{"enum":["skip","customer","merchant","both"],"description":"**Optional.** What the terminal prints for this call. **Left out**, the terminal follows its own RECEIPTS settings (Customer copy, Merchant copy, Manual print, on the terminal's page in the portal): the copies switched on print right after approval, customer first; with Manual print on, nothing prints, because nobody is at the terminal to choose. **Sent**, it overrides those settings for this call only: `customer`, `merchant` or `both` print those copies whatever the settings say, and `skip` prints nothing — for a POS that prints its own receipt. Nothing prints unless the payment is approved. The receipt template can tell the copies apart with `{if receipt.customer}` and `{if receipt.merchant}`.","type":"string"}}},"examples":{"partial":{"summary":"Partial refund with its tax share","value":{"amount":565,"options":{"taxAmount":65},"reason":"Item returned","reasonCode":"item_returned"},"description":"Half of an $11.30 sale, carrying back $0.65 of its tax."}}}}},"parameters":[{"schema":{"type":"string"},"in":"query","name":"serial","required":false,"description":"Address the terminal by device serial — exact, globally unique. Use this OR `tag`, not both."},{"schema":{"type":"string"},"in":"query","name":"tag","required":false,"description":"Address the terminal by its tag — a handle written like \"#tag\" without the \"#\". Normalized to lowercase ASCII [a-z0-9_] before matching (spaces/case/punctuation dropped), so \"Front Counter\" and \"frontcounter\" resolve the same terminal. Set per terminal in the portal. Use this OR `serial`, not both."},{"schema":{"type":"string"},"in":"path","name":"paymentId","required":true,"description":"Payment / transaction identifier returned by a prior PAYMENT REQUEST"},{"schema":{"type":"string"},"in":"header","name":"x-idempotency-key","required":true,"description":"Idempotency key — UUID (RFC-4122 v4/v7). REQUIRED on money-movement. Value is normalized to lowercase. Generate a FRESH UUID per new operation — the key is consumed by its first attempt whatever the outcome (approved, declined, failed, cancelled). Resend the SAME key only to safely retry that one operation (the stored outcome is replayed — no double charge, no new attempt); a new charge needs a new key."}],"security":[{"ApiKeyAuth":["transaction:write","ecr:payment:refund"]}],"x-badges":[{"name":"transaction:write","position":"after"},{"name":"ecr:payment:refund","position":"after"}],"responses":{"200":{"description":"The terminal answered — approved OR declined: a decline is still `200`, so read `result.status`. `amount.approved` is what moved, tip included; `id` is the request id for `GET /api/v2/ecr/requests/{id}`. A resend with a used key answers `200` too, with `idempotent: true` and the first attempt's stored outcome — whatever that was.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/RequestRecord"},"examples":{"approved":{"summary":"Approved","value":{"idempotent":false,"id":"6ab6fa2ba31a6c313c223b9b","type":"semi_refund","status":"completed","result":{"status":"approved","code":"000000","hostCode":"00","message":"APPROVAL"},"amount":{"partial":false,"requested":565,"approved":565,"tax":65},"card":{"brand":"VISA","last4":"0119","entryMode":"contactless","holder":"JANE DOE"},"processor":{"authorizationCode":"OK4821","referenceNumber":"000118","hostReferenceNumber":"530612345678","batchNumber":"42","invoiceNumber":"8842","traceNumber":"118"},"time":{"sent":1790376491144,"completed":1790376491153}}}}}}},"400":{"description":"Bad request — a field broke the schema (`invalid_request`, with `detail.field`), the idempotency key is not a UUID (`invalid_idempotency_key`), or no terminal was addressed (`terminal_required`).","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"401":{"description":"Unauthorized — the request carried no `x-api-key` header.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"403":{"description":"Forbidden — the key lacks a scope this command needs (`insufficient_scope`, listing them in `detail.missing`), or the key, its user or the account is no longer active.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"404":{"description":"Not found — no terminal with that serial or tag (`terminal_not_found`), no payment with that id (`payment_not_found`), or no such employee (`employee_not_found`).","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"409":{"description":"Conflict — the terminal is inactive or not in SEMI mode, another command holds it (`terminal_busy`), the same key is still in flight (`request_in_progress`), the payment cannot be reversed this way (voided already, batch closed, another terminal, a cash sale to void), or the terminal cancelled the command before it completed (`cancelled` — no money moved; retry with a NEW key).","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"422":{"description":"Unprocessable — the refund amount exceeds the payment's remaining refundable balance (`refund_exceeds_refundable`), the original has no terminal reference to reverse, or a consumed x-idempotency-key was replayed with a DIFFERENT body (`idempotency_key_reused`).","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"},"examples":{"exceeds":{"summary":"More than is left","value":{"error":"refund_exceeds_refundable","detail":{"message":"Refund amount (999999) exceeds the refundable balance (1280 cents)."}}}}}}},"502":{"description":"Device error — the terminal received the command and failed it, including when it was busy with a cashier's own operation. Usually no money moved: if `detail.message` begins `PARTIAL APPROVAL`, the card may have been charged — read the request record and the transaction before retrying.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"503":{"description":"Terminal offline or unreachable — the command never reached the device; safe to retry with the same key.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"504":{"description":"No answer within the wait window (`outcome_unknown`) — the payment MAY have gone through. Fetch `GET /api/v2/ecr/requests/{id}` with `detail.requestId` before doing anything else.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}}}},"/api/v2/ecr/terminal/payment/{paymentId}/void":{"put":{"operationId":"voidPayment","summary":"Void transaction","tags":["ECR Commands"],"description":"Void a prior approved payment (linked by paymentId). Card only — a cash sale has no host authorization to void and returns `409`; refund it instead. Unlike a payment or a refund, a void the terminal declines answers `502 device_error` (the terminal's text is in `detail.message`): nothing is reversed and the payment stays voidable.","requestBody":{"required":true,"content":{"application/json":{"schema":{"additionalProperties":false,"type":"object","properties":{"reason":{"maxLength":256,"description":"Optional free-text reason for the void/refund — recorded on the transaction as `reversalReason`, for your audit trail. Always optional: a reversal is never refused over a missing or unrecognised label.","type":"string"},"reasonCode":{"maxLength":64,"examples":["customer_request","wrong_amount","duplicate","item_returned","operator_error","other"],"description":"Optional machine-readable reason, recorded as `reversalReasonCode` for reporting. One of: `customer_request`, `wrong_amount`, `duplicate`, `item_returned`, `operator_error`, `other` — or the 24-character id of a row in the account's own reason list. Anything else, including a 24-character id that is not in that list, is ignored rather than rejected — the reversal still goes through. Independent of `reason`: send either, both, or neither.","type":"string"},"externalId":{"maxLength":128,"description":"Optional client business id — stored verbatim and indexed for YOUR reconciliation lookups; the server takes no action on it.","type":"string"},"employee":{"pattern":"^[0-9a-fA-F]{24}$","description":"Optional employee id (employee.id) to attribute this transaction to. Must be an employee of the account (`404 employee_not_found`) and, if bound to a site, of the terminal's site (`409 employee_wrong_site`); it need not be on the terminal's roster.","type":"string"},"screen":{"additionalProperties":false,"description":"Customer-screen behavior for this command's result — each key is a screen, each screen has a `duration` (ms)","type":"object","properties":{"approved":{"additionalProperties":false,"description":"APPROVED result screen","type":"object","properties":{"duration":{"minimum":0,"maximum":60000,"description":"ms to show the APPROVED result screen (default 5000; 0 = don't linger)","type":"integer"}}},"declined":{"additionalProperties":false,"description":"DECLINED/error result screen","type":"object","properties":{"duration":{"minimum":0,"maximum":60000,"description":"ms to show the DECLINED/error result screen (default 8000; 0 = don't linger)","type":"integer"}}},"completion":{"additionalProperties":false,"description":"Post-payment completion message (optional)","type":"object","properties":{"message":{"maxLength":120,"description":"Message shown to the customer after an approved payment (e.g. 'Thank you!'). Must be non-empty AND duration > 0 to appear.","type":"string"},"duration":{"minimum":0,"maximum":60000,"description":"ms to show the completion message. Must be > 0 AND message non-empty to appear; default 0 = no completion screen.","type":"integer"}}}}},"receipt":{"enum":["skip","customer","merchant","both"],"description":"**Optional.** What the terminal prints for this call. **Left out**, the terminal follows its own RECEIPTS settings (Customer copy, Merchant copy, Manual print, on the terminal's page in the portal): the copies switched on print right after approval, customer first; with Manual print on, nothing prints, because nobody is at the terminal to choose. **Sent**, it overrides those settings for this call only: `customer`, `merchant` or `both` print those copies whatever the settings say, and `skip` prints nothing — for a POS that prints its own receipt. Nothing prints unless the payment is approved. The receipt template can tell the copies apart with `{if receipt.customer}` and `{if receipt.merchant}`.","type":"string"}}},"examples":{"duplicate":{"summary":"Rung up twice","value":{"reason":"Rang up twice","reasonCode":"duplicate"}}}}}},"parameters":[{"schema":{"type":"string"},"in":"query","name":"serial","required":false,"description":"Address the terminal by device serial — exact, globally unique. Use this OR `tag`, not both."},{"schema":{"type":"string"},"in":"query","name":"tag","required":false,"description":"Address the terminal by its tag — a handle written like \"#tag\" without the \"#\". Normalized to lowercase ASCII [a-z0-9_] before matching (spaces/case/punctuation dropped), so \"Front Counter\" and \"frontcounter\" resolve the same terminal. Set per terminal in the portal. Use this OR `serial`, not both."},{"schema":{"type":"string"},"in":"path","name":"paymentId","required":true,"description":"Payment / transaction identifier returned by a prior PAYMENT REQUEST"},{"schema":{"type":"string"},"in":"header","name":"x-idempotency-key","required":true,"description":"Idempotency key — UUID (RFC-4122 v4/v7). REQUIRED on money-movement. Value is normalized to lowercase. Generate a FRESH UUID per new operation — the key is consumed by its first attempt whatever the outcome (approved, declined, failed, cancelled). Resend the SAME key only to safely retry that one operation (the stored outcome is replayed — no double charge, no new attempt); a new charge needs a new key."}],"security":[{"ApiKeyAuth":["transaction:write","ecr:payment:void"]}],"x-badges":[{"name":"transaction:write","position":"after"},{"name":"ecr:payment:void","position":"after"}],"responses":{"200":{"description":"The terminal answered — approved OR declined: a decline is still `200`, so read `result.status`. `amount.approved` is what moved, tip included; `id` is the request id for `GET /api/v2/ecr/requests/{id}`. A resend with a used key answers `200` too, with `idempotent: true` and the first attempt's stored outcome — whatever that was.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/RequestRecord"},"examples":{"approved":{"summary":"Voided","value":{"idempotent":false,"id":"6ab6fa2ba31a6c313c223b99","type":"semi_void","status":"completed","result":{"status":"approved","code":"000000","hostCode":"00","message":"APPROVAL"},"amount":{"partial":false,"requested":1280,"approved":1280,"tax":130,"tip":150},"card":{"brand":"VISA","last4":"0119","entryMode":"contactless","holder":"JANE DOE"},"processor":{"authorizationCode":"OK4821","referenceNumber":"000118","hostReferenceNumber":"530612345678","batchNumber":"42","invoiceNumber":"8842","traceNumber":"118"},"time":{"sent":1790376491091,"completed":1790376491095}}}}}}},"400":{"description":"Bad request — a field broke the schema (`invalid_request`, with `detail.field`), the idempotency key is not a UUID (`invalid_idempotency_key`), or no terminal was addressed (`terminal_required`).","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"401":{"description":"Unauthorized — the request carried no `x-api-key` header.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"403":{"description":"Forbidden — the key lacks a scope this command needs (`insufficient_scope`, listing them in `detail.missing`), or the key, its user or the account is no longer active.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"404":{"description":"Not found — no terminal with that serial or tag (`terminal_not_found`), no payment with that id (`payment_not_found`), or no such employee (`employee_not_found`).","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"409":{"description":"Conflict — the terminal is inactive or not in SEMI mode, another command holds it (`terminal_busy`), the same key is still in flight (`request_in_progress`), the payment cannot be reversed this way (voided already, batch closed, another terminal, a cash sale to void), or the terminal cancelled the command before it completed (`cancelled` — no money moved; retry with a NEW key).","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"},"examples":{"cash":{"summary":"A cash sale","value":{"error":"payment_not_reversible","detail":{"message":"This is a cash payment — it has no host authorization to void. Refund it instead."}}},"closed":{"summary":"Batch closed","value":{"error":"payment_batch_closed","detail":{"message":"The payment's batch is closed — refund it instead."}}}}}}},"422":{"description":"Unprocessable — the refund amount exceeds the payment's remaining refundable balance (`refund_exceeds_refundable`), the original has no terminal reference to reverse, or a consumed x-idempotency-key was replayed with a DIFFERENT body (`idempotency_key_reused`).","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"502":{"description":"Device error — the terminal received the command and failed it, including when it was busy with a cashier's own operation. Usually no money moved: if `detail.message` begins `PARTIAL APPROVAL`, the card may have been charged — read the request record and the transaction before retrying.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"503":{"description":"Terminal offline or unreachable — the command never reached the device; safe to retry with the same key.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"504":{"description":"No answer within the wait window (`outcome_unknown`) — the payment MAY have gone through. Fetch `GET /api/v2/ecr/requests/{id}` with `detail.requestId` before doing anything else.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}}}},"/api/v2/ecr/terminal/credit":{"post":{"operationId":"createBlindRefund","summary":"Blind refund","tags":["ECR Commands"],"description":"Unlinked (blind) refund to a card — money back with no original payment to point at, so nothing caps the amount.","requestBody":{"required":true,"content":{"application/json":{"schema":{"additionalProperties":false,"type":"object","required":["amount"],"properties":{"amount":{"minimum":1,"description":"TOTAL amount to credit back, in cents — tax included. Blind: no original payment is referenced, so nothing caps this number.","type":"integer"},"cardNotPresent":{"description":"Use manual entry (card-not-present)","type":"boolean"},"reason":{"maxLength":256,"description":"Optional free-text reason for the void/refund — recorded on the transaction as `reversalReason`, for your audit trail. Always optional: a reversal is never refused over a missing or unrecognised label.","type":"string"},"reasonCode":{"maxLength":64,"examples":["customer_request","wrong_amount","duplicate","item_returned","operator_error","other"],"description":"Optional machine-readable reason, recorded as `reversalReasonCode` for reporting. One of: `customer_request`, `wrong_amount`, `duplicate`, `item_returned`, `operator_error`, `other` — or the 24-character id of a row in the account's own reason list. Anything else, including a 24-character id that is not in that list, is ignored rather than rejected — the reversal still goes through. Independent of `reason`: send either, both, or neither.","type":"string"},"externalId":{"maxLength":128,"description":"Optional client business id — stored verbatim and indexed for YOUR reconciliation lookups; the server takes no action on it.","type":"string"},"employee":{"pattern":"^[0-9a-fA-F]{24}$","description":"Optional employee id (employee.id) to attribute this transaction to. Must be an employee of the account (`404 employee_not_found`) and, if bound to a site, of the terminal's site (`409 employee_wrong_site`); it need not be on the terminal's roster.","type":"string"},"timeout":{"minimum":5000,"maximum":300000,"description":"ms the terminal waits for this operation to complete (e.g. customer card tap) before aborting with a timeout. Omitted = terminal default. The server's own wait window stretches to cover it.","type":"integer"},"screen":{"additionalProperties":false,"description":"Customer-screen behavior for this command's result — each key is a screen, each screen has a `duration` (ms)","type":"object","properties":{"approved":{"additionalProperties":false,"description":"APPROVED result screen","type":"object","properties":{"duration":{"minimum":0,"maximum":60000,"description":"ms to show the APPROVED result screen (default 5000; 0 = don't linger)","type":"integer"}}},"declined":{"additionalProperties":false,"description":"DECLINED/error result screen","type":"object","properties":{"duration":{"minimum":0,"maximum":60000,"description":"ms to show the DECLINED/error result screen (default 8000; 0 = don't linger)","type":"integer"}}},"completion":{"additionalProperties":false,"description":"Post-payment completion message (optional)","type":"object","properties":{"message":{"maxLength":120,"description":"Message shown to the customer after an approved payment (e.g. 'Thank you!'). Must be non-empty AND duration > 0 to appear.","type":"string"},"duration":{"minimum":0,"maximum":60000,"description":"ms to show the completion message. Must be > 0 AND message non-empty to appear; default 0 = no completion screen.","type":"integer"}}}}}}}}}},"parameters":[{"schema":{"type":"string"},"in":"query","name":"serial","required":false,"description":"Address the terminal by device serial — exact, globally unique. Use this OR `tag`, not both."},{"schema":{"type":"string"},"in":"query","name":"tag","required":false,"description":"Address the terminal by its tag — a handle written like \"#tag\" without the \"#\". Normalized to lowercase ASCII [a-z0-9_] before matching (spaces/case/punctuation dropped), so \"Front Counter\" and \"frontcounter\" resolve the same terminal. Set per terminal in the portal. Use this OR `serial`, not both."},{"schema":{"type":"string"},"in":"header","name":"x-idempotency-key","required":true,"description":"Idempotency key — UUID (RFC-4122 v4/v7). REQUIRED on money-movement. Value is normalized to lowercase. Generate a FRESH UUID per new operation — the key is consumed by its first attempt whatever the outcome (approved, declined, failed, cancelled). Resend the SAME key only to safely retry that one operation (the stored outcome is replayed — no double charge, no new attempt); a new charge needs a new key."}],"security":[{"ApiKeyAuth":["transaction:write","ecr:payment:refund"]}],"x-badges":[{"name":"Coming soon","position":"before"},{"name":"transaction:write","position":"after"},{"name":"ecr:payment:refund","position":"after"}],"responses":{"400":{"description":"Bad request — a field broke the schema (`invalid_request`, with `detail.field`), the idempotency key is not a UUID (`invalid_idempotency_key`), or no terminal was addressed (`terminal_required`).","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"401":{"description":"Unauthorized — the request carried no `x-api-key` header.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"403":{"description":"Forbidden — the key lacks a scope this command needs (`insufficient_scope`, listing them in `detail.missing`), or the key, its user or the account is no longer active.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"404":{"description":"Not found — no terminal with that serial or tag (`terminal_not_found`), no payment with that id (`payment_not_found`), or no such employee (`employee_not_found`).","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"409":{"description":"Conflict — the terminal is inactive or not in SEMI mode, another command holds it (`terminal_busy`), the same key is still in flight (`request_in_progress`), the payment cannot be reversed this way (voided already, batch closed, another terminal, a cash sale to void), or the terminal cancelled the command before it completed (`cancelled` — no money moved; retry with a NEW key).","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"422":{"description":"Unprocessable — the refund amount exceeds the payment's remaining refundable balance (`refund_exceeds_refundable`), the original has no terminal reference to reverse, or a consumed x-idempotency-key was replayed with a DIFFERENT body (`idempotency_key_reused`).","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"501":{"description":"Not implemented yet — the route answers 501 until the command ships.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"502":{"description":"Device error — the terminal received the command and failed it, including when it was busy with a cashier's own operation. Usually no money moved: if `detail.message` begins `PARTIAL APPROVAL`, the card may have been charged — read the request record and the transaction before retrying.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"503":{"description":"Terminal offline or unreachable — the command never reached the device; safe to retry with the same key.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"504":{"description":"No answer within the wait window (`outcome_unknown`) — the payment MAY have gone through. Fetch `GET /api/v2/ecr/requests/{id}` with `detail.requestId` before doing anything else.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}}}},"/api/v2/ecr/terminal/cash":{"post":{"operationId":"createCashPayment","summary":"Cash payment","tags":["ECR Commands"],"description":"Record a cash payment on the terminal; its receipt prints as the `receipt` field (else the terminal's RECEIPTS settings) says. Send `tendered` to have the terminal show the customer their change.","requestBody":{"required":true,"content":{"application/json":{"schema":{"additionalProperties":false,"type":"object","required":["amount"],"properties":{"amount":{"minimum":1,"description":"TOTAL cash owed, in cents — tax included, exactly what the customer pays. Nothing is added to it (no tip, no tax) — it is recorded as collected.","type":"integer"},"tendered":{"minimum":1,"description":"Cash given by the customer in cents (optional; should be ≥ amount). When provided, the terminal shows the customer the tendered amount and the change due (tendered − amount).","type":"integer"},"reason":{"maxLength":256,"description":"Optional free-text reason for the void/refund — recorded on the transaction as `reversalReason`, for your audit trail. Always optional: a reversal is never refused over a missing or unrecognised label.","type":"string"},"externalId":{"maxLength":128,"description":"Optional client business id — stored verbatim and indexed for YOUR reconciliation lookups; the server takes no action on it.","type":"string"},"employee":{"pattern":"^[0-9a-fA-F]{24}$","description":"Optional employee id (employee.id) to attribute this transaction to. Must be an employee of the account (`404 employee_not_found`) and, if bound to a site, of the terminal's site (`409 employee_wrong_site`); it need not be on the terminal's roster.","type":"string"},"screen":{"additionalProperties":false,"description":"Customer-screen behavior for this command's result — each key is a screen, each screen has a `duration` (ms)","type":"object","properties":{"approved":{"additionalProperties":false,"description":"APPROVED result screen","type":"object","properties":{"duration":{"minimum":0,"maximum":60000,"description":"ms to show the APPROVED result screen (default 5000; 0 = don't linger)","type":"integer"}}},"declined":{"additionalProperties":false,"description":"DECLINED/error result screen","type":"object","properties":{"duration":{"minimum":0,"maximum":60000,"description":"ms to show the DECLINED/error result screen (default 8000; 0 = don't linger)","type":"integer"}}},"completion":{"additionalProperties":false,"description":"Post-payment completion message (optional)","type":"object","properties":{"message":{"maxLength":120,"description":"Message shown to the customer after an approved payment (e.g. 'Thank you!'). Must be non-empty AND duration > 0 to appear.","type":"string"},"duration":{"minimum":0,"maximum":60000,"description":"ms to show the completion message. Must be > 0 AND message non-empty to appear; default 0 = no completion screen.","type":"integer"}}}}},"receipt":{"enum":["skip","customer","merchant","both"],"description":"**Optional.** What the terminal prints for this call. **Left out**, the terminal follows its own RECEIPTS settings (Customer copy, Merchant copy, Manual print, on the terminal's page in the portal): the copies switched on print right after approval, customer first; with Manual print on, nothing prints, because nobody is at the terminal to choose. **Sent**, it overrides those settings for this call only: `customer`, `merchant` or `both` print those copies whatever the settings say, and `skip` prints nothing — for a POS that prints its own receipt. Nothing prints unless the payment is approved. The receipt template can tell the copies apart with `{if receipt.customer}` and `{if receipt.merchant}`.","type":"string"}}},"examples":{"change":{"summary":"With change due","value":{"amount":1130,"tendered":2000,"externalId":"pos-inv-9002"},"description":"The terminal shows the customer $8.70 change."}}}}},"parameters":[{"schema":{"type":"string"},"in":"query","name":"serial","required":false,"description":"Address the terminal by device serial — exact, globally unique. Use this OR `tag`, not both."},{"schema":{"type":"string"},"in":"query","name":"tag","required":false,"description":"Address the terminal by its tag — a handle written like \"#tag\" without the \"#\". Normalized to lowercase ASCII [a-z0-9_] before matching (spaces/case/punctuation dropped), so \"Front Counter\" and \"frontcounter\" resolve the same terminal. Set per terminal in the portal. Use this OR `serial`, not both."},{"schema":{"type":"string"},"in":"header","name":"x-idempotency-key","required":true,"description":"Idempotency key — UUID (RFC-4122 v4/v7). REQUIRED on money-movement. Value is normalized to lowercase. Generate a FRESH UUID per new operation — the key is consumed by its first attempt whatever the outcome (approved, declined, failed, cancelled). Resend the SAME key only to safely retry that one operation (the stored outcome is replayed — no double charge, no new attempt); a new charge needs a new key."}],"security":[{"ApiKeyAuth":["transaction:write","ecr:payment:sale"]}],"x-badges":[{"name":"transaction:write","position":"after"},{"name":"ecr:payment:sale","position":"after"}],"responses":{"200":{"description":"The terminal answered — approved OR declined: a decline is still `200`, so read `result.status`. `amount.approved` is what moved, tip included; `id` is the request id for `GET /api/v2/ecr/requests/{id}`. A resend with a used key answers `200` too, with `idempotent: true` and the first attempt's stored outcome — whatever that was.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/RequestRecord"},"examples":{"collected":{"summary":"Collected","value":{"idempotent":false,"id":"6ab6fa2ba31a6c313c223b9d","type":"semi_cash","status":"completed","externalId":"pos-inv-9002","result":{"status":"approved","code":"000000","message":"OK"},"amount":{"partial":false,"requested":0,"approved":1130,"tax":0,"tip":0,"surcharge":0,"cashback":0,"tendered":2000,"change":870},"time":{"sent":1790361002114,"completed":1790361014902}}}}}}},"400":{"description":"Bad request — a field broke the schema (`invalid_request`, with `detail.field`), the idempotency key is not a UUID (`invalid_idempotency_key`), or no terminal was addressed (`terminal_required`).","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"401":{"description":"Unauthorized — the request carried no `x-api-key` header.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"403":{"description":"Forbidden — the key lacks a scope this command needs (`insufficient_scope`, listing them in `detail.missing`), or the key, its user or the account is no longer active.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"404":{"description":"Not found — no terminal with that serial or tag (`terminal_not_found`), no payment with that id (`payment_not_found`), or no such employee (`employee_not_found`).","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"409":{"description":"Conflict — the terminal is inactive or not in SEMI mode, another command holds it (`terminal_busy`), the same key is still in flight (`request_in_progress`), the payment cannot be reversed this way (voided already, batch closed, another terminal, a cash sale to void), or the terminal cancelled the command before it completed (`cancelled` — no money moved; retry with a NEW key).","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"422":{"description":"Unprocessable — the refund amount exceeds the payment's remaining refundable balance (`refund_exceeds_refundable`), the original has no terminal reference to reverse, or a consumed x-idempotency-key was replayed with a DIFFERENT body (`idempotency_key_reused`).","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"502":{"description":"Device error — the terminal received the command and failed it, including when it was busy with a cashier's own operation. Usually no money moved: if `detail.message` begins `PARTIAL APPROVAL`, the card may have been charged — read the request record and the transaction before retrying.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"503":{"description":"Terminal offline or unreachable — the command never reached the device; safe to retry with the same key.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"504":{"description":"No answer within the wait window (`outcome_unknown`) — the payment MAY have gone through. Fetch `GET /api/v2/ecr/requests/{id}` with `detail.requestId` before doing anything else.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}}}},"/api/v2/ecr/terminal/cash/refund":{"post":{"operationId":"createCashRefund","summary":"Cash refund","tags":["ECR Commands"],"description":"Record a BLIND cash refund on the terminal — money out of the drawer with no original transaction, so nothing is marked refunded and nothing caps it. Use it only when there is no payment to point at (sale taken elsewhere, imported/legacy row). When you DO have the payment, use PUT /payment/:paymentId/refund — it detects the cash tender itself and keeps the refund linked.","requestBody":{"required":true,"content":{"application/json":{"schema":{"additionalProperties":false,"type":"object","required":["amount"],"properties":{"amount":{"minimum":1,"description":"TOTAL cash to hand back, in cents — tax included (blind: no original transaction is referenced).","type":"integer"},"reason":{"maxLength":256,"description":"Optional free-text reason for the void/refund — recorded on the transaction as `reversalReason`, for your audit trail. Always optional: a reversal is never refused over a missing or unrecognised label.","type":"string"},"reasonCode":{"maxLength":64,"examples":["customer_request","wrong_amount","duplicate","item_returned","operator_error","other"],"description":"Optional machine-readable reason, recorded as `reversalReasonCode` for reporting. One of: `customer_request`, `wrong_amount`, `duplicate`, `item_returned`, `operator_error`, `other` — or the 24-character id of a row in the account's own reason list. Anything else, including a 24-character id that is not in that list, is ignored rather than rejected — the reversal still goes through. Independent of `reason`: send either, both, or neither.","type":"string"},"externalId":{"maxLength":128,"description":"Optional client business id — stored verbatim and indexed for YOUR reconciliation lookups; the server takes no action on it.","type":"string"},"employee":{"pattern":"^[0-9a-fA-F]{24}$","description":"Optional employee id (employee.id) to attribute this transaction to. Must be an employee of the account (`404 employee_not_found`) and, if bound to a site, of the terminal's site (`409 employee_wrong_site`); it need not be on the terminal's roster.","type":"string"},"screen":{"additionalProperties":false,"description":"Customer-screen behavior for this command's result — each key is a screen, each screen has a `duration` (ms)","type":"object","properties":{"approved":{"additionalProperties":false,"description":"APPROVED result screen","type":"object","properties":{"duration":{"minimum":0,"maximum":60000,"description":"ms to show the APPROVED result screen (default 5000; 0 = don't linger)","type":"integer"}}},"declined":{"additionalProperties":false,"description":"DECLINED/error result screen","type":"object","properties":{"duration":{"minimum":0,"maximum":60000,"description":"ms to show the DECLINED/error result screen (default 8000; 0 = don't linger)","type":"integer"}}},"completion":{"additionalProperties":false,"description":"Post-payment completion message (optional)","type":"object","properties":{"message":{"maxLength":120,"description":"Message shown to the customer after an approved payment (e.g. 'Thank you!'). Must be non-empty AND duration > 0 to appear.","type":"string"},"duration":{"minimum":0,"maximum":60000,"description":"ms to show the completion message. Must be > 0 AND message non-empty to appear; default 0 = no completion screen.","type":"integer"}}}}},"receipt":{"enum":["skip","customer","merchant","both"],"description":"**Optional.** What the terminal prints for this call. **Left out**, the terminal follows its own RECEIPTS settings (Customer copy, Merchant copy, Manual print, on the terminal's page in the portal): the copies switched on print right after approval, customer first; with Manual print on, nothing prints, because nobody is at the terminal to choose. **Sent**, it overrides those settings for this call only: `customer`, `merchant` or `both` print those copies whatever the settings say, and `skip` prints nothing — for a POS that prints its own receipt. Nothing prints unless the payment is approved. The receipt template can tell the copies apart with `{if receipt.customer}` and `{if receipt.merchant}`.","type":"string"}}},"examples":{"adjustment":{"summary":"Price adjustment","value":{"amount":500,"reason":"Price match","reasonCode":"wrong_amount"}}}}}},"parameters":[{"schema":{"type":"string"},"in":"query","name":"serial","required":false,"description":"Address the terminal by device serial — exact, globally unique. Use this OR `tag`, not both."},{"schema":{"type":"string"},"in":"query","name":"tag","required":false,"description":"Address the terminal by its tag — a handle written like \"#tag\" without the \"#\". Normalized to lowercase ASCII [a-z0-9_] before matching (spaces/case/punctuation dropped), so \"Front Counter\" and \"frontcounter\" resolve the same terminal. Set per terminal in the portal. Use this OR `serial`, not both."},{"schema":{"type":"string"},"in":"header","name":"x-idempotency-key","required":true,"description":"Idempotency key — UUID (RFC-4122 v4/v7). REQUIRED on money-movement. Value is normalized to lowercase. Generate a FRESH UUID per new operation — the key is consumed by its first attempt whatever the outcome (approved, declined, failed, cancelled). Resend the SAME key only to safely retry that one operation (the stored outcome is replayed — no double charge, no new attempt); a new charge needs a new key."}],"security":[{"ApiKeyAuth":["transaction:write","ecr:payment:refund"]}],"x-badges":[{"name":"transaction:write","position":"after"},{"name":"ecr:payment:refund","position":"after"}],"responses":{"200":{"description":"The terminal answered — approved OR declined: a decline is still `200`, so read `result.status`. `amount.approved` is what moved, tip included; `id` is the request id for `GET /api/v2/ecr/requests/{id}`. A resend with a used key answers `200` too, with `idempotent: true` and the first attempt's stored outcome — whatever that was.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/RequestRecord"},"examples":{"paidOut":{"summary":"Paid out","value":{"idempotent":false,"id":"6ab6fa2ba31a6c313c223b9e","type":"semi_cash_refund","status":"completed","result":{"status":"approved","code":"000000","message":"OK"},"amount":{"partial":false,"requested":0,"approved":500,"tax":0,"tip":0,"surcharge":0,"cashback":0,"tendered":500,"change":0},"time":{"sent":1790361002114,"completed":1790361014902}}}}}}},"400":{"description":"Bad request — a field broke the schema (`invalid_request`, with `detail.field`), the idempotency key is not a UUID (`invalid_idempotency_key`), or no terminal was addressed (`terminal_required`).","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"401":{"description":"Unauthorized — the request carried no `x-api-key` header.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"403":{"description":"Forbidden — the key lacks a scope this command needs (`insufficient_scope`, listing them in `detail.missing`), or the key, its user or the account is no longer active.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"404":{"description":"Not found — no terminal with that serial or tag (`terminal_not_found`), no payment with that id (`payment_not_found`), or no such employee (`employee_not_found`).","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"409":{"description":"Conflict — the terminal is inactive or not in SEMI mode, another command holds it (`terminal_busy`), the same key is still in flight (`request_in_progress`), the payment cannot be reversed this way (voided already, batch closed, another terminal, a cash sale to void), or the terminal cancelled the command before it completed (`cancelled` — no money moved; retry with a NEW key).","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"422":{"description":"Unprocessable — the refund amount exceeds the payment's remaining refundable balance (`refund_exceeds_refundable`), the original has no terminal reference to reverse, or a consumed x-idempotency-key was replayed with a DIFFERENT body (`idempotency_key_reused`).","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"502":{"description":"Device error — the terminal received the command and failed it, including when it was busy with a cashier's own operation. Usually no money moved: if `detail.message` begins `PARTIAL APPROVAL`, the card may have been charged — read the request record and the transaction before retrying.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"503":{"description":"Terminal offline or unreachable — the command never reached the device; safe to retry with the same key.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"504":{"description":"No answer within the wait window (`outcome_unknown`) — the payment MAY have gone through. Fetch `GET /api/v2/ecr/requests/{id}` with `detail.requestId` before doing anything else.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}}}},"/api/v2/ecr/terminal/cancel":{"post":{"operationId":"cancelPayment","summary":"Cancel payment request","tags":["ECR Commands"],"description":"Best-effort cancel of an in-flight payment request on the terminal. Optionally show a message on the customer screen afterwards (else it clears to idle). Send a JSON body — `{}` when you have nothing to show. `result.cancelled` is `true` once the abort was sent; that does not prove a payment was running or stopped, so read that payment's own outcome.","requestBody":{"required":true,"content":{"application/json":{"schema":{"additionalProperties":false,"type":"object","properties":{"message":{"maxLength":120,"description":"Optional message shown on the customer screen after cancelling (e.g. 'Cancelled'). Omitted = screen just clears to idle.","type":"string"},"duration":{"minimum":0,"maximum":60000,"description":"ms to show the cancel message (only when `message` set). Omitted/0 = default 5000ms.","type":"integer"}}},"examples":{"message":{"summary":"With a message","value":{"message":"Cancelled","duration":3000}}}}}},"parameters":[{"schema":{"type":"string"},"in":"query","name":"serial","required":false,"description":"Address the terminal by device serial — exact, globally unique. Use this OR `tag`, not both."},{"schema":{"type":"string"},"in":"query","name":"tag","required":false,"description":"Address the terminal by its tag — a handle written like \"#tag\" without the \"#\". Normalized to lowercase ASCII [a-z0-9_] before matching (spaces/case/punctuation dropped), so \"Front Counter\" and \"frontcounter\" resolve the same terminal. Set per terminal in the portal. Use this OR `serial`, not both."}],"security":[{"ApiKeyAuth":["transaction:write"]}],"x-badges":[{"name":"transaction:write","position":"after"}],"responses":{"200":{"description":"OK — the terminal returned a result.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/RequestRecord"},"examples":{"cancelled":{"summary":"Stopped","value":{"idempotent":false,"id":"6ab6fa2ba31a6c313c223ba0","type":"semi_cancel","status":"completed","result":{"cancelled":true},"time":{"sent":1790376491270,"completed":1790376491273}}},"tooLate":{"summary":"Abort could not be sent","value":{"idempotent":false,"id":"6ab6fa2ba31a6c313c223ba1","type":"semi_cancel","status":"completed","result":{"cancelled":false,"reason":"native_method_unavailable"},"time":{"sent":1790376491270,"completed":1790376491273}}}}}}},"400":{"description":"Bad request — invalid or missing parameters.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"401":{"description":"Unauthorized — the request carried no `x-api-key` header.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"403":{"description":"Forbidden — the API key's user is missing a required scope.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"404":{"description":"Not found — no matching terminal on this account.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"409":{"description":"Conflict — the terminal is inactive or not in SEMI mode.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"502":{"description":"Device error — the terminal failed the command, including when it was busy with a cashier's own operation.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"503":{"description":"Terminal offline or unreachable — the command never reached it (`device_unreachable`).","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"504":{"description":"Timeout — no result within the window.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}}}},"/api/v2/ecr/terminal/card":{"get":{"operationId":"readCard","summary":"Read card","tags":["ECR Commands"],"description":"Read a card on the terminal without charging it.","parameters":[{"schema":{"type":"string"},"in":"query","name":"serial","required":false,"description":"Address the terminal by device serial — exact, globally unique. Use this OR `tag`, not both."},{"schema":{"type":"string"},"in":"query","name":"tag","required":false,"description":"Address the terminal by its tag — a handle written like \"#tag\" without the \"#\". Normalized to lowercase ASCII [a-z0-9_] before matching (spaces/case/punctuation dropped), so \"Front Counter\" and \"frontcounter\" resolve the same terminal. Set per terminal in the portal. Use this OR `serial`, not both."}],"security":[{"ApiKeyAuth":["transaction:write"]}],"x-badges":[{"name":"Coming soon","position":"before"},{"name":"transaction:write","position":"after"}],"responses":{"400":{"description":"Bad request — invalid or missing parameters.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"401":{"description":"Unauthorized — the request carried no `x-api-key` header.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"403":{"description":"Forbidden — the API key's user is missing a required scope.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"404":{"description":"Not found — no matching terminal on this account.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"409":{"description":"Conflict — the terminal is inactive or not in SEMI mode.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"501":{"description":"Not implemented yet — the route answers 501 until the command ships.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"502":{"description":"Device error — the terminal failed the command, including when it was busy with a cashier's own operation.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"504":{"description":"Timeout — no result within the window.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}}}},"/api/v2/ecr/terminal/card/transactions":{"get":{"operationId":"getCardDetails","summary":"Get card details","tags":["ECR Commands"],"description":"Return the card details captured by a prior read-card.","parameters":[{"schema":{"type":"string"},"in":"query","name":"serial","required":false,"description":"Address the terminal by device serial — exact, globally unique. Use this OR `tag`, not both."},{"schema":{"type":"string"},"in":"query","name":"tag","required":false,"description":"Address the terminal by its tag — a handle written like \"#tag\" without the \"#\". Normalized to lowercase ASCII [a-z0-9_] before matching (spaces/case/punctuation dropped), so \"Front Counter\" and \"frontcounter\" resolve the same terminal. Set per terminal in the portal. Use this OR `serial`, not both."}],"security":[{"ApiKeyAuth":["transaction:write"]}],"x-badges":[{"name":"Coming soon","position":"before"},{"name":"transaction:write","position":"after"}],"responses":{"400":{"description":"Bad request — invalid or missing parameters.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"401":{"description":"Unauthorized — the request carried no `x-api-key` header.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"403":{"description":"Forbidden — the API key's user is missing a required scope.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"404":{"description":"Not found — no matching terminal on this account.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"409":{"description":"Conflict — the terminal is inactive or not in SEMI mode.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"501":{"description":"Not implemented yet — the route answers 501 until the command ships.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"502":{"description":"Device error — the terminal failed the command, including when it was busy with a cashier's own operation.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"504":{"description":"Timeout — no result within the window.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}}}},"/api/v2/ecr/terminal/inputs":{"post":{"operationId":"collectCustomerInputs","summary":"Ask for customer inputs","tags":["ECR Commands"],"description":"Run a multi-step input wizard on the terminal — one field per step (text/enum/phone/email/number/date/time/signature) with Back/Next and a progress bar. Blocks until the customer finishes or backs out; returns `data.completed` (did they finish) plus `data.values`, one entry per screen and always parallel to `screens`. Each entry is `{type,value,completed}`: `type` echoes the screen's type, `value` is the answer as a string (or null), and `completed` says the customer ACTIVELY provided it. `value` and `completed` are independent — a `date`/`time` wheel is pre-set to now, so an untouched one reports the date it displayed WITH `completed:false`, letting you tell \"accepted our default\" from \"actually picked\". `date`/`time` return ISO 8601 parts (`YYYY-MM-DD` / `HH:mm`) exactly as set — no timezone conversion. A `signature` returns the vector SVG, or a rasterized image `data:` URI when that step sets `format:\"png\"|\"jpeg\"` (per-step, so several signatures can each use their own); it is transient either way (never stored) and always comes back null if the customer backed out.","requestBody":{"required":true,"content":{"application/json":{"schema":{"additionalProperties":false,"type":"object","required":["screens"],"properties":{"title":{"maxLength":120,"description":"Wizard title shown at the top of every step.","type":"string"},"screens":{"minItems":1,"description":"Ordered steps — one input per screen. The array length is the number of steps.","type":"array","items":{"additionalProperties":false,"type":"object","required":["type","label"],"properties":{"type":{"enum":["text","enum","phone","email","number","signature","date","time","message"],"description":"Input type — text (free text), enum (pick one of `options`), phone, email (an address, typed on an email keyboard — the terminal only lets a well-formed one through, so `name@host.tld` or empty), number, date (calendar wheel; returns an ISO 8601 date `YYYY-MM-DD`), time (clock wheel; returns an ISO 8601 local time `HH:mm`), signature (on-screen signature pad; returns the vector SVG, or a rasterized image per this step's `format`), or message (read-only — shows `description` with no input at all; always returns `value:null`, and its `completed` is the only thing it reports — see `mandatory`).","type":"string"},"label":{"description":"Field label shown above the input on this step. On a `message` step this is the heading over the text.","type":"string"},"description":{"description":"Optional long-form text shown in a scrollable card between the label and the input — e.g. terms & conditions to read before a `signature` step. The customer must scroll it to the end before the button unlocks (except on a non-mandatory `message`, which never gates). Required in practice for `message` steps — that text IS the step. Rendered as-is (plain text, `\\n` for line breaks).","type":"string"},"mandatory":{"description":"If true, the customer must fill this step before continuing (default false). No-op for `date`/`time` — a picker wheel always holds a value, so those are never empty. On a `message` there is no field to fill, so it means something different there: `true` = the customer must scroll the text to the end and press the acknowledge button, and `completed` then means THEY AGREED (false if they read it but backed out). `false` = no gate, plain NEXT, and `completed` becomes a reading receipt — true only if they actually reached the end of the text (or it was short enough to need no scrolling).","type":"boolean"},"button":{"maxLength":20,"description":"`message` steps only — the label on the advance button. Defaults to `ACKNOWLEDGE` when `mandatory:true`, else the usual `NEXT`/`DONE`. Use it to say what pressing it actually means: `AGREE` for terms you need consent to, `I UNDERSTAND` for a warning, `ACKNOWLEDGE` for a notice. On a mandatory message this label wins even on the last step (`AGREE` says what it does; `DONE` doesn't). Kept short — it must fit one line.","type":"string"},"options":{"description":"Choices shown for type=enum.","type":"array","items":{"type":"string"}},"format":{"enum":["svg","png","jpeg"],"default":"svg","description":"`signature` steps only — how THIS signature comes back: `svg` (default, the vector exactly as collected) or a rasterized `png`/`jpeg` image `data:` URI. Per-step, so a wizard with several signatures can return each in its own format. Ignored on non-signature steps.","type":"string"}}}}}},"examples":{"agreement":{"summary":"Name, terms, signature","value":{"title":"Service agreement","screens":[{"type":"text","label":"Full name","mandatory":true},{"type":"message","label":"Terms of service","description":"Work is guaranteed for 90 days.\nParts are billed at list price.","mandatory":true,"button":"AGREE"},{"type":"signature","label":"Sign to accept","mandatory":true,"format":"png"}]}},"receipt":{"summary":"Email for the receipt","value":{"title":"Send the receipt","screens":[{"type":"email","label":"Email address"}]}}}}}},"parameters":[{"schema":{"type":"string"},"in":"query","name":"serial","required":false,"description":"Address the terminal by device serial — exact, globally unique. Use this OR `tag`, not both."},{"schema":{"type":"string"},"in":"query","name":"tag","required":false,"description":"Address the terminal by its tag — a handle written like \"#tag\" without the \"#\". Normalized to lowercase ASCII [a-z0-9_] before matching (spaces/case/punctuation dropped), so \"Front Counter\" and \"frontcounter\" resolve the same terminal. Set per terminal in the portal. Use this OR `serial`, not both."}],"security":[{"ApiKeyAuth":["transaction:write"]}],"x-badges":[{"name":"transaction:write","position":"after"}],"responses":{"200":{"description":"Collected customer inputs.","content":{"application/json":{"schema":{"additionalProperties":false,"description":"Collected customer inputs.","type":"object","required":["data"],"properties":{"data":{"additionalProperties":false,"description":"The wizard outcome plus every screen's answer.","type":"object","required":["completed","values"],"properties":{"completed":{"description":"True if the customer finished the wizard; false if they backed out (or it was reset from the server). `values` is still returned either way — un-reached steps come back `{value:null,completed:false}`.","type":"boolean"},"values":{"description":"One entry per screen, always parallel to `screens` and always the same length — even when the customer backed out partway.","type":"array","items":{"additionalProperties":false,"type":"object","required":["type","value","completed"],"properties":{"type":{"description":"The `type` of the screen this answer belongs to, echoed back so the response is self-describing (`text`|`enum`|`phone`|`email`|`number`|`date`|`time`|`signature`|`message`).","anyOf":[{"type":"string"},{"type":"null"}]},"value":{"description":"The answer, always a string or null — never a number, so numeric input keeps leading zeros and full precision (parse it yourself if you want arithmetic). null when nothing was entered, the step was never reached, it is a `signature` on a backed-out wizard, or the step is a `message` (which captures nothing — always null). `date` = `YYYY-MM-DD`, `time` = `HH:mm`, `signature` = an SVG string or an image `data:` URI per that step's `format`.","anyOf":[{"type":"string"},{"type":"null"}]},"completed":{"description":"True when the customer ACTIVELY provided this value. Independent of `value`, which matters in exactly two places. (1) `date`/`time`: the wheel is pre-set, so an untouched one reports the value it displayed with `completed:false` — that's how you tell \"accepted our default\" from \"actually picked\". (2) `message`: `value` is always null, so `completed` is the entire payload — on a mandatory message it means THEY AGREED (false if they read it and then backed out), and on a non-mandatory one it is a reading receipt (did they reach the end of the text, or click straight past). For every other type this ends up equivalent to `value !== null`.","type":"boolean"}}}}}}}},"examples":{"signed":{"summary":"Completed","value":{"data":{"completed":true,"values":[{"type":"text","value":"Jane Doe","completed":true},{"type":"message","value":null,"completed":true},{"type":"signature","value":"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAA…","completed":true}]}}},"receipt":{"summary":"Email given","value":{"data":{"completed":true,"values":[{"type":"email","value":"dana@example.com","completed":true}]}}}}}}},"400":{"description":"Bad request — invalid or missing parameters.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"401":{"description":"Unauthorized — the request carried no `x-api-key` header.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"403":{"description":"Forbidden — the API key's user is missing a required scope.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"404":{"description":"Not found — no matching terminal on this account.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"409":{"description":"Conflict — the terminal is inactive or not in SEMI mode.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"502":{"description":"Device error — the terminal failed the command, including when it was busy with a cashier's own operation.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"503":{"description":"Terminal offline or unreachable — the command never reached it (`device_unreachable`).","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"504":{"description":"Timeout — no result within the window.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}}}},"/api/v2/ecr/terminal/reset":{"post":{"operationId":"resetScreen","summary":"Reset screen","tags":["ECR Commands"],"description":"Clear the customer screen back to idle — immediately wipes any lingering result, completion message, or screen/items overlay (and cancels its auto-dismiss timer).","parameters":[{"schema":{"type":"string"},"in":"query","name":"serial","required":false,"description":"Address the terminal by device serial — exact, globally unique. Use this OR `tag`, not both."},{"schema":{"type":"string"},"in":"query","name":"tag","required":false,"description":"Address the terminal by its tag — a handle written like \"#tag\" without the \"#\". Normalized to lowercase ASCII [a-z0-9_] before matching (spaces/case/punctuation dropped), so \"Front Counter\" and \"frontcounter\" resolve the same terminal. Set per terminal in the portal. Use this OR `serial`, not both."}],"security":[{"ApiKeyAuth":["transaction:write"]}],"x-badges":[{"name":"transaction:write","position":"after"}],"responses":{"200":{"description":"OK — the terminal returned a result.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/RequestRecord"},"examples":{"cleared":{"summary":"Cleared","value":{"id":"6ab6fa2ba31a6c313c223ba2","type":"semi_reset","status":"completed","time":{"sent":1790376491312,"completed":1790376491316}}}}}}},"400":{"description":"Bad request — invalid or missing parameters.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"401":{"description":"Unauthorized — the request carried no `x-api-key` header.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"403":{"description":"Forbidden — the API key's user is missing a required scope.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"404":{"description":"Not found — no matching terminal on this account.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"409":{"description":"Conflict — the terminal is inactive or not in SEMI mode.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"502":{"description":"Device error — the terminal failed the command, including when it was busy with a cashier's own operation.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"503":{"description":"Terminal offline or unreachable — the command never reached it (`device_unreachable`).","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"504":{"description":"Timeout — no result within the window.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}}}},"/api/v2/ecr/terminal/tips":{"post":{"operationId":"requestTip","summary":"Tip request","tags":["ECR Commands"],"description":"Prompt the customer to pick a tip on the terminal, outside a payment.","requestBody":{"required":true,"content":{"application/json":{"schema":{"additionalProperties":false,"type":"object","required":["amount","suggestions"],"properties":{"amount":{"minimum":1,"description":"Base amount in cents the tip is calculated against","type":"integer"},"suggestions":{"description":"Tip options shown on the terminal","type":"array","items":{"additionalProperties":false,"description":"One tip option on the terminal's tip screen — a percentage OR a fixed amount, with an optional display label and accent color.","type":"object","properties":{"label":{"description":"Display label of the tip option, shown under the value on the tip card (e.g. \"Good\", \"Great\"). Omit for a bare percentage/amount tile — a label that merely repeats the percentage (\"15%\") is dropped, since the value is already rendered.","type":"string"},"amount":{"description":"Fixed tip amount in cents (mutually exclusive with percentage)","type":"number"},"percentage":{"description":"Tip percentage applied to the tip base — the full `amount` on `tipBase: \"gross\"`, `amount` − `options.taxAmount` on `\"net\"` (mutually exclusive with amount)","type":"number"},"accent":{"pattern":"^#(?:[0-9a-fA-F]{3}|[0-9a-fA-F]{6})$","description":"Optional accent color (hex, e.g. #EA580C) for THIS option's tile — colors its border, underline, unit symbol and label. Omitted = the terminal's default accent. The amount itself stays white so every option reads with equal weight.","type":"string"}}}}}}}}},"parameters":[{"schema":{"type":"string"},"in":"query","name":"serial","required":false,"description":"Address the terminal by device serial — exact, globally unique. Use this OR `tag`, not both."},{"schema":{"type":"string"},"in":"query","name":"tag","required":false,"description":"Address the terminal by its tag — a handle written like \"#tag\" without the \"#\". Normalized to lowercase ASCII [a-z0-9_] before matching (spaces/case/punctuation dropped), so \"Front Counter\" and \"frontcounter\" resolve the same terminal. Set per terminal in the portal. Use this OR `serial`, not both."}],"security":[{"ApiKeyAuth":["transaction:write"]}],"x-badges":[{"name":"Coming soon","position":"before"},{"name":"transaction:write","position":"after"}],"responses":{"400":{"description":"Bad request — invalid or missing parameters.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"401":{"description":"Unauthorized — the request carried no `x-api-key` header.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"403":{"description":"Forbidden — the API key's user is missing a required scope.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"404":{"description":"Not found — no matching terminal on this account.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"409":{"description":"Conflict — the terminal is inactive or not in SEMI mode.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"501":{"description":"Not implemented yet — the route answers 501 until the command ships.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"502":{"description":"Device error — the terminal failed the command, including when it was busy with a cashier's own operation.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"504":{"description":"Timeout — no result within the window.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}}}},"/api/v2/ecr/terminal/ping":{"get":{"operationId":"pingTerminal","summary":"Ping terminal","tags":["ECR Commands"],"description":"Health-check the terminal and return a live snapshot: identity, GPS location, lock/busy state, the API command it is working on (if any, with its idempotency key), and the terminal clock (ISO 8601). `200` only when the terminal answers within 5 s; `503 terminal_offline` when it does not; `409` when it is inactive or not in SEMI mode.","parameters":[{"schema":{"type":"string"},"in":"query","name":"serial","required":false,"description":"Address the terminal by device serial — exact, globally unique. Use this OR `tag`, not both."},{"schema":{"type":"string"},"in":"query","name":"tag","required":false,"description":"Address the terminal by its tag — a handle written like \"#tag\" without the \"#\". Normalized to lowercase ASCII [a-z0-9_] before matching (spaces/case/punctuation dropped), so \"Front Counter\" and \"frontcounter\" resolve the same terminal. Set per terminal in the portal. Use this OR `serial`, not both."}],"security":[{"ApiKeyAuth":["transaction:write"]}],"x-badges":[{"name":"transaction:write","position":"after"}],"responses":{"200":{"description":"Terminal live snapshot, taken just now.","content":{"application/json":{"schema":{"additionalProperties":true,"description":"Terminal live snapshot, taken just now.","type":"object","required":["latencyMs","deviceTime","terminal","location","state"],"properties":{"latencyMs":{"description":"Round-trip time to the terminal in ms","type":"integer"},"deviceTime":{"description":"Terminal clock — ISO 8601 (the terminal's own current date/time)","anyOf":[{"type":"string"},{"type":"null"}]},"terminal":{"additionalProperties":true,"description":"Terminal identity","type":"object","required":["id","name","serial","model","active"],"properties":{"id":{"type":"string"},"name":{"anyOf":[{"type":"string"},{"type":"null"}]},"serial":{"anyOf":[{"type":"string"},{"type":"null"}]},"model":{"anyOf":[{"type":"string"},{"type":"null"}]},"active":{"type":"boolean"}}},"location":{"description":"The terminal's last reported GPS location (null if unknown)","anyOf":[{"type":"null"},{"additionalProperties":true,"type":"object","properties":{"lat":{"type":"number"},"lng":{"type":"number"}}}]},"state":{"additionalProperties":true,"description":"Live terminal state","type":"object","required":["locked","busy","command"],"properties":{"locked":{"description":"Whether the terminal screen is locked","anyOf":[{"type":"boolean"},{"type":"null"}]},"busy":{"description":"Whether the terminal is working on something — an API command or a cashier's own sale","anyOf":[{"type":"boolean"},{"type":"null"}]},"command":{"description":"The transaction currently running on the terminal, or null when idle.","anyOf":[{"type":"null"},{"additionalProperties":true,"type":"object","required":["type","idempotencyKey","externalId","since"],"properties":{"type":{"description":"The API command holding the terminal: `semi_sale` | `semi_void` | `semi_refund` | `semi_cash` | `semi_cash_refund`.","type":"string"},"idempotencyKey":{"description":"Idempotency-Key of the in-flight request, if any","anyOf":[{"type":"string"},{"type":"null"}]},"externalId":{"description":"Client business id of the in-flight request, if any","anyOf":[{"type":"string"},{"type":"null"}]},"since":{"description":"ISO 8601 — when the in-flight command started on the terminal","type":"string"}}}]}}}}},"examples":{"idle":{"summary":"Idle","value":{"latencyMs":42,"deviceTime":"2026-09-25T14:30:12.000Z","terminal":{"id":"6ab6fa29a31a6c313c223b67","name":"Front Counter","serial":"1850123456","model":"A920Pro","active":true},"location":{"lat":43.6487,"lng":-79.3817},"state":{"locked":false,"busy":false,"command":null}}}}}}},"400":{"description":"Bad request — invalid or missing parameters.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"401":{"description":"Unauthorized — the request carried no `x-api-key` header.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"403":{"description":"Forbidden — the API key's user is missing a required scope.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"404":{"description":"Not found — no matching terminal on this account.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"409":{"description":"Conflict — the terminal is inactive or not in SEMI mode.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"502":{"description":"Device error — the terminal failed the command, including when it was busy with a cashier's own operation.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"503":{"description":"The terminal did not answer within 5 s — offline or unreachable (`terminal_offline`).","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"},"examples":{"offline":{"summary":"No answer","value":{"error":"terminal_offline","detail":{"message":"The terminal did not answer — it is offline or unreachable."}}}}}}},"504":{"description":"Timeout — no result within the window.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}}}},"/api/v2/ecr/terminal/print":{"post":{"operationId":"printText","summary":"Device print text","tags":["ECR Commands"],"description":"Print arbitrary text lines on the terminal's thermal printer. A terminal with nowhere to print fails the command with `502 device_error`; unlike printing a payment's receipt, this call is not answered `409 terminal_has_no_printer`.","requestBody":{"required":true,"content":{"application/json":{"schema":{"additionalProperties":false,"type":"object","required":["text"],"properties":{"text":{"description":"Lines of text to print on the built-in thermal printer","type":"array","items":{"type":"string"}}}},"examples":{"ticket":{"summary":"Pickup ticket","value":{"text":["ACME COFFEE","Order #1042","Pick up at counter 2"]}}}}}},"parameters":[{"schema":{"type":"string"},"in":"query","name":"serial","required":false,"description":"Address the terminal by device serial — exact, globally unique. Use this OR `tag`, not both."},{"schema":{"type":"string"},"in":"query","name":"tag","required":false,"description":"Address the terminal by its tag — a handle written like \"#tag\" without the \"#\". Normalized to lowercase ASCII [a-z0-9_] before matching (spaces/case/punctuation dropped), so \"Front Counter\" and \"frontcounter\" resolve the same terminal. Set per terminal in the portal. Use this OR `serial`, not both."}],"security":[{"ApiKeyAuth":["transaction:write"]}],"x-badges":[{"name":"transaction:write","position":"after"}],"responses":{"200":{"description":"OK — the terminal returned a result.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/RequestRecord"},"examples":{"printed":{"summary":"Printed","value":{"id":"6ab6fa2ba31a6c313c223ba3","type":"semi_print","status":"completed","time":{"sent":1790376491342,"completed":1790376491346}}}}}}},"400":{"description":"Bad request — invalid or missing parameters.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"401":{"description":"Unauthorized — the request carried no `x-api-key` header.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"403":{"description":"Forbidden — the API key's user is missing a required scope.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"404":{"description":"Not found — no matching terminal on this account.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"409":{"description":"Conflict — the terminal is inactive or not in SEMI mode.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"502":{"description":"Device error — the terminal failed the command, including when it was busy with a cashier's own operation.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"503":{"description":"Terminal offline or unreachable — the command never reached it (`device_unreachable`).","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"504":{"description":"Timeout — no result within the window.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}}}},"/api/v2/ecr/terminal/screen":{"put":{"operationId":"showScreen","summary":"Set device screen / message","tags":["ECR Commands"],"description":"Display a styled card on the terminal. `screen=message` shows just the text; `screen=list` adds line items (e.g. an order summary); `screen=image` shows a centered image with the message above it. `message` is shown at the top of any layout when non-empty.","requestBody":{"required":true,"content":{"application/json":{"schema":{"additionalProperties":false,"type":"object","required":["screen"],"properties":{"screen":{"enum":["message","list","image"],"description":"Layout: `message` (text only), `list` (text + line items), or `image` (text + a centered image).","type":"string"},"message":{"description":"Heading / message text. Shown at the top whenever it's non-empty (all layouts).","type":"string"},"items":{"description":"Line items for `screen=list` — rendered as a clean styled list under the message. Each row: title (left) + optional `qty × $price` (right).","type":"array","items":{"additionalProperties":false,"type":"object","required":["title"],"properties":{"title":{"description":"Row label, e.g. a product name","type":"string"},"qty":{"description":"Quantity (optional) — rendered as `qty ×`","type":"number"},"price":{"description":"Price in cents (optional) — rendered as $x.xx","type":"integer"}}}},"image":{"maxLength":2048,"description":"Image URL for `screen=image` — rendered centered (vertical + horizontal), with `message` above it when set. RASTER ONLY (PNG/JPG/WebP/GIF); SVG is not supported by the terminal and renders blank.","type":"string"},"duration":{"minimum":-1,"maximum":60000,"description":"How long to keep the screen up, in ms. -1 = forever (stays until the next screen/reset). 0/null/omitted = default 5000ms. >0 = that many ms.","type":"integer"},"accent":{"pattern":"^#(?:[0-9a-fA-F]{3}|[0-9a-fA-F]{6})$","description":"Optional accent color (hex, e.g. #EA580C) used for the screen's stripe/header/icon.","type":"string"}}},"examples":{"order":{"summary":"Order summary","value":{"screen":"list","message":"Your order","items":[{"title":"Flat white","qty":2,"price":550},{"title":"Croissant","qty":1,"price":475}],"duration":-1,"accent":"#EA580C"},"description":"`duration: -1` keeps it up until the next screen or a reset."},"image":{"summary":"Image","value":{"screen":"image","message":"Scan to join our loyalty club","image":"https://example.com/loyalty-qr.png","duration":15000}},"message":{"summary":"Message","value":{"screen":"message","message":"Please wait — preparing your receipt","duration":5000}}}}}},"parameters":[{"schema":{"type":"string"},"in":"query","name":"serial","required":false,"description":"Address the terminal by device serial — exact, globally unique. Use this OR `tag`, not both."},{"schema":{"type":"string"},"in":"query","name":"tag","required":false,"description":"Address the terminal by its tag — a handle written like \"#tag\" without the \"#\". Normalized to lowercase ASCII [a-z0-9_] before matching (spaces/case/punctuation dropped), so \"Front Counter\" and \"frontcounter\" resolve the same terminal. Set per terminal in the portal. Use this OR `serial`, not both."}],"security":[{"ApiKeyAuth":["transaction:write"]}],"x-badges":[{"name":"transaction:write","position":"after"}],"responses":{"200":{"description":"OK — the terminal returned a result.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/RequestRecord"},"examples":{"shown":{"summary":"Shown","value":{"id":"6ab6fa2ba31a6c313c223ba4","type":"semi_screen","status":"completed","time":{"sent":1790376491342,"completed":1790376491346}}}}}}},"400":{"description":"Bad request — invalid or missing parameters.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"401":{"description":"Unauthorized — the request carried no `x-api-key` header.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"403":{"description":"Forbidden — the API key's user is missing a required scope.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"404":{"description":"Not found — no matching terminal on this account.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"409":{"description":"Conflict — the terminal is inactive or not in SEMI mode.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"502":{"description":"Device error — the terminal failed the command, including when it was busy with a cashier's own operation.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"503":{"description":"Terminal offline or unreachable — the command never reached it (`device_unreachable`).","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"504":{"description":"Timeout — no result within the window.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}}}},"/api/v2/ecr/terminal/batch/close":{"post":{"operationId":"closeBatch","summary":"Close batch","tags":["ECR Commands"],"description":"Trigger batch settlement on the terminal. Answers `{ accepted: true }` as soon as the command is sent — also to a terminal that is offline, which never receives it — and the settlement lands later: poll `GET /batch`. Whether the settlement report prints is the terminal's own batch setting, not part of this call. Refused with `409 batch_host_managed` when the terminal's batch mode is `host`: the host closes that batch itself. Send a JSON body — `{}` when you have nothing to set.","requestBody":{"required":true,"content":{"application/json":{"schema":{"additionalProperties":false,"type":"object","properties":{}}}}},"parameters":[{"schema":{"type":"string"},"in":"query","name":"serial","required":false,"description":"Address the terminal by device serial — exact, globally unique. Use this OR `tag`, not both."},{"schema":{"type":"string"},"in":"query","name":"tag","required":false,"description":"Address the terminal by its tag — a handle written like \"#tag\" without the \"#\". Normalized to lowercase ASCII [a-z0-9_] before matching (spaces/case/punctuation dropped), so \"Front Counter\" and \"frontcounter\" resolve the same terminal. Set per terminal in the portal. Use this OR `serial`, not both."}],"security":[{"ApiKeyAuth":["transaction:write","ecr:batch:close"]}],"x-badges":[{"name":"transaction:write","position":"after"},{"name":"ecr:batch:close","position":"after"}],"responses":{"200":{"description":"OK — the terminal returned a result.","content":{"application/json":{"schema":{"description":"Sent to the terminal — no confirmation that it ran. The platform does not track whether the terminal is online for this command.","type":"object","required":["accepted"],"properties":{"accepted":{"type":"boolean","enum":[true]}}},"examples":{"accepted":{"summary":"Sent","value":{"accepted":true}}}}}},"400":{"description":"Bad request — invalid or missing parameters.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"401":{"description":"Unauthorized — the request carried no `x-api-key` header.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"403":{"description":"Forbidden — the API key's user is missing a required scope.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"404":{"description":"Not found — no matching terminal on this account.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"409":{"description":"Conflict — the terminal is inactive or not in SEMI mode, or its batch is closed by the host (`batch_host_managed`).","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"},"examples":{"host":{"summary":"Host-managed batch","value":{"error":"batch_host_managed","detail":{"message":"This terminal's batch is closed by the host. It cannot be closed from the terminal."}}}}}}}}}},"/api/v2/ecr/terminal/batch":{"get":{"operationId":"getLatestBatch","summary":"Get latest batch","tags":["ECR Commands"],"description":"Return the most recent settlement batch recorded for this terminal, or `{}` when there is none yet. The open batch is not mirrored here — only closed ones.","parameters":[{"schema":{"type":"string"},"in":"query","name":"serial","required":false,"description":"Address the terminal by device serial — exact, globally unique. Use this OR `tag`, not both."},{"schema":{"type":"string"},"in":"query","name":"tag","required":false,"description":"Address the terminal by its tag — a handle written like \"#tag\" without the \"#\". Normalized to lowercase ASCII [a-z0-9_] before matching (spaces/case/punctuation dropped), so \"Front Counter\" and \"frontcounter\" resolve the same terminal. Set per terminal in the portal. Use this OR `serial`, not both."}],"security":[{"ApiKeyAuth":["transaction:write"]}],"x-badges":[{"name":"transaction:write","position":"after"}],"responses":{"200":{"description":"OK — the terminal returned a result.","content":{"application/json":{"schema":{"anyOf":[{"title":"Batch","type":"object","required":["batchNumber","settlementDate","source","createdAt"],"properties":{"batchNumber":{"anyOf":[{"type":"string"},{"type":"null"}]},"settlementDate":{"anyOf":[{"format":"date-time","description":"Settled — ISO 8601.","type":"string"},{"type":"null"}]},"source":{"enum":["device","host"],"description":"Who closed it.","type":"string"},"createdAt":{"format":"date-time","description":"Recorded — ISO 8601.","type":"string"}}},{"additionalProperties":false,"title":"No batch","description":"The terminal has no batch recorded yet.","type":"object","properties":{}}]},"examples":{"closed":{"summary":"Last closed batch","value":{"batchNumber":"41","settlementDate":"2026-09-24T00:00:00.000Z","source":"device","createdAt":"2026-09-25T22:48:09.420Z"}},"none":{"summary":"No batch yet","value":{}}}}}},"400":{"description":"Bad request — invalid or missing parameters.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"401":{"description":"Unauthorized — the request carried no `x-api-key` header.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"403":{"description":"Forbidden — the API key's user is missing a required scope.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"404":{"description":"Not found — no matching terminal on this account.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"409":{"description":"Conflict — the terminal is inactive or not in SEMI mode.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}}}},"/api/v2/ecr/terminal/resync":{"post":{"operationId":"resyncTerminal","summary":"Resync terminal","tags":["ECR Commands"],"description":"Tell the terminal to re-sync its app state — after switching it to SEMI mode, or after changing its settings. Answers `{ accepted: true }` as soon as the command is sent; nothing reports back when the terminal has done it.","parameters":[{"schema":{"type":"string"},"in":"query","name":"serial","required":false,"description":"Address the terminal by device serial — exact, globally unique. Use this OR `tag`, not both."},{"schema":{"type":"string"},"in":"query","name":"tag","required":false,"description":"Address the terminal by its tag — a handle written like \"#tag\" without the \"#\". Normalized to lowercase ASCII [a-z0-9_] before matching (spaces/case/punctuation dropped), so \"Front Counter\" and \"frontcounter\" resolve the same terminal. Set per terminal in the portal. Use this OR `serial`, not both."}],"security":[{"ApiKeyAuth":["transaction:write"]}],"x-badges":[{"name":"transaction:write","position":"after"}],"responses":{"200":{"description":"OK — the terminal returned a result.","content":{"application/json":{"schema":{"description":"Sent to the terminal — no confirmation that it ran. The platform does not track whether the terminal is online for this command.","type":"object","required":["accepted"],"properties":{"accepted":{"type":"boolean","enum":[true]}}},"examples":{"accepted":{"summary":"Sent","value":{"accepted":true}}}}}},"400":{"description":"Bad request — invalid or missing parameters.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"401":{"description":"Unauthorized — the request carried no `x-api-key` header.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"403":{"description":"Forbidden — the API key's user is missing a required scope.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"404":{"description":"Not found — no matching terminal on this account.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"409":{"description":"Conflict — the terminal is inactive or not in SEMI mode.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}}}},"/api/v2/ecr/terminal/reboot":{"post":{"operationId":"rebootTerminal","summary":"Reboot terminal","tags":["ECR Commands"],"description":"Reboot the PAX device. Coming soon: a terminal can only reboot itself as a Device Owner, which current builds are not, so the route answers `501 coming_soon` and sends nothing.","parameters":[{"schema":{"type":"string"},"in":"query","name":"serial","required":false,"description":"Address the terminal by device serial — exact, globally unique. Use this OR `tag`, not both."},{"schema":{"type":"string"},"in":"query","name":"tag","required":false,"description":"Address the terminal by its tag — a handle written like \"#tag\" without the \"#\". Normalized to lowercase ASCII [a-z0-9_] before matching (spaces/case/punctuation dropped), so \"Front Counter\" and \"frontcounter\" resolve the same terminal. Set per terminal in the portal. Use this OR `serial`, not both."}],"security":[{"ApiKeyAuth":["transaction:write","ecr:terminal:reboot"]}],"x-badges":[{"name":"Coming soon","position":"before"},{"name":"transaction:write","position":"after"},{"name":"ecr:terminal:reboot","position":"after"}],"responses":{"400":{"description":"Bad request — invalid or missing parameters.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"401":{"description":"Unauthorized — the request carried no `x-api-key` header.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"403":{"description":"Forbidden — the API key's user is missing a required scope.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"404":{"description":"Not found — no matching terminal on this account.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"409":{"description":"Conflict — the terminal is inactive or not in SEMI mode.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"501":{"description":"Not implemented yet — the route answers 501 until the command ships.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"502":{"description":"Device error — the terminal failed the command, including when it was busy with a cashier's own operation.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"504":{"description":"Timeout — no result within the window.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}}}},"/api/v2/ecr/requests/{id}":{"get":{"operationId":"getRequest","summary":"Reconcile a dispatched command","tags":["ECR Commands"],"description":"Fetch any command dispatched on the key's account — lifecycle `status`, the PAX-shaped `result` / `amount` / `card` / `processor` blocks, the linked `transaction` and any `error`. Two uses: resolve a `504` (pass its `detail.requestId`), and pick up `transaction.id` for a sale, which the command response usually arrives without — the ledger row is linked by the terminal's own report, after the fact. Records are kept for about 7 days. No terminal is addressed and no merchant is in the path — the account comes from the API key.","parameters":[{"schema":{"type":"string"},"in":"path","name":"id","required":true,"description":"Request id — the `id` a command returned, or `detail.requestId` from a 504."}],"security":[{"ApiKeyAuth":["transaction:write"]}],"x-badges":[{"name":"transaction:write","position":"after"}],"responses":{"200":{"description":"The dispatched command's record.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/RequestRecord"},"examples":{"completed":{"summary":"Completed and linked","value":{"id":"6ab6fa2aa31a6c313c223b90","type":"semi_sale","status":"completed","externalId":"pos-inv-9001","result":{"status":"approved","code":"000000","hostCode":"00","message":"APPROVAL"},"amount":{"partial":false,"requested":1280,"approved":1280,"tax":130,"tip":150},"card":{"brand":"VISA","last4":"0119","entryMode":"contactless","holder":"JANE DOE"},"processor":{"authorizationCode":"OK4821","referenceNumber":"000118","hostReferenceNumber":"530612345678","batchNumber":"42","invoiceNumber":"8842","traceNumber":"118"},"time":{"sent":1790376490899,"completed":1790376490904},"transaction":{"id":"6ab6fa29a31a6c313c223b6c"}}},"inFlight":{"summary":"Still in flight","value":{"id":"68f2a1c4e9b0d31a5c7e4b22","type":"semi_sale","status":"in_progress","time":{"sent":1790361060000}}},"unknown":{"summary":"Timed out, then landed","value":{"id":"68f2a1c4e9b0d31a5c7e4b23","type":"semi_sale","status":"unknown","transaction":{"id":"68f2a1c4e9b0d31a5c7e4b99"},"error":"semi_timeout","time":{"sent":1790361060000,"completed":1790361150004}},"description":"The wait window closed with no answer, and the terminal's report linked a transaction afterwards: the sale went through."}}}}},"400":{"description":"Bad request — the id is not a 24-hex request id (`invalid_payment_id`).","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"401":{"description":"Unauthorized — the request carried no `x-api-key` header.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"403":{"description":"Forbidden — the API key's user is missing transaction:write.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"404":{"description":"Not found — no such request on this account, or it is older than about 7 days.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}}}}},"tags":[{"name":"Account","description":"The merchant account — profile, tenders, and the active sites with their working hours and per-site rules."},{"name":"Transactions","description":"The ledger, for reporting and reconciliation: settled and in-flight transactions, their receipts, and the record of any ECR command your key dispatched. Rows come in the **PAX-native** shape (`{ items, paging }`); add `?format=clover` for Clover-shaped rows."},{"name":"Employees","description":"The people payments are attributed to — list, create, update and soft-delete."},{"name":"Terminals","description":"The account's terminals. Switch one to **SEMI** mode here to open the ECR command plane to it, then `POST /api/v2/ecr/terminal/resync`."},{"name":"Orders","description":"A read-only order feed. Orders are generated from transactions, never created or edited directly. **PAX-native** shape by default; `?format=clover` for the Clover Order shape."},{"name":"Workorders","description":"Scheduled field-service jobs — list, create, update and delete. A work order can be deleted only while it is still `pending`, its time has not passed, and no transaction is linked to it."},{"name":"ECR Commands","description":"The **ECR / SEMI** command surface of one terminal — card and cash payments, refunds, voids, customer screens and prompts, printing, batch close and device control. Every command answers with a PAX-shaped **request record**: `result.status` (`approved` | `declined`) plus `amount` / `card` / `processor` — never the terminal's raw response."}],"x-tagGroups":[{"name":"MERCHANT","tags":["Account","Transactions","Employees","Terminals","Orders","Workorders"]},{"name":"SEMI INTEGRATION","tags":["ECR Commands"]}],"servers":[{"url":"https://pax.posvelocity.com","description":"Production"}]}