# Extension points

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

An extension point (target) is a place POS exposes for apps. Each target defines where it is shown, how it is presented, what context data the module receives and what it must return.

## Naming

Targets attached to a POS record are named `<resource>.<location>`, for example `order.details.action` or `purchase.index.selection-action`. Resources share the same location names, so an app written for orders works the same way for purchases or warehouse transfers. Some locations do not exist for every resource: products have no print templates (`print-action`), and customers only have boxes, actions and columns (see [`customer.*`](#customer)).

| Resource | POS screen | List | Detail form |
|---|---|---|---|
| `order` | Orders | Order list | Order form |
| `product` | Products | Product list | Product modal |
| `purchase` | Purchases (goods receipts) | Purchase list | Purchase modal |
| `export` | Exports (goods issues) | Export list | Export modal |
| `warehouse_transfer` | Warehouse transfers | Transfer list | Transfer modal |
| `customer` | Customers | Customer list | Customer profile |

## Target list

| Target | Location in POS | Presentation |
|---|---|---|
| `nav.item` | Main menu, "Apps" group | Declared only, no code |
| `app.page` | The app's page `/shop/<shop_id>/apps/<handle>/<path>` | iframe or Worker UI |
| `<resource>.details.action` | Action menu of the detail form | headless, modal, fullscreen, navigate |
| `<resource>.index.action` | Action menu on each list row | headless, modal, fullscreen, navigate |
| `<resource>.index.selection-action` | Bulk action bar when several records are selected | headless, modal, fullscreen, navigate |
| `<resource>.details.block` | A box inside the detail form | Worker UI |
| `<resource>.before_submit` | Before a record is created/updated on the detail form | hook, headless |
| `<resource>.after_save` | After a record is created/updated on the detail form | hook, headless |
| `<resource>.index.column` | A column in the list | hook, headless |
| `<resource>.print-action` | A print template in the "Print template" picker (except `product`) | headless, returns HTML |
| `customer.<location>` | Customer profile and list: `details.block`, `details.action`, `index.action`, `index.selection-action`, `index.column` | See [`customer.*`](#customer) |

`<resource>` is one of `order`, `product`, `purchase`, `export`, `warehouse_transfer`. Products have no `print-action` because they only print barcodes.

## Module convention

Every module is an ES module with a function as its `export default`. The function receives the context data (same as `pos.data`) and may be `async`.

```ts
// Extensions with UI (block, modal/fullscreen action, Worker UI page): render into document.body
export default async function (data) {
  render(<App data={data} />, document.body);
}

// Headless extensions and hooks: return the result expected by the target
export default async function (data) {
  return { message: 'Done' };
}
```

## Shared types

Context data has the same shape for every resource: `data.resource` names the resource, `data.record` is the saved record (`data.records` in the bulk action bar) and `data.draft` is what the detail form currently shows.

```ts
type Resource = 'order' | 'product' | 'purchase' | 'export' | 'warehouse_transfer';

type RefOf<R extends Resource> = {
  order: OrderRef;
  product: ProductRef;
  purchase: PurchaseRef;
  export: ExportRef;
  warehouse_transfer: WarehouseTransferRef;
}[R];

type DraftOf<R extends Resource> = {
  order: OrderDraft;
  product: ProductDraft;
  purchase: PurchaseDraft;
  export: ExportDraft;
  warehouse_transfer: WarehouseTransferDraft;
}[R];

// Detail form: *.details.action, *.details.block, *.before_submit
type DetailsData<R extends Resource> = {
  resource: R;
  mode: 'create' | 'edit';
  record: RefOf<R> | null;   // null while the record is unsaved
  draft: DraftOf<R>;         // includes unsaved changes
};

// One list row: *.index.action
type IndexData<R extends Resource> = {
  resource: R;
  record: RefOf<R>;
};

// Bulk action bar: *.index.selection-action
type SelectionData<R extends Resource> = {
  resource: R;
  records: RefOf<R>[];       // up to 500 records
};
```

Example `pos.data` in `purchase.details.action`:

```json
{
  "resource": "purchase",
  "mode": "edit",
  "record": { "id": "7c1d…", "display_id": 318, "custom_id": null, "status": 0, "warehouse_id": "a52f…", "updated_at": "2026-10-06T02:45:00" },
  "draft": { "status": 0, "warehouse_id": "a52f…", "supplier_products_id": "e09b…", "tags": [], "note": null, "items": [{ "variation_id": "41aa…", "quantity": 20, "imported_price": 1850000 }], "total_price": 37000000 }
}
```

### Record id

| Resource | Id used with the Open API | Query added by `navigate` |
|---|---|---|
| `order` | `record.system_id` | `order_id`, `order_ids` |
| `product` | `record.id` | `product_id`, `product_ids` |
| `purchase` | `record.id` | `purchase_id`, `purchase_ids` |
| `export` | `record.id` | `export_id`, `export_ids` |
| `warehouse_transfer` | `record.id` | `warehouse_transfer_id`, `warehouse_transfer_ids` |
| `customer` | `record.id` | `customer_id`, `customer_ids` |

### Types per resource

```ts
type OrderRef = {
  id: string | number;      // displayed order id (the custom id if the shop uses one, otherwise the system id)
  system_id: number;        // system order id, as the order's system_id field in the Open API: /shops/{shop_id}/orders/{system_id}
  status: number;
  updated_at: string;       // ISO 8601
};

type OrderDraft = {
  status: number;
  customer_id: string | null;
  bill_full_name: string | null;
  bill_phone_number: string | null;
  warehouse_id: string | null;
  tags: (number | string)[];
  note: string | null;
  items: {
    variation_id: string;
    product_id: string;
    quantity: number;
    price: number;          // unit price after line discount
  }[];
  total_price: number;
};

type ProductRef = {
  id: string;               // UUID: /shops/{shop_id}/products/{id}
  display_id: number;
  custom_id: string | null; // product code
  name: string;
  is_hide: boolean;
  updated_at: string;
};

type ProductDraft = {
  name: string;
  custom_id: string | null;
  category_ids: number[];
  brand_id: number | null;
  tags: (number | string)[];
  variations: {
    id: string | null;      // null for a variation not saved yet
    custom_id: string | null;
    barcode: string | null;
    retail_price: number;
  }[];
};

type PurchaseRef = {
  id: string;               // UUID: /shops/{shop_id}/purchases/{id}
  display_id: number;
  custom_id: string | null;
  status: number;
  warehouse_id: string;
  updated_at: string;
};

type PurchaseDraft = {
  status: number;
  warehouse_id: string | null;
  supplier_products_id: string | null;   // supplier
  tags: (number | string)[];
  note: string | null;
  items: {
    variation_id: string;
    quantity: number;
    imported_price: number;
  }[];
  total_price: number;
};

type ExportRef = {
  id: string;               // UUID: /shops/{shop_id}/export/{id}
  display_id: number;
  custom_id: string | null;
  status: number;
  type: number;             // export type as in the Open API (normal, defective goods…)
  warehouse_id: string;
  updated_at: string;
};

type ExportDraft = {
  status: number;
  type: number;
  warehouse_id: string | null;
  tags: (number | string)[];
  note: string | null;
  export_items: {
    variation_id: string;
    quantity: number;
  }[];
};

type WarehouseTransferRef = {
  id: string;               // UUID: /shops/{shop_id}/transfers/{id}
  display_id: number;
  custom_id: string | null;
  status: number;
  from_warehouse_id: string;
  to_warehouse_id: string;
  updated_status_at: string;
};

type WarehouseTransferDraft = {
  status: number;
  from_warehouse_id: string | null;
  to_warehouse_id: string | null;
  note: string | null;
  items: {
    variation_id: string;
    quantity: number;
  }[];
};
```

- Field names and status codes follow the Open API. Fields the user may not see (for example the imported price without the matching permission) are left out, and fields listed in `access.hidden_order_fields` are removed from `OrderDraft` (their value becomes `undefined`).
- Context data only includes the minimum fields. Call the Open API with `pos.api` when you need more (see [Runtime API](/developers/apps/en/runtime-api)).
- If a record does not satisfy the app's `access` limits (warehouse, product category, order source…), POS does not show the extension for that record.

## nav.item

Adds an item to the main menu that opens an `app.page`.

| Manifest | Description |
|---|---|
| `label` | Label in the menu |
| `page` | `id` of the `app.page` extension |

No module. The item is only shown to people who can see the app (every shop member by default, see [App lifecycle](/developers/apps/en/overview#app-lifecycle)).


## app.page

The app's own page.

| Manifest | Description |
|---|---|
| `title` | Page title |
| `render` | `iframe`: free-form SPA; `worker`: Worker UI with `pos-*` elements |
| `entry` | HTML file in the bundle (with `iframe`) |
| `url` | Self-hosted page on your own server (with `iframe`, instead of `entry`) |
| `module` | Module (with `worker`) |

Context data:

```ts
type PageData = {
  path: string;                     // the part after /apps/<handle>, for example "/returns"
  query: Record<string, string>;    // the POS URL query string
};
```

- **Iframe pages:** the URL is synced both ways; when the app changes its internal route the POS URL follows, so reloads, the back button and shared links all work. The page connects to POS through the SDK, see [Runtime API › Iframe pages](/developers/apps/en/runtime-api#iframe-pages).
- **Worker UI pages:** the worker has no history. Change the path with `pos.navigate({ page, path, query })`; POS updates the URL and emits `data-change` with the new `path` and `query` without recreating the worker. The browser back button also emits `data-change`.
- Build Worker UI pages with `pos-page`, `pos-filter-bar`, `pos-stat`, `pos-chart`, `pos-tabs`, `pos-table` and the other elements following [Standard screens](/developers/apps/en/components#standard-screens) (see [Components](/developers/apps/en/components#worker-ui-pages)). Set the title and primary button on the POS title bar with `pos.page`, as on iframe pages (see [Runtime API › Worker UI pages](/developers/apps/en/runtime-api#worker-ui-pages)).

```tsx
import { render } from 'preact';
import { useEffect, useState } from 'preact/hooks';

function Report() {
  const [tab, setTab] = useState(pos.data.query.tab || 'day');
  useEffect(() => pos.on('data-change', (data) => setTab(data.query.tab || 'day')), []);
  return (
    <pos-page heading="Revenue" width="full">
      <pos-tabs tabs={[{ id: 'day', label: 'By day' }, { id: 'source', label: 'By source' }]} selected={tab} onChange={(e) => pos.navigate({ page: 'report', query: { tab: e.detail } })}>
        <pos-chart type={tab == 'day' ? 'line' : 'pie'} xKey="label" data={[{ label: '01/10', revenue: 12500000 }]} series={[{ key: 'revenue', label: 'Revenue', format: 'money' }]} />
      </pos-tabs>
    </pos-page>
  );
}

export default function () {
  pos.page?.setTitle('Revenue report');
  render(<Report />, document.body);
}
```

## `*.details.action`

Adds an item to the action menu of a detail form. App items sit in an "Apps" group at the bottom of the menu, in declaration order.

| Resource | Menu |
|---|---|
| `order` | The action menu opened from the Save button of the order form |
| `purchase`, `export`, `warehouse_transfer` | The status menu in the footer of the detail modal |
| `product` | An "Apps" button in the product modal header, shown only when an app adds actions there |

Context data: `DetailsData<R>`.

For orders, app items are not shown when the menu is in print-only mode or actions are hidden.

### Action types

| `presentation` | On click | Module returns |
|---|---|---|
| `headless` | Runs the module in the worker; the menu item shows a running state | `{ message?: string, refresh?: boolean }` or nothing |
| `modal` | Opens a medium modal; the module renders its content | Nothing; call `pos.close()` to close |
| `fullscreen` | Opens a full-screen modal; closing returns to the same screen | Same as `modal` |
| `navigate` | Navigates to `page` at `path`, adding the record id to the query (see [Record id](#record-id)) | No module |

For `headless`:

- If it returns `message`, POS shows a success toast with that text.
- If it throws (`throw new Error('...')`), POS shows an error toast with `error.message`.
- `refresh` defaults to `true`: POS reloads the record when the module finishes.
- Maximum run time is 30 seconds; after that the worker is stopped and an error is reported.

The videos below use orders; other resources behave the same way.




### should_render

The `should_render` module receives the same context data and returns a `boolean`.

- POS evaluates it ahead of time when the record loads or changes, caching the result by record id and its update time (`updated_status_at` for warehouse transfers).
- Timeout is 300 ms; on timeout or error the item is **hidden**.
- Prefer `conditions` in the manifest; only use `should_render` for complex conditions.

## `*.index.action`

Like `*.details.action`, but on each row of the list.

| Resource | Menu |
|---|---|
| `order`, `purchase`, `export`, `warehouse_transfer` | The status menu on each row |
| `product` | A "⋯" menu at the end of each row, shown only when an app adds actions there |

Context data: `IndexData<R>`.


## `*.index.selection-action`

An item on the action bar shown when several records are selected in the list.

Context data: `SelectionData<R>`. With `navigate`, the query contains every id, for example `purchase_ids=<id>,<id>,...`.


## `*.details.block`

A box inside the detail form.

| Resource | Position |
|---|---|
| `order` | Right after the Note section |
| `product`, `purchase`, `export`, `warehouse_transfer` | At the end of the detail modal, above the footer buttons |

- Context data: `DetailsData<R>` (same as `*.details.action`).
- When the user edits the form, POS updates `pos.data` and emits a `data-change` event (see [Runtime API › Events](/developers/apps/en/runtime-api#events)).
- The box is as wide as its column; its height grows with its content.
- The box never blocks the form: POS shows a skeleton while the worker starts.


## `*.after_save`

Runs after a record is successfully created or updated **on its detail form in POS web**.

```ts
type AfterSaveInput<R extends Resource> = {
  resource: R;
  mode: 'create' | 'edit';
  record: RefOf<R>;
};
```

- Does not block the user; the return value is ignored.
- Errors only go to the app's error log and are not shown to the user.


## `*.before_submit`

Runs before a record is created or updated **on its detail form in POS web**.

```ts
type BeforeSubmitInput<R extends Resource> = DetailsData<R>;

type BeforeSubmitResult =
  | { ok: true; changes?: BeforeSubmitChanges }
  | { ok: false; message: string };   // message up to 200 characters, shown to the user

type BeforeSubmitChanges = {
  note?: string;                      // replaces the note, up to 2,000 characters
  add_tags?: (number | string)[];     // adds tags by the shop's tag ids, up to 20
};
```

- Hooks from several apps run in parallel. If any hook returns `ok: false`, the record is not saved; POS shows every `message`.
- When `timeout_ms` is exceeded or the module fails, `on_error` decides (`allow` saves, `block` stops the save).
- `changes` can only set the note and add tags. POS applies them to the form before saving and tells the user which app changed the record.

| Resource | `note` | `add_tags` |
|---|---|---|
| `order` | Order note | Order tags, following the shop's tag groups as when picked by hand |
| `purchase`, `export` | Note | Tags |
| `warehouse_transfer` | Note | Not available |
| `product` | Not available | Product tags |

- Fields the resource does not have are ignored. Tag ids the shop does not have, or that are already set, are ignored. Get tag ids from the Open API or the MCP `list_shop_meta` tool.
- If two apps return different `note` values, POS stops the save and reports the conflict. Tags from several apps are combined.
- An invalid `changes` value is handled like a failing module (by `on_error`).

```ts
export default defineExtension('order.before_submit', async (data) => {
  if (data.draft.total_price < 20_000_000) return { ok: true };
  return { ok: true, changes: { add_tags: [12], note: `${data.draft.note ?? ''}\nLarge order: check before shipping`.trim() } };
});
```


### When hooks run

`*.before_submit` and `*.after_save` only run when the user saves on the detail form in POS web. They do **not** run for changes made elsewhere: the status menu on list rows, bulk updates, Excel import, the Open API, the mobile app or marketplace sync. Rules that must always hold belong on the server.

## `customer.*`

Customers have boxes, actions and columns like the other resources, but **no** `before_submit`, `after_save` (the profile saves each field as soon as it changes; there is no shared Save button) and no print templates.

| Target | Location | Context data |
|---|---|---|
| `customer.details.block` | A box in the customer profile, right column below Notes and Addresses | `CustomerData` |
| `customer.details.action` | An "Apps" button at the top of the customer profile, shown only when an app adds an action here | `CustomerData` |
| `customer.index.action` | The "⋯" menu at the end of each customer list row, shown only when an app adds an action here | `IndexData<'customer'>` |
| `customer.index.selection-action` | An "Apps" button on the action bar when several customers are selected | `SelectionData<'customer'>` |
| `customer.index.column` | A column in the customer list; `values` is keyed by the customer's `id` | `ColumnData<'customer'>` |

The profile has the box and the actions wherever it opens: the Customers page, the order form and other screens. When the customer list is filtered to deleted customers, app actions and columns are hidden.

```ts
type CustomerRef = {
  id: string;                       // use with the Open API: /shops/{shop_id}/customers/{id}
  name: string;
  phone_numbers: string[];
  tags: (number | string)[];
  level_id: string | null;
  assigned_user_id: string | null;
  updated_at: string | null;
};

type CustomerData = { resource: 'customer'; record: CustomerRef };   // same shape as IndexData<'customer'>
```

- Manifest: the same fields as the target at the same location for other resources (`*.details.action`, `*.details.block`, `*.index.column`…). Customer targets have **no** `conditions`; use `should_render` to hide by data.
- `refresh` of a headless action and `pos.refresh()` reload the customer list page being viewed; a profile opened from the list gets the new data. A profile opened from the order form or other screens does not reload yet.
- `navigate` adds `customer_id` or `customer_ids` to the query.
- When the customer is edited in the profile, the box receives `data-change` with the new data.
- As with `*.details.block`, the box's worker starts when the box scrolls into view; POS shows a placeholder meanwhile.

## `*.print-action`

The app's print template in the "Print template" picker; users pick it like any other template. The module returns HTML, POS cleans it and opens the browser's print dialog.

| Resource | "Print template" picker |
|---|---|
| `order` | Action menu of the order form (print on save, Print F4) and the print dialog of the bulk action bar |
| `purchase`, `export`, `warehouse_transfer` | The Print button on the bulk action bar |

Products only print barcodes, so they have no such target.

```ts
type PrintResource = 'order' | 'purchase' | 'export' | 'warehouse_transfer';
type PrintData<R extends PrintResource> = { resource: R; records: RefOf<R>[] };   // 1–500 records
type PrintResult = { html: string; title?: string };                             // html up to 2 MB
```

| Manifest | Description |
|---|---|
| `label` | Template name, up to 40 characters |
| `module` | Module returning `PrintResult` |

- Runs like a headless action: up to 30 seconds, may use `pos.api` and `pos.toast`. On errors or an invalid result POS reports that printing failed and writes the app's error log.
- Records outside the app's `access` limits are not sent to the module. Printing with an app template does not increase the order's print count.
- The context data only has ids and summary fields (`RefOf<R>`); fetch lines, customers or amounts from the Open API by record id.
- **The HTML is cleaned before printing:** `script`, event attributes (`onclick`…), `iframe`, `form`, `link` and `meta` are removed, and the print runs in an iframe without scripts. Use `<style>` or `style="…"` for layout, `@page` for the paper size and `break-after: page` for page breaks.
- **Images** follow the `pos-image` rules: `data:image/…;base64,…`, `public/<file name>` in the bundle, or an `https://` URL in `network.allowed_origins`. Other images, fonts and external CSS are blocked. Draw barcodes and QR codes as SVG or `data:` images.

```ts
export default defineExtension('purchase.print-action', async ({ records }) => {
  const details = await Promise.all(records.map((purchase) => pos.api.get(`/shops/${pos.shop.id}/purchases/${purchase.id}`)));
  const pages = details.map(({ data }) => `<section class="slip"><h1>Purchase ${data.display_id}</h1><p>${data.items.length} lines</p></section>`);
  return {
    title: 'ERP purchase slips',
    html: `<style>@page { size: A5; margin: 8mm } .slip { break-after: page }</style>${pages.join('')}`,
  };
});
```

## `*.index.column`

The app's column in a list, placed before the fixed right column (Status) or at the end of the table. Available for all 5 resources and for customers. POS calls the module **once for the records shown** on the page, not once per row.

```ts
type ColumnData<R extends Resource> = { resource: R; records: RefOf<R>[] };       // records on the page, up to 500
type ColumnResult = { values: Record<string, string | number | null> };         // keyed by record id
```

`values` is keyed by the id used with the Open API (see [Record id](#record-id)): `system_id` for orders, `id` for the other resources.

| Manifest | Description |
|---|---|
| `title` | Column title, up to 30 characters |
| `module` | Module returning `ColumnResult` |
| `format` | `text` (default), `number`, `money`, `date`, `datetime` |
| `align` | `left`, `center`, `right` |
| `width` | Width in px, 60–400, default 140 |

- Runs like a hook: 5-second timeout, only `pos.api` is available. Batch Open API calls with `Promise.all` or use list endpoints.
- Results are remembered per record and update time: when the list reloads (page change, socket updates) the module only runs for new or changed records.
- Records missing from `values`, records outside the `access` limits and failed runs leave the cell empty. Strings longer than 200 characters are cut.
- App columns are always shown; they are not part of the list's column settings yet.

```ts
export default defineExtension('order.index.column', async ({ records }) => {
  const ids = records.map((order) => order.system_id).join(',');
  const response = await fetch(`https://erp.example.com/pos-orders?ids=${ids}`);
  const erp: Record<string, string> = await response.json();
  return { values: erp };
});
```
