# Runtime API (pos)

> Applies to `api_version: "2026-01"`.

Every extension gets a `pos` object to read context, call the Open API and send commands to the POS UI.

- **Extensions in the worker** (blocks, actions, hooks, Worker UI pages): `pos` is a global preloaded by the POS runtime before the module runs.
- **Iframe pages**: call `connect()` from the SDK to get `pos` (see [Iframe pages](#iframe-pages)).

Full types ship in `@pancake-pos/ui-extensions` (see [SDK & CLI](/developers/apps/en/sdk)).

## Overview

```ts
interface Pos {
  apiVersion: string;                 // for example "2026-01"
  target: string;                     // the running extension's target
  extensionId: string;                // the id declared in the manifest
  app: { id: string; handle: string; version: string };
  shop: { id: number; name: string; currency: string; country: string; timezone: string };
  user: { id: string; name: string; locale: string };
  data: unknown;                      // context data, schema depends on the target

  api: PosApi;                        // Open API calls
  session: { getToken(): Promise<{ token: string; expiresAt: string }> };

  toast: { show(message: string, options?: ToastOptions): void };
  ui: { confirm(options: ConfirmOptions): Promise<boolean> };
  navigate(to: NavigateTarget): void;
  refresh(): void;
  close(): void;

  on(event: PosEvent, handler: (payload: any) => void): () => void;
}
```

### Availability

| API | Block | Headless action | Modal/fullscreen action | Hook, should_render | Page |
|---|---|---|---|---|---|
| `shop`, `user`, `app`, `data` | ✓ | ✓ | ✓ | ✓ | ✓ |
| `api`, `session` | ✓ | ✓ | ✓ | ✓ | ✓ |
| `toast`, `ui.confirm` | ✓ | ✓ | ✓ | | ✓ |
| `navigate` | ✓ | ✓ | ✓ | | ✓ |
| `refresh` | ✓ | ✓ | ✓ | | |
| `close` | | | ✓ | | |
| `on('data-change')` | ✓ | | ✓ | | ✓ |
| `page.*` | | | | | ✓ (iframe) |

Calling an API outside its availability throws `PosRuntimeError` with `code: 'unavailable'`.

## pos.data

The context data of the current target, also passed to the `export default` function. See each target's schema in [Extension points](/developers/apps/en/targets).

- Targets on POS records share one shape: `pos.data.resource` names the resource (`order`, `purchase`…), `pos.data.record` is the saved record (`records` in the bulk action bar) and `pos.data.draft` is the detail form content.
- Fields hidden by `access.hidden_order_fields` are not present in `pos.data`.
- For blocks, `pos.data` updates as the user edits the form; listen to `data-change` to re-render.

## pos.api

Calls the Open API with the app token. The SDK attaches the token, renews it and retries once when it expires.

```ts
interface PosApi {
  baseUrl: string;   // Open API root, for example "https://pos.pages.fm/api/v1"
  request<T = any>(options: {
    method: 'GET' | 'POST' | 'PUT' | 'DELETE';
    path: string;                          // relative to baseUrl, starts with /shops/{shop_id}
    query?: Record<string, unknown>;
    body?: unknown;                        // sent as JSON
    signal?: AbortSignal;
  }): Promise<T>;
  get<T = any>(path: string, options?: { query?: Record<string, unknown>; signal?: AbortSignal }): Promise<T>;
  post<T = any>(path: string, body?: unknown, options?: { query?: Record<string, unknown>; signal?: AbortSignal }): Promise<T>;
  put<T = any>(path: string, body?: unknown, options?: { query?: Record<string, unknown>; signal?: AbortSignal }): Promise<T>;
  delete<T = any>(path: string, options?: { query?: Record<string, unknown>; signal?: AbortSignal }): Promise<T>;
}
```

- `path` must belong to the current shop (`/shops/${pos.shop.id}/...`). Calls to other shops are rejected.
- The result is the raw Open API JSON (usually `{ success, data, ... }`).
- The method must be listed in the app's `access.methods`; otherwise an error is thrown before the request is sent.

### Errors

```ts
class PosApiError extends Error {
  status: number;            // HTTP status
  code?: number | string;    // Open API error_code when present
  body?: unknown;            // response body
}
```

The SDK throws `PosApiError` when the HTTP status is ≥ 400 **or** the body has `success: false`. See the error codes in [Data access › Error codes](/developers/apps/en/data-access#error-codes).

```ts
try {
  const { record } = pos.data;   // order.details.action: record is an OrderRef
  const res = await pos.api.get(`/shops/${pos.shop.id}/orders/${record.system_id}`);
  render(<OrderInfo order={res.data} />, document.body);
} catch (error) {
  if (error.status === 403) pos.toast.show('You do not have permission to view this order', { tone: 'error' });
  else throw error;
}
```

## pos.session.getToken

Gets the current app token to call the Open API with another library.

```ts
const { token, expiresAt } = await pos.session.getToken();
await fetch(`${pos.api.baseUrl}/shops/${pos.shop.id}/orders`, {
  headers: { Authorization: `Bearer ${token}` },
});
```

Do not store the token long term or send it to external systems, except to the backend of a [self-hosted page](#self-hosted-pages). Tokens expire after 15 minutes; call `getToken()` again for a new one.

## pos.toast.show

```ts
type ToastOptions = {
  tone?: 'success' | 'error' | 'info' | 'warning';   // default 'success'
  duration?: number;                                  // seconds, default 3, from 1 to 10
};
```

At most 3 toasts per 10 seconds per extension; extra toasts are dropped.

## pos.ui.confirm

A confirmation dialog drawn by POS over the whole screen.

```ts
type ConfirmOptions = {
  title: string;
  content?: string;
  okText?: string;       // defaults to the POS "OK" label
  cancelText?: string;   // defaults to the POS "Cancel" label
  danger?: boolean;      // red confirm button
};

if (await pos.ui.confirm({ title: 'Push this order to the ERP?' })) {
  // ...
}
```

## pos.navigate

Navigates to one of the app's pages.

```ts
type NavigateTarget = {
  page: string;                       // id of an app.page extension
  path?: string;                      // for example "/returns"
  query?: Record<string, string>;
};

pos.navigate({ page: 'home', path: '/returns', query: { order_id: String(pos.data.record.system_id) } });
```

When called from a modal, the modal closes before navigating. If that page is already open, POS only updates the URL and emits `data-change` with the new `path` and `query`; the page is not reloaded.

## pos.refresh

Asks POS to reload the data of the current screen (for example the open order or purchase) after the app updated it through the Open API.

- POS reloads the list on screen and the open record. Unsaved changes in that record's form are replaced by the new data.
- Repeated calls are merged: each resource reloads at most once every 3 seconds.

## pos.close

Closes the open modal/fullscreen action.

## Events

```ts
const off = pos.on('data-change', (data) => {
  // data has the same schema as pos.data
});
off(); // unsubscribe
```

| Event | Fires when | Payload |
|---|---|---|
| `data-change` | Context data changes, for example the user edits a detail form (batched over 300 ms) or a page's path changes | the new `pos.data` |
| `primary-action` | The user clicks the iframe page's primary button | none |

## Iframe pages

A `render: "iframe"` page is a regular web app served from the root of the app's sandbox domain. Every path that is not a static file returns `index.html`, so routers use `/` as their `basename`. Paths starting with `/_pos/` are reserved for POS: do not use them as routes or file paths.

```ts
import { connect } from '@pancake-pos/ui-extensions/page';

const pos = await connect();   // waits for POS to send context and token
// pos.data: { path, query }
```

On top of the APIs above, iframe pages get:

```ts
interface PosPage {
  setTitle(title: string): void;
  setPrimaryAction(action: { label: string; disabled?: boolean; loading?: boolean } | null): void;
}

pos.page.setTitle('Reconciliation for May 12');
pos.page.setPrimaryAction({ label: 'Export Excel' });
pos.on('primary-action', exportExcel);
```

- **URL sync:** the SDK watches `history.pushState`, `replaceState` and `popstate` inside the iframe and updates the POS URL. When a shared link is opened, the iframe loads the matching path.
- **Height:** the iframe fills the POS content area; the page scrolls internally.
- **Look and feel:** `@pancake-pos/ui` (POS theme and components) is recommended.
- **Storage:** the iframe page's `localStorage` belongs to the app's sandbox domain only and is not shared with POS or other apps.

### Self-hosted pages

Declare `url` instead of `entry` when the page already runs on your own server, such as an existing ERP web app. POS embeds `url` directly in the iframe; the bundle does not need a `page/` folder.

```json
{ "target": "app.page", "id": "home", "title": "ERP", "render": "iframe", "url": "https://erp.example.com/pos" }
```

- **Connecting:** install `@pancake-pos/ui-extensions` and call `connect()` as a bundled page does; `pos.page`, `pos.api`, `pos.toast` and the rest work the same way.
- **Paths:** the path part of `url` is the app root. `/shop/<id>/apps/<handle>/orders/5` in POS maps to `https://erp.example.com/pos/orders/5`. When the page leaves that root (for example `/login`), the POS URL stays as it is. `pos.data.path` is the page's real path.
- **Allowing POS to embed it:** send `Content-Security-Policy: frame-ancestors https://pos.pancake.vn https://pos.pages.fm` so only POS can embed the page. Do not rely on the page's own sign-in cookies: browsers may block cookies in cross-site iframes (Safari does by default).
- **Calling the API from your server:** send the app token (`pos.session.getToken()`) with requests to your backend, and have the backend call the Open API with it (`Authorization: Bearer`). The token lasts 15 minutes and carries only the app's access; do not store it long term.
- **Review and changes:** admins see `url` when they activate a version. Your server decides what the page shows and can change it at any time without a new version, so only use servers you control.
- **Trying it out:** `pos-app dev --page-url http://localhost:3000/pos` replaces `url` with a server on your machine; Vite or Next hot reload keeps working inside the iframe.

## Worker environment

Extensions in the worker can only rely on:

- Standard JavaScript syntax and built-ins (ES2020).
- `fetch`, `Headers`, `Request`, `Response`, `AbortController`, `URL`, `URLSearchParams`.
- `setTimeout`, `clearTimeout`, `setInterval`, `clearInterval`.
- `console` (only visible in development mode).
- The minimal DOM provided by the POS runtime so Preact or plain JS can build `pos-*` elements: `document.createElement`, `document.createTextNode`, `append`, `appendChild`, `insertBefore`, `removeChild`, `remove`, `setAttribute`, `removeAttribute`, `textContent`, `addEventListener`, `removeEventListener`, `parentNode`, `childNodes`, `firstChild`, `nextSibling`, `CustomEvent`. `document.body` is the root of the extension's UI.

**Do not** use `window`, `document.querySelector`, `localStorage`, `sessionStorage`, `canvas`, DOM measurement (`getBoundingClientRect`…), `alert`/`confirm`/`prompt`, or load scripts from CDNs. These may not exist or may be locked down at any time.

## Error log

- Development mode: errors and `console.*` show up in the browser DevTools.
- Active version: uncaught errors, render errors and timeouts are recorded in the app's error log, visible in the [error log](/developers/apps/en/manage-apps#error-log) on the custom apps page.
