# PalomaPOS Platform API v1 — integration guide

This guide explains how to build an integration with the PalomaPOS Platform API: how an app gets
connected to a business, how to read its data and keep a local copy in sync, and how to create
orders that the POS accepts as they are. The machine-readable contract is
[`openapi.json`](openapi.json) (OpenAPI 3.1); the same description rendered as a page is
[`docs`](docs). When this guide and the OpenAPI description disagree, the OpenAPI description wins —
please report the difference.

## 1. What the API is

A REST API over HTTPS with JSON bodies. An app acts on behalf of **one business account** with the
**owner's consent** (OAuth 2.1 authorization code with PKCE). Every app gets the same rules: there
are no private endpoints, keys or permissions for individual integrators.

There are two ways to get a token for the **same** endpoints:

| | OAuth app (§2–§3) | Personal integration key (§3a) |
|---|---|---|
| Who creates it | you, in the dashboard: **Settings → Developers** — keys at once (§2) | the business owner, in the dashboard |
| Works for | any business whose owner consents | only the owner's own account |
| Getting the token | authorization code + PKCE, refresh every hour | copy the `ppik_…` key once |
| Typical use | a service for many businesses | the business's own developer, 1C, scripts, no-code tools |

Requests, scopes, errors, limits and idempotency are identical; code written for one works with the
other.

One API serves two markets. Each market has its own host, its own app registry and its own accounts:

| Market | Brand | API base URL | OAuth endpoints |
|---|---|---|---|
| USA | PalomaPOS | `https://alfaposapi.palomapos.com/platform/v1` | `https://alfaposapi.palomapos.com/platform/oauth/{authorize,token,revoke}` |
| CIS | Paloma365 | `https://alfaposapi.paloma365.com/platform/v1` | `https://alfaposapi.paloma365.com/platform/oauth/{authorize,token,revoke}` |

An app that serves businesses in both markets is registered twice and gets two different
`client_id`/`client_secret` pairs. Tokens of one market are not accepted by the other.

Public documentation on each host (no token needed):

- `GET /platform/llms.txt` — short index for AI tools, with absolute links for that host;
- `GET /platform/v1/openapi.json` — the OpenAPI description;
- `GET /platform/v1/guide.md` — this guide;
- `GET /platform/v1/docs` — the API reference page.

**Sandbox, then production — all self-service.** Nobody at PalomaPOS has to do anything for you:
you register, create your app and get your keys yourself, first on the sandbox, then on the market.
The step-by-step start page with every link is `https://sandbox-api.paloma365.com/platform/`
(each market host serves its own at `/platform/`).

| | Sandbox | Production |
|---|---|---|
| API host | `https://sandbox-api.paloma365.com/platform` (API base `https://sandbox-api.paloma365.com/platform/v1`) | the market host above |
| Dashboard | `https://sandbox.paloma365.com` | `https://dashboard.paloma365.com` (CIS), `https://dashboard.palomapos.com` (USA) |
| Your account | register a free **test business** at `https://sandbox.paloma365.com/register` — it is your developer account and your test restaurant at once | your business account on that market (register one if you have none) |
| Your app and keys | dashboard → **Settings → Developers** (§2): `client_id`, `client_secret`, and `ppss_…` for payment providers, at once | the same, in the market dashboard — **separate keys** |
| Data and money | test businesses and sample data only; payment sessions carry `"test": true` | real businesses, real money |

The sandbox runs the same code and contract as production, has no SLA (it runs the newest code and
may restart) and its own app registry: keys of one host never work on another. Payment providers
test end to end on their own with sandbox-only endpoints and see a readiness report next to their
app (§9.7); go live once it says `ready: true`.

**Contact.** `support@palomapos.com` (USA) or `info@paloma365.com` (CIS) — for questions about the
API and to ask for a **review**, which lists a payment provider for every business of the market
(§2). You do not need the team to register an app, get keys or a test account. On any host,
`openapi.json` names that host as its server, so a client generated from it talks to the host it was
generated from.

## 2. Registering your app (self-service)

Sign in to the dashboard of the host (§1) as the **account owner**, open **Settings → Developers**
(«Настройки → Разработчикам» in the Russian interface; `/settings/developer-apps`) and create an app. No review and no e-mail to anyone: the keys are shown
right away. Employees and support staff signed in to the account cannot manage apps.

| Field | Rule |
|---|---|
| App key | 2–40 characters: lowercase latin letters, digits, `_`, `-`, starting with a letter; unique on the host. It names your app as the source of the orders it creates and **cannot be changed** |
| App name, developer name, description, logo URL | shown to the owner on the consent screen and in the list of connected apps |
| Privacy policy URL and terms URL | required, `https://` |
| Redirect URIs | `https://` only; compared as exact strings (no wildcards, no prefix matching); up to 2000 characters each |
| Scopes | the maximum set the app may ask for (see §4); the owner consents to what the app requests within it |

You receive at once:

- `client_id` — `ppc_` followed by 32 hex characters;
- `client_secret` — starts with `ppcs_`, shown **once**; the server stores only its hash. Keep it on
  your server only. A lost secret cannot be recovered — issue a new one in the console, which
  invalidates the old secret immediately.

