# Data access

Apps read/write shop data through **the existing Open API** ([Open API docs](/developers/api/en)): same endpoints, response format, pagination and update rules. Code that already works with an API key only needs a different way to get credentials.

```ts
// Before: a static, full-access key that leaks easily when it sits in frontend code
fetch(`https://pos.pages.fm/api/v1/shops/${SHOP_ID}/orders?api_key=${STATIC_KEY}`);

// Inside an app: the SDK attaches the app token
await pos.api.get(`/shops/${pos.shop.id}/orders`);
```

## App token

POS issues an app token automatically while an extension runs; apps do not need, and should not have, a static API key.

| | Shop API Key | User API Key | **App token** |
|---|---|---|---|
| Issued by | Admin, long-lived | User, long-lived | POS, automatically, expires after 15 minutes |
| Identity | The shop's connection account | A real user | A real user + the app |
| Permissions | Full access (can be limited) | The user's permissions | **The user's permissions ∩ the app's `access`** |
| Shop scope | One shop | Every shop the user belongs to | Only the shop currently open |
| Used from | Servers | Servers, scripts | Inside the app in POS |

- The token is bound to one shop, one user, one app and the running version.
- The SDK sends the token in the `Authorization: Bearer <token>` header. `?api_key=<token>` is still accepted for compatibility with existing Open API code, but not recommended because URLs easily end up in logs.
- The token is revoked immediately when the app is disabled or removed, the admin changes `access`, or the user logs out or their session changes. Removing the user from the shop or changing their role takes effect on the next request.

## Effective permissions

```text
App token permissions = permissions of the current user ∩ the app's access (as approved by the admin)
```

**User permissions** apply exactly as when the user works in POS: role, per-warehouse permissions, CRM table/field permissions, department, working hours, allowed IPs, enabled modules and subscription.

**The app's `access`** narrows them further: allowed methods (only `GET` by default), warehouses, order sources, order statuses, order creation date, product categories/brands, hidden order fields. See [Manifest › access](/developers/apps/en/manifest#access).

Consequences:

- The same app shows more data to users with broader permissions than to users with narrower ones.
- A request that works with a Shop API Key may be rejected with an app token. That is the intended behavior.
- Apps should handle `403` instead of assuming every user has every permission.

### Always-blocked groups

These groups are only available from a logged-in session in the POS UI; every kind of API key and app token is rejected:

- Staff management: adding/editing staff, assigning roles.
- Linking Google accounts.
- API key management.
- App management and app token issuance.

## Allowed endpoints

- Only use endpoints listed in the [Open API docs](/developers/api/en). Internal endpoints used by the POS UI can change at any time and will be blocked for app tokens.
- Every path must belong to the current shop: `/shops/{shop_id}/...`.
- If data or an operation you need is missing, request it in the Open API instead of using internal endpoints.

Main endpoints for the resources that have extension points:

| Resource | Open API | Record id in the path |
|---|---|---|
| `order` | `GET`/`POST /shops/{shop_id}/orders`, `GET`/`PUT /shops/{shop_id}/orders/{id}` | `record.system_id` |
| `product` | `GET /shops/{shop_id}/products/variations`, `POST /shops/{shop_id}/products`, `GET`/`PUT /shops/{shop_id}/products/{id}` | `record.id` |
| `purchase` | `GET`/`POST /shops/{shop_id}/purchases`, `GET`/`PUT /shops/{shop_id}/purchases/{id}` | `record.id` |
| `export` | `GET`/`POST /shops/{shop_id}/export`, `GET`/`PUT /shops/{shop_id}/export/{id}` | `record.id` |
| `warehouse_transfer` | `GET /shops/{shop_id}/transfers`, `POST /shops/{shop_id}/transfers/multi`, `GET`/`PUT /shops/{shop_id}/transfers/{id}` | `record.id` |

## Context data

`pos.data` holds the minimum data POS is already showing (for example the id and status of the open order or purchase), so extensions do not have to call the API right away. It is already filtered by the app's `access`. Call the Open API for anything else.

## Rate limits

- Counted per **app + user + shop + endpoint**; all extensions of the same app share the quota: under 30 reads (`GET`) and under 100 writes per second.
- When exceeded: HTTP `429`. Retry later with an increasing delay (for example 1 s, 2 s, 4 s), at most 3 times.
- Do not call the API on every render. Batch requests, paginate, and use `AbortSignal` to cancel stale requests when the user changes filters.

## Audit

Every write made with an app token is recorded with the **real user**, the **app** and the **version**. Automatic fields such as creator and last editor are set to the current user.

## Error codes

The `code` column is the `error_code` field of the response body, which the SDK puts in `PosApiError.code`.

| HTTP | `code` | Meaning | SDK behavior |
|---|---|---|---|
| 401 | `111` | The token expired or was revoked: the app was disabled, another version was activated, the departments that can see the app changed, or the user's login session changed (see [App token](#app-token)) | Gets a new token and retries once. If it still fails, throws `PosApiError` (`status: 401`, `code: 111`); if POS refuses to issue a new token (the app is disabled or the user can no longer see it), throws `PosRuntimeError` with `code: 'failed'` |
| 403 | | Not allowed: role, warehouse, working hours, IP, always-blocked groups | Throws `PosApiError` |
| 403 | `110` | The endpoint is not in the [Open API](/developers/api/en) | Throws `PosApiError` |
| 404 | | Not found, or the data is outside the app's `access` | Throws `PosApiError` |
| 404 | | The method is not in `access.methods` (when sent directly, bypassing the SDK) | The SDK blocks it before sending |
| 429 | `106` | Rate limit exceeded | Throws `PosApiError`; the app retries |
| 200 | depends on the endpoint | The body has `success: false`: a business error, see `message` | Throws `PosApiError` with `status: 200` |

## Calling external systems

- Declare origins in `network.allowed_origins`; requests to other origins are blocked.
- External servers must enable CORS for the app's sandbox origin, which is the `Origin` header of the requests the app sends while running the active version. In development mode the origin is the address you entered when turning it on (for example `http://localhost:5174`); enable CORS for it too if you want to test.
- **Never send the app token elsewhere**, except to the backend of a [self-hosted page](/developers/apps/en/runtime-api#self-hosted-pages). If an external system needs POS data, the app reads it through the Open API and sends only what is needed.
- **Never put secrets in app code.** Anyone who can open the app can download the bundle. To call systems that need a secret, put a backend of your own in between and authenticate callers separately.

## Background tasks

Apps only run while someone has POS open. Scheduled jobs, syncs or event handling while nobody uses POS need the shop's own backend:

- Call the Open API with a **User API Key** (recommended, user-scoped permissions) or a **Shop API Key** as today.
- Receive change events through the shop's webhooks (Settings › Webhook - API).

Planned for phase 2: a **per-app service key** (acting as a designated user, with the app's `access` applied) and **per-app webhook subscriptions** with HMAC signatures.

## App-owned data

This version has no dedicated storage for apps. Options:

- The shop's custom CRM tables, read/written through the Open API, with table- and field-level permissions built in.
- The shop's own backend.
- `localStorage` in an iframe page: stored only in the current browser, suitable for UI preferences.
