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):
posis a global preloaded by the POS runtime before the module runs. - Iframe pages: call
connect()from the SDK to getpos(see Iframe pages).
Full types ship in @pancake-pos/ui-extensions (see SDK & CLI).
Overview
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.
- Targets on POS records share one shape:
pos.data.resourcenames the resource (order,purchase…),pos.data.recordis the saved record (recordsin the bulk action bar) andpos.data.draftis the detail form content. - Fields hidden by
access.hidden_order_fieldsare not present inpos.data. - For blocks,
pos.dataupdates as the user edits the form; listen todata-changeto 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.
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>;
}pathmust 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
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.
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.
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. Tokens expire after 15 minutes; call getToken() again for a new one.
pos.toast.show
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.
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.
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
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.
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:
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,replaceStateandpopstateinside 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
localStoragebelongs 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.
{ "target": "app.page", "id": "home", "title": "ERP", "render": "iframe", "url": "https://erp.example.com/pos" }- Connecting: install
@pancake-pos/ui-extensionsand callconnect()as a bundled page does;pos.page,pos.api,pos.toastand the rest work the same way. - Paths: the path part of
urlis the app root./shop/<id>/apps/<handle>/orders/5in POS maps tohttps://erp.example.com/pos/orders/5. When the page leaves that root (for example/login), the POS URL stays as it is.pos.data.pathis the page's real path. - Allowing POS to embed it: send
Content-Security-Policy: frame-ancestors https://pos.pancake.vn https://pos.pages.fmso 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
urlwhen 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/posreplacesurlwith 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.bodyis 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 on the custom apps page.