In the console you can later change every field except the key, issue a new secret, make the app a
payment provider (§9.6) and switch it off and on (a switched-off app's tokens stop working, as after
the owner disconnects it). Up to 10 apps per account.

**Review.** Your app works for any business whose owner connects it — without a review. The consent
screen shows the owner the domain your redirect URI returns to and says that the platform team has
not reviewed the app. A **payment provider** appears in a business's list of payment providers once
its owner has connected your app (send owners the authorization link of §3 from your site); to be
listed for **every** business of the market, ask the team for a review (contact in §1). Changing the
app name, developer name, description or logo removes a reviewed app from that list until it is
reviewed again.

The team can suspend an app (for example after complaints); every token of a suspended app stops
working on its next request, and you cannot switch it back on yourself.

## 3. Connecting a business: OAuth 2.1 authorization code + PKCE

The app is a confidential client. PKCE with `S256` is mandatory. Only the **account owner** can
approve a connection; employees and support staff get a page asking them to contact the owner.

### Step 1 — send the owner to the authorization endpoint

Create a random `code_verifier` (43–128 characters of `[A-Za-z0-9-._~]`) and its
`code_challenge = BASE64URL(SHA-256(code_verifier))` (43 characters, no padding), plus a random
`state` (up to 512 characters). Redirect the owner's browser to:

```
GET https://alfaposapi.palomapos.com/platform/oauth/authorize
    ?response_type=code
    &client_id=ppc_0123456789abcdef0123456789abcdef
    &redirect_uri=https%3A%2F%2Fapp.example.com%2Foauth%2Fcallback
    &scope=account%3Aread%20catalog%3Aread%20orders%3Aread%20orders%3Awrite%20offline_access
    &state=5b1f0c7e9a
    &code_challenge=E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM
    &code_challenge_method=S256
```

- If the owner is not signed in to the market's dashboard, the page asks them to sign in and continue.
- The consent screen shows the app, its developer, the business and the requested permissions in
  plain words. It is in English on the USA host and in Russian on the CIS host.
- Granted scopes = requested scopes ∩ the app's registered ceiling. Unknown scopes, or nothing
  grantable besides `offline_access`, end with `error=invalid_scope`.

### Step 2 — receive the code

On approval the browser returns to your redirect URI:

```
https://app.example.com/oauth/callback?code=ppac_...&state=5b1f0c7e9a
```

Check that `state` matches. Errors come back the same way (`error`, `error_description`, `state`):
`access_denied` (the owner declined), `invalid_request` (missing PKCE), `invalid_scope`,
`unsupported_response_type`. An unknown or suspended `client_id`, a `redirect_uri` that is not
registered, or a malformed `state` never redirect: the page shows the error instead.

The code is single-use and expires after **10 minutes**. Presenting a code twice revokes the tokens
that were issued for it.

### Step 3 — exchange the code for tokens

```bash
curl -s https://alfaposapi.palomapos.com/platform/oauth/token \
  -u 'ppc_0123456789abcdef0123456789abcdef:ppcs_YOUR_SECRET' \
  -d grant_type=authorization_code \
  -d code=ppac_... \
  -d redirect_uri=https://app.example.com/oauth/callback \
  -d code_verifier=YOUR_CODE_VERIFIER
```

Client authentication: `client_secret_basic` (HTTP Basic, as above) **or** `client_secret_post`
(`client_id` and `client_secret` in the form body) — exactly one of them. The body is
`application/x-www-form-urlencoded`.

```json
{
  "access_token": "ppat_...",
  "token_type": "Bearer",
  "expires_in": 3600,
  "refresh_token": "pprt_...",
  "scope": "account:read catalog:read orders:read orders:write offline_access",
  "account_id": "5120"
}
```

- `refresh_token` is present only when `offline_access` was granted.
- `account_id` identifies the connected business (a string). One app has at most one connection
  per account; a new consent replaces the scopes of the previous one and ends its old tokens.
- OAuth errors use RFC 6749 JSON: `{"error": "invalid_grant", "error_description": "…", "request_id": "…"}`.

### Step 4 — call the API

```bash
curl -s https://alfaposapi.palomapos.com/platform/v1/me \
  -H 'Authorization: Bearer ppat_...'
```

Only `ppat_` access tokens and `ppik_` personal integration keys (§3a) are accepted. Tokens of other
PalomaPOS APIs are rejected with 401.

### Token lifetimes, refresh and revocation

| Token | Prefix | Lifetime |
|---|---|---|
| Authorization code | `ppac_` | 10 minutes, single use |
| Access token | `ppat_` | 1 hour |
| Refresh token | `pprt_` | 90 days from issue, single use (only with `offline_access`) |

Refresh:

```bash
curl -s https://alfaposapi.palomapos.com/platform/oauth/token \
  -u 'ppc_...:ppcs_...' \
  -d grant_type=refresh_token \
  -d refresh_token=pprt_...
```

- **Rotation.** Every refresh returns a new access token *and* a new refresh token; the old refresh
  token is spent. Always store the new one.
- **Grace window.** Presenting the same spent refresh token again within **30 seconds** returns the
  same new pair — timeouts, retries and two workers refreshing at once are safe. If the app has
  already refreshed further in that window, the retry is refused without revoking anything.
- **Reuse detection.** Presenting a spent refresh token later is treated as theft: the whole
  connection (the grant and all its tokens) is revoked, and the owner has to connect the app again.
- **Idle expiry.** Because each refresh token lives 90 days from issue, access continues as long as
  the app refreshes at least once every 90 days. A `scope` sent with a refresh is ignored: scopes
  change only through a new consent.
- A token stops working when it expires, when the owner disconnects the app in the dashboard, when
  the app revokes it, when the app is suspended, when the owner consents again, or when the business
  account is deleted or archived. The API then answers `401 invalid_token` with
  `WWW-Authenticate: Bearer error="invalid_token"`.

Revocation ([RFC 7009](https://www.rfc-editor.org/rfc/rfc7009)), authenticated like the token endpoint:

```bash
curl -s https://alfaposapi.palomapos.com/platform/oauth/revoke \
  -u 'ppc_...:ppcs_...' -d token=pprt_...
```

Revoking a **refresh** token disconnects the app from the account (the grant and all its tokens).
Revoking an **access** token revokes only that token. The answer is `200` for any token, including
unknown ones.

## 3a. Personal integration key: no registration, no OAuth

A business owner can create a key for **their own account** in the dashboard: **Settings → Connected
apps → Create integration**. They name it, tick the scopes (any of §4 except `offline_access`) and
copy the key. Nothing has to be registered with the PalomaPOS team.

```bash
curl -s https://alfaposapi.palomapos.com/platform/v1/me \
  -H 'Authorization: Bearer ppik_...'
```

- The key starts with `ppik_` and is shown **once**; the server stores only its hash. Keep it
  server-side, like a password.
- It works only for the account it was created in, only with the scopes chosen at creation (a missing
  one → `403 insufficient_scope`), and on the market host where it was created.
- It **does not expire** and is never refreshed. It stops working when the owner deletes it on the
  same page (then every request gets `401 invalid_token`) or when the account is deleted or archived.
  To change the scopes, create a new key and delete the old one.
- `GET /v1/me` answers with `app.kind = "personal"` and `token_expires_at = null`.
- Rate limits (§8), idempotency (§7) and the audit trail are the same as for apps. Orders created
  with a key are marked with the integration's name as their source.
- Only the account owner can create or delete keys; up to 20 active keys per account.

## 4. Scopes

| Scope | Grants |
|---|---|
| `account:read` | business profile, locations with hours and fulfillment modes, taxes, payment types |
| `catalog:read` | categories, items, modifier groups, menus, stop lists |
| `orders:read` | all orders of the account (any source); guest name, phone and address only with `customers:read` or in the app's own orders |
| `orders:write` | quote, create and cancel the app's own orders |
| `payments:write` | mark the app's own order as paid outside the POS |
| `payments:process` | payment providers only: receive payment sessions for online orders and resolve them (section 9.6) |
| `reports:read` | daily sales report |
| `customers:read` | customer base and guest personal data in orders |
| `staff:read` | employees and shifts |
| `offline_access` | issue a refresh token |

`GET /v1/me` needs no particular scope. A call without the needed scope gets
`403 insufficient_scope` with `details.required_scope` and
`WWW-Authenticate: Bearer error="insufficient_scope", scope="…"`.

## 5. Conventions

- **Transport.** HTTPS, JSON (`application/json; charset=utf-8`), `Authorization: Bearer ppat_…`.
- **Ids** are always strings (`"id": "10432"`), including numeric ones. Store them as strings.
- **Money** is a decimal string with a dot and exactly two decimals (`"12.50"`, `"990.00"`), next to a
  `currency` field with the ISO 4217 code of the account (`"USD"`, `"KZT"`, …). Never parse money as a
  float; convert to integer minor units.
- **Money step.** Each location rounds line totals to its POS step: `money_step` is `"0.01"`, `"0.10"`
  or `"1.00"` (see `GET /v1/locations` and `GET /v1/taxes`). US accounts use cents; CIS accounts
  usually round to whole units (`"1.00"`), so amounts still carry two decimals but end in `.00`.
- **Times** are ISO 8601 with an offset (`2026-09-29T18:30:00-04:00`). Times of things that happen at
  a location — orders, shifts (`opened_at`, `closed_at`), the stop list (`until`, `as_of`) — are in
  the time zone of that location (or the account's when the location has none); other times are in
  the account's time zone. Every value carries its offset, so the instant is always exact; group by
  the **local date of the value** (as written), not by the date in your own time zone. Time query parameters (`created_after`, `ready_at`) must carry an offset
  (RFC 3339). Dates (`from`, `to` of the sales report) are `YYYY-MM-DD`; a sales day is the local
  date on which the POS closed the order.
- **Errors** share one envelope:

  ```json
  {"error": {"code": "insufficient_scope", "message": "…", "retryable": false, "request_id": "req_…", "details": {"required_scope": "orders:read"}}}
  ```

  `code` is stable and machine-readable; `message` is for humans; `details` is always an object;
  `retryable: true` means the same request may succeed later (respect `Retry-After`).
- **Tracing.** Every response has `X-Request-Id` (quote it when you contact support) and
  `PalomaPOS-Api-Version: 1`.
- **Unknown fields.** Ignore fields you do not know: v1 only ever adds fields and values.

## 6. Lists, pagination and incremental sync

Lists take `limit` (1–200, default 100) and return `next_cursor` (null on the last page). Pass
`cursor=<next_cursor>` to get the next page. Cursors are opaque, bound to your connection and to the
list (and to the filters of the first page), and they never expire within a pass. **Send the same
filters (`created_after`, `location_id`) with every page and with every later `since` call** — a
cursor or `sync_token` used with different filters is refused with `400 invalid_request`.

Lists that support incremental sync — `categories`, `items`, `modifier-groups`, `menus`, `orders`,
`customers`, `employees`, `shifts` — return a `sync_token` on the **last page** of a pass. Later,
`?since=<sync_token>` returns only what changed after that pass, **including deletions**: catalog
lists return `{"id": "…", "deleted": true, "updated_at": "…"}` tombstones; orders come back with
`"deleted": true`. The last page of a `since` pass carries the next `sync_token`.

- A pass sees one consistent window that ends 5 seconds before it started; anything changed during
  the pass arrives in the next delta. Upsert by `id` — an object changed while you page may come
  twice.
- A page may hold fewer than `limit` objects while `next_cursor` is set: keep paging until it is null.
- A `sync_token` older than **20 days** is refused with `410 sync_token_expired`: run a full pass again.
- Run a full pass at least daily as a reconciliation (the orders list notes which edits it cannot
  see incrementally).
- `locations` and `payment-types` page with `cursor` only (no `since`); re-read them periodically.

Orders (`GET /v1/orders`) cover every order of the account, from any source. A full pass returns
orders created at most **400 days** ago; `created_after` (RFC 3339 with offset) narrows it and may not
be earlier than that. Older orders are available by id. `location_id` filters by location. The
sales report (`GET /v1/reports/sales-daily`) takes `from`/`to` dates spanning at most **92 days**.

What a `since` pass of orders does not see — run the daily full pass for these:

- an edit that touches only the lines of an order (`lines`) and nothing in its header;
- an order the POS deleted and then purged before your next delta.

**Which numbers to use.** For revenue and sales reporting use `GET /v1/reports/sales-daily`: it is
what the back-office sales report shows. Summing `total` of orders gives a different number on
purpose — refunds are separate documents there, a sale counts on the day the POS closed it, and the
tip is not part of `total`. Use orders for what happened to each order; use the report for money per
day, location and item.

Initial 90-day backfill, then incremental updates:

```python
def sync_orders(api, store):
    # The filters are part of the list: keep them fixed for the whole life of the sync_token and send
    # them with EVERY request — the first page, every cursor page and every later `since` call.
    filters = store.get("orders_filters")
    if filters is None:
        filters = {"created_after": iso(now() - days(90))}   # + "location_id": "…" to follow one location
        store.set("orders_filters", filters)
    token = store.get("orders_sync_token")
    params = {**filters, "limit": 200}
    if token is not None:
        params["since"] = token
    while True:
        page = api.get("/v1/orders", params)
        # 410 sync_token_expired → delete orders_sync_token AND orders_filters, start a full pass
        for order in page["data"]:
            store.upsert_order(order)                 # key: order["id"]; order["deleted"] may be true
        if page["next_cursor"] is None:
            store.set("orders_sync_token", page["sync_token"])
            return
        params = {**filters, "cursor": page["next_cursor"], "limit": 200}

# poll every 15–30 s while the app has open orders, less often otherwise;
# repeat for categories, items, modifier-groups, menus, customers, employees, shifts.
```

## 7. Idempotency

`POST /v1/orders`, `POST /v1/orders/{id}/cancel` and `POST /v1/orders/{id}/payments` require an
`Idempotency-Key` header: 1–64 printable ASCII characters, case-sensitive, scoped to your connection,
remembered for **24 hours**. Use a new random key (for example a UUID) per logical operation and
reuse it only to retry that operation.

- Same key, same request (method, path, query and JSON body; key order and whitespace do not matter)
  → the stored response is returned with `Idempotent-Replayed: true`; nothing is done twice.
- Same key, different request → `409 idempotency_key_reused`.
- Same key while the first request is still running → `409 idempotency_in_progress`
  (`retryable: true`, `Retry-After: 1`).
- Stored: successful answers and business refusals (403, 404, 409, 410, 422). Not stored: 400, 401,
  429 and 5xx — the key is released and the same request can be retried with the same key.
- Order creation also finds an already written order by the key after a crash, so a retry within
  24 hours never creates a second order. After 24 hours a key is new again.

`POST /v1/orders/quote` needs no key.

## 8. Rate limits

Per connection (app + account): **600 reads** (GET) and **60 writes** (every other method, including
`POST /v1/orders/quote`) per minute. Above that the API answers `429 rate_limited` with `Retry-After`
(seconds). Wait at least `Retry-After`, then retry with exponential backoff and jitter. `500` and
`503` are retryable the same way; `503` also carries `Retry-After`.

## 9. Orders

The server — not the app — sets prices, taxes, totals, the order number and the order source. Money
sent by the app is only compared, never used.

### 9.1 Quote

`POST /v1/orders/quote` (`orders:write`) prices a cart without creating anything, with the same
function and checks as creating an order:

```bash
curl -s https://alfaposapi.palomapos.com/platform/v1/orders/quote \
  -H 'Authorization: Bearer ppat_...' -H 'Content-Type: application/json' \
  -d '{"location_id":"3","fulfillment":{"type":"pickup","asap":true},
       "lines":[{"item_id":"501","quantity":"2","modifiers":[{"modifier_id":"901"}]}],
       "tip":{"amount":"3.00"}}'
```

The answer has per-line prices and taxes, `subtotal`, `tax_total`, `fees`, `delivery_fee`, `tip`,
`total` (what a payment must equal, without the tip) and `total_with_tip`. Taxes are computed with
the POS v2 formula: integer minor units, one tax per line, rounded per line, half away from zero.

### 9.2 Create

`POST /v1/orders` (`orders:write`, `Idempotency-Key`) creates a pickup or delivery order:

```json
{
  "location_id": "3",
  "fulfillment": {"type": "pickup", "ready_at": "2026-09-29T18:30:00-04:00"},
  "customer": {"name": "Sam Guest", "phone": "+13055550100"},
  "note": "Ring the bell",
  "lines": [{"item_id": "501", "quantity": "2", "modifiers": [{"modifier_id": "901"}], "note": "No onions"}],
  "tip": {"amount": "3.00"},
  "external_id": "ord-8812",
  "expected_total": "35.65"
}
```

- `fulfillment.type` is `pickup` or `delivery`; set exactly one of `asap: true` and `ready_at` (with
  an offset; not in the past, at most 7 days ahead). Delivery needs `delivery_address`
  (`formatted` or `line1`); the delivery fee comes from the location's settings.
- `customer.phone` is E.164; `customer.name` up to 100 characters; notes up to 200; `external_id` (your
  own reference) up to 50.
- `lines`: 1–100 lines, `quantity` a decimal string (fractions only for items sold by weight),
  at most 500 check rows with modifiers. Each modifier is added once per line (`quantity` 1).
- `tip.amount` may not exceed the subtotal.
- `expected_total` — the total you already told the guest (without the tip). If the server's total
  differs, the call fails with `409 total_mismatch` (`details.expected`, `details.actual`,
  `details.quote`) and nothing is created.
- **Every order is created priced.** A cart the server cannot price exactly is refused with
  `422 quote_unsupported` — the same answer as `/orders/quote` — and nothing is written. The POS takes
  the lines, taxes and total of an API order as written and never re-prices it.
- Other refusals: `422 item_unavailable` (unknown item, not sold for this mode, stop list, not on the
  location's pinned menu), `422 fulfillment_disabled` (mode off, below the minimum order amount,
  `pos_not_supported`), `503 temporarily_unavailable` (the POS order counter is busy — retry with the
  same key).

The answer is the created `Order` with `number` — the number the cashier sees on the POS. The POS is
notified immediately.

### 9.3 Track

Order status changes on the POS. Poll `GET /v1/orders?since=…` (§6): every 15–30 seconds while the
app has open orders. Statuses: `new`, `accepted`, `in_progress`, `ready`, `out_for_delivery`,
`picked_up`, `delivered`, `cancelled`; orders without a pickup/delivery row (dine-in, counter) are
`in_progress` until the check is closed and `completed` after. `GET /v1/orders/{id}` reads one order.

### 9.4 Cancel

`POST /v1/orders/{id}/cancel` (`orders:write`, `Idempotency-Key`, body `{"reason": "…"}`) cancels
**the app's own** order while its status is `new` or `accepted` and the check is open. Otherwise
`409 invalid_state`; another source's order is `403 not_owner`. Nothing is deleted: the status
becomes `cancelled`. Cancelling an order paid through this API records a refund row; the money itself
is returned by your payment provider. Cancelling a cancelled order returns it unchanged.

### 9.5 Mark as paid

`POST /v1/orders/{id}/payments` (`payments:write`, `Idempotency-Key`) records a payment taken
outside the POS for the app's own open order:

```json
{"amount": "35.65", "tip_amount": "3.00", "provider": "stripe", "reference": "pi_3Nx0example"}
```

`amount` must equal the amount still due (the order total without the tip, minus what was already
collected); otherwise `409 total_mismatch`. `409 invalid_state` covers `order_closed`,
`order_cancelled` and `already_paid`. Recording the same `reference` again returns the order
unchanged. The order stays open: the cashier hands it over and closes it on the POS.

### 9.6 Payment providers: take payments for online orders

A payment system (a bank, an acquirer, a gateway such as Kaspi Pay through ApiPay) can plug into
the QR menu online ordering of any business on its own. You do not create orders and you never
see a card: PalomaPOS sends you a **payment session** for a guest's order, you show the guest how
to pay, and you tell PalomaPOS the outcome. Operations are tagged `Payments` in `openapi.json`
(phase `payments`, live where the market has switched payment providers on).

**1. Become a payment provider.** Register your app (section 2) with `payments:process` and
`offline_access` in its scopes, then in the console press **Make it a payment provider** and enter:

- `session_url` — the `https://` address (port 443, public DNS name) PalomaPOS calls with payment
  sessions;
- the payment methods you take (`kaspi_qr`, `card`, …), the currencies (`KZT`, `USD`), and whether
  you issue the fiscal receipt yourself.

You receive the signing secret `ppss_…` at once (shown once; issue a new one in the console at any
time) in addition to `client_id`/`client_secret`.

**2. A business connects you.** The owner approves your app through OAuth (section 3) with
`payments:process offline_access`; keep the `account_id` from the token response — every session
names it — and map it to the merchant account on your side. Then the owner picks you in the
dashboard for online orders (all locations or one location). A location that already uses a
built-in online payment keeps it until the owner switches it off. The owner finds you in that list
after connecting your app through your link; reviewed providers are listed for every business (§2).

**3. PalomaPOS sends a payment session** when the guest presses "Pay":

```http
POST {session_url}
Content-Type: application/json
Idempotency-Key: pps_3f9a1c2b7d4e6f8091a2b3c4d5e6f708
PalomaPOS-Signature: t=1760770802,v1=5f2c…

{"type": "payment_session.create", "api_version": "1", "id": "pps_3f9a1c2b7d4e6f8091a2b3c4d5e6f708",
 "created_at": "2026-10-18T07:00:02Z", "expires_at": "2026-10-18T07:15:02Z", "test": false,
 "account_id": "12345", "location_id": "3", "order": {"id": "10432", "number": "42", "type": "pickup"},
 "amount": "5000.00", "currency": "KZT", "description": "Заказ №42"}
```

Verify the signature before anything else: `v1` = HMAC-SHA256(`ppss_…` secret, `"<t>.<raw body>"`),
hex; compare in constant time; reject `t` older than 5 minutes. The amount is final — it is the total
PalomaPOS computed for the order. Do not take money on this call: create your invoice and answer
**within 8 seconds** with `200`/`201`:

```json
{"reference": "inv_88213",
 "action": {"type": "qr", "qr_payload": "https://pay.kaspi.kz/pay/abc123",
            "deep_links": [{"label": "Kaspi", "url": "https://kaspi.kz/pay/abc123"}]},
 "expires_at": "2026-10-18T07:10:02Z"}
```

`action.type` is `qr` (the guest scans a QR code drawn from `qr_payload`; `url` and up to four
`deep_links` let a guest on the same phone pay with one tap) or `redirect` (the guest is sent to
`url`). Links are `https` only. `expires_at` is clamped to 2–30 minutes (default 15). Any other answer
— timeout, non-2xx, a redirect, a body off contract — fails the session and the guest is told online
payment is unavailable. The same session `id` may arrive again (a retry): return the same invoice.

**4. Report the outcome** from your server with the business's access token (`Idempotency-Key`
required):

```http
POST /platform/v1/payment-sessions/pps_3f9a…/resolve     {"reference": "inv_88213", "amount": "5000.00"}
POST /platform/v1/payment-sessions/pps_3f9a…/reject      {"reason": "declined"}     # declined | cancelled | expired | error
GET  /platform/v1/payment-sessions/pps_3f9a…
```

Resolve only when the money is final. `amount` must equal the session amount (`409 total_mismatch`
otherwise). PalomaPOS records the payment on the order with your app's payment type and closes it;
the POS shows it as paid. The response is the session with `status: paid` and `settlement`:

- `applied` — done;
- `not_applied` with `settlement_reason` (`order_cancelled`, `order_closed`, `amount_changed`,
  `order_not_found`) — the order could not take the payment anymore. The money is recorded on the
  session and the owner sees it in the dashboard; **refund the guest**.

A guest who started paying before `expires_at` may finish later: resolving an `expired` session is
accepted. A session can also be **superseded** (`status: failed`, `failure_code: superseded`): the
guest got a new one because the order total or the chosen provider changed, or the order was already
paid by another session. Cancel your invoice for it if you can — but if the guest paid it anyway,
resolve it: the money is recorded (most likely `not_applied`) and you refund it. Retrying a resolve
with the same `reference` returns the same session; a paid session with a different `reference` is
`409 invalid_state`. A rejected session cannot be paid; the guest starts a new one. Rejecting a
session whose payment PalomaPOS has already recorded is refused with `409 invalid_state`
(`details.reason: already_paid`) — do not refund it. Another app's session, or an unknown id, is `404`.

**Keep your access alive.** PalomaPOS sends sessions only while your app holds a live token for the
account (refresh tokens expire 90 days after issue — refresh regularly); otherwise the owner sees that
your access has expired and guests are not offered your payment.

**Fiscal receipt.** PalomaPOS does not print a fiscal receipt for these payments. If your service
issues one, say so at registration.

**Test** on the sandbox host `sandbox-api.paloma365.com` (§9.7): sessions there carry `"test": true` — never
take real money for them.

### 9.7 Sandbox and going live (payment providers)

You build and test the whole flow on your own: the sandbox sends you test payment sessions on demand
and grades your endpoint. No QR menu, guest or real order is involved, and nothing is written to the
business's orders.

**Set up — on your own, in minutes** (§1, §2): register a free test business at
`https://sandbox.paloma365.com/register` and sign in as its owner; in **Settings → Developers**
create your app with `payments:process offline_access` and make it a payment provider with your
sandbox `session_url`. You get `client_id`, `client_secret` and the signing secret `ppss_…` on the
spot.

**1. Connect** your app to your test business: run OAuth (§3) against
`https://sandbox-api.paloma365.com/platform/oauth/authorize` with `payments:process offline_access` and approve
as yourself — you are the owner of the test business. Keep the tokens as in production.

**2. Check your signature code** against this example before the first request:

```
secret  ppss_example_secret_for_docs_only
t       1760770802
body    {"type":"payment_session.create","id":"pps_0123456789abcdef0123456789abcdef","amount":"1000.00","currency":"KZT"}
header  PalomaPOS-Signature: t=1760770802,v1=d67bdbe1c441870ec06d0811901c8bdf31e9aaa7520482a62032276a0fef6adb
```

`v1` = hex HMAC-SHA256 of `"<t>.<body>"` with the secret — the raw body exactly as received.

**3. Ask for a test session** — it arrives at your `session_url` right away:

```http
POST https://sandbox-api.paloma365.com/platform/v1/sandbox/payment-sessions
Authorization: Bearer ppat_…
Idempotency-Key: test-0001
Content-Type: application/json

{"amount": "1000.00", "settlement": "applied"}
```

The answer has the session (`pending`, or `failed` if your endpoint did not answer per contract), the
test order, **the exact request we sent** (`delivery.request` — re-compute the signature from its
`body` if yours does not match) and three checks of your endpoint (`delivery.checks`):

| Check | Passes when |
|---|---|
| `signature` | the same request with an **invalid** signature, sent first, got `401` (any 4xx) |
| `response` | your answer to the real request is a 2xx with `reference`, `action`, `expires_at` within 8 s |
| `retry` | the same session sent again got the same `reference` |

Each failed check has a `detail` saying what is wrong. `"checks": false` sends only the real request.
If the first request gets no answer at all, the other two are not sent. One test session of your app is
delivered at a time (`503 temporarily_unavailable` meanwhile — retry in a few seconds), at most 500 per
24 hours (`429`).

**4. Finish the session** exactly as in production — `POST /v1/payment-sessions/{id}/resolve` or
`…/reject` (§9.6 step 4). The body field `settlement` of step 3 decides what resolving does:

| `settlement` | Resolve answers | What your service does |
|---|---|---|
| `applied` (default) | `paid`, `settlement: applied` | done |
| `order_cancelled`, `order_closed`, `amount_changed`, `order_not_found` | `paid`, `settlement: not_applied` with that `settlement_reason` | refund the guest |

To test a **superseded** session, send `{"replaces": "pps_…", "amount": "1200.00"}` with the id of
your waiting test session and a **new** amount (a POS replaces a session only when the total
changed): it becomes `failed` / `superseded`, and a new session for the same test order arrives.
Resolving the superseded one afterwards is accepted with `not_applied` — `amount_changed`, or
`order_closed` once the new session is paid — and you refund the guest.

**5. Check readiness**: `GET https://sandbox-api.paloma365.com/platform/v1/sandbox/payment-readiness` lists
every check (`connection`, `signature`, `session_response`, `retry`, `resolve_applied`,
`resolve_not_applied`, `reject`) with `passed` / `failed` / `not_run`, what to do next (`next`, `how`)
and `ready: true` once all pass. `signature`, `session_response` and `retry` come from your last test
session with checks on; `signature` counts only when the valid request of that session passed too.
The same report is shown next to your app in **Settings → Developers** of the sandbox dashboard.

**6. Go live** once the report says `ready: true`. In the dashboard of the market (§1), create the app
again and make it a payment provider with your production `session_url`: **production**
`client_id`, `client_secret` and `ppss_…` are issued at once. Switch the base URL and keys; businesses
connect your app through your link and choose you in their dashboard. To be listed for every
business of the market, ask for a review (§2). Sandbox endpoints do not exist on market hosts (`404`).

## 10. Market differences

| | USA (PalomaPOS) | CIS (Paloma365) |
|---|---|---|
| Host | `alfaposapi.palomapos.com` | `alfaposapi.paloma365.com` |
| Currency | USD | the account's currency (KZT and others) |
| Taxes | sales tax usually added on top (`included: false`) | VAT usually included in prices (`included: true`) |
| Money step | usually `"0.01"` | usually `"1.00"` (whole units) |
| Order numbers | a counter per location | a counter per POS shift (per location when no shift is open) |
| Consent screen | English | Russian |

Where an account sets its own rules (a daily reset of order numbers, a different rounding step), the
account's settings win. Read the rules of a business from `GET /v1/taxes` and `GET /v1/locations`
rather than assuming them from the market. An order `number` is for people; identify orders by `id`.

## 11. Limits of v1

- **No webhooks.** Poll with `since`. Webhooks are planned; polling stays supported after they arrive.
- **Modifier quantity is 1** per line until the POS reads modifier quantities.
- **Order writes need a POS v2 account** and the market having order writes switched on. Check
  `capabilities.order_writes` in `GET /v1/account` before offering ordering to a business:
  `{"available": false, "reason": "pos_not_supported"}` — the business runs the legacy Windows POS
  and cannot receive API orders (writes answer `422 fulfillment_disabled`, `details.reason =
  pos_not_supported`); `"not_launched"` — order writes are not live on this market yet (the write
  operations answer `404`). Reading works for every account.
- **Carts that cannot be priced are refused** (`422 quote_unsupported`, `details.reason`): customer
  discounts the POS would apply, promo codes and promotions, dual pricing / card surcharge, automatic
  fees for the order type, automatic service charge, an ambiguous default tax, time prices, combos,
  non-retail price types, menu prices that differ from the catalog while no menu is pinned, open
  prices.
- One tax per line, as on the POS.

## 12. Error codes

| HTTP | `code` | Retry | When |
|---|---|---|---|
| 400 | `invalid_request` | no | body or parameters invalid (`details.fields`) |
| 401 | `invalid_token` | no | token missing, expired, revoked, of another format; account no longer active |
| 403 | `insufficient_scope` | no | the token lacks the scope (`details.required_scope`) |
| 403 | `not_owner` | no | the order was created by another app or on the POS |
| 404 | `not_found` | no | no such object in this account; unknown or switched-off route |
| 409 | `idempotency_key_reused` | no | same key, different request |
| 409 | `idempotency_in_progress` | yes (`Retry-After`) | same key, first request still running |
| 409 | `total_mismatch` | no | `expected_total` or payment amount differs from the server's |
| 409 | `invalid_state` | no | the order cannot be cancelled or paid in its state (`details.reason`) |
| 409 | `provider_not_ready` | no | sandbox: your app cannot take payments for this account yet (`details.reason`, §9.7) |
| 410 | `sync_token_expired` | no | the `sync_token` is older than 20 days: run a full pass |
| 422 | `item_unavailable` | no | item unknown, not sold for the mode, in the stop list, not on the menu |
| 422 | `location_closed` | no | reserved: defined in v1, not returned by any operation yet |
| 422 | `fulfillment_disabled` | no | mode off, below the minimum amount, `pos_not_supported` |
| 422 | `quote_unsupported` | no | the server cannot price the cart exactly (`details.reason`) |
| 429 | `rate_limited` | yes (`Retry-After`) | rate limit of the connection exceeded |
| 500 | `internal_error` | yes | unexpected error (quote `request_id`) |
| 503 | `temporarily_unavailable` | yes (`Retry-After`) | a dependency is busy or unavailable |
| 503 | `not_configured` | no | sandbox: payment providers are not configured on this host |
| 503 | `account_not_ready` | no | this business account's database has not been upgraded for the API yet (for example, orders cannot be read). Do not retry in a loop: tell the owner or the market's team (§1) with the `request_id`. After the upgrade the same request works — also with the same `Idempotency-Key`, which is not consumed by this error |

## 13. Versioning

The version is in the path (`/v1`). Within v1 changes are **additive only**: new endpoints, new
optional request fields, new response fields, new enum values and new error codes. Build clients that
ignore unknown fields and treat unknown enum values and error codes by their HTTP status. A breaking
change would ship as `/v2` next to `/v1`.

## 14. Instructions for AI coding agents

Follow this checklist to build an integration from this guide and `openapi.json`:

1. Read `openapi.json` (`/platform/v1/openapi.json` on the market's host); generate types from its
   schemas. Keep ids as strings and money as strings or integer minor units — never floats.
2. Pick the market host (§1). Store `client_id`/`client_secret` per market, server-side only.
   If the integration is for ONE business and its owner gave you a personal integration key
   (`ppik_…`, §3a), skip OAuth and refreshing entirely: send the key as the Bearer token (steps 3–4
   do not apply; on `401 invalid_token` ask the owner for a new key).
3. Implement OAuth exactly as §3: PKCE S256, random `state`, exact redirect URI, code exchange with
   client authentication, store `access_token`, `refresh_token`, `expires_in`, `account_id`.
4. Refresh before the access token expires; always persist the new refresh token; serialize refreshes
   per connection; on `invalid_grant` or `401 invalid_token`, mark the connection as disconnected and
   ask the owner to connect again.
5. Call `GET /v1/me` after connecting to confirm scopes and `account_id`, and `GET /v1/account` to
   read the business's currency, time zone and `capabilities` (offer ordering only when
   `capabilities.order_writes.available` is true).
6. Sync with full passes plus `since` deltas (§6): persist `sync_token` per list together with the
   filters of its first page and send those filters with every request, upsert by `id`, apply
   tombstones, restart on `410`, run a full pass daily. Take revenue from `reports/sales-daily`,
   not from summing orders.
7. Respect rate limits (§8): honour `Retry-After`, back off exponentially with jitter, retry only
   `retryable: true` errors.
8. For orders: quote first, show the guest the server's `total`, then create with `expected_total`
   and a fresh `Idempotency-Key`; retry transport failures with the same key; poll `since` for
   statuses.
9. Handle `422 quote_unsupported` and `pos_not_supported` as "this business cannot take this order
   through the API" — do not retry them unchanged.
10. Log `X-Request-Id` of failed calls; never log tokens or the client secret.
11. Build against the sandbox host first (§1). For a payment provider: implement §9.6, verify your
    signature code with the example of §9.7, then loop `POST /v1/sandbox/payment-sessions` →
    resolve / reject → `GET /v1/sandbox/payment-readiness` until `ready` is true, and only then create
    the production app in the market dashboard (§2). Registration and keys are self-service: a human
    with an owner login creates the app in **Settings → Developers**; you cannot do it through the API.
