Reference

Extension points

Where apps attach inside POS for orders, products, purchases, exports and warehouse transfers, how each target is presented and what context data it receives.

View Markdown

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.*).

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.*

<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.

TypeScript
// 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.

TypeScript
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

TypeScript
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).
  • 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.

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).

  1. The app adds an item to the POS main menu
  2. Hover to see the app pages, click to open one
  3. The app page runs in an iframe on a sandbox domain and reads data through the Open API
  4. Switching tabs inside the app updates the POS URL
IllustrationApp menu item and pagenav.itemapp.page

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:

TypeScript
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.
  • 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-tabs, pos-filter-bar, pos-chart and the other elements (see Components). Set the title and primary button on the POS title bar with pos.page, as on iframe pages (see 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) 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.

  1. Open an order and hover the Save button to open the action menu
  2. App actions sit in the Apps group of the menu
  3. Code runs in the worker and calls the Open API and the ERP
  4. When done, POS shows a notification and reloads the order
IllustrationHeadless action in the order action menuorder.details.actionpresentation: headless
  1. Pick the Warranty card action in the action menu
  2. POS opens a modal whose content the app draws with pos-* elements
  3. Fill it in like any POS form
  4. The app calls pos.close() and shows a notification when done
IllustrationAction that opens a modalorder.details.actionpresentation: modal
  1. fullscreen: a multi-step flow in a full-screen modal
  2. Closing returns to the same order
  3. navigate: opens the app page with order_id
IllustrationFull-screen and navigate actionsorder.details.actionpresentation: fullscreenpresentation: navigate

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>.

  1. Hover an order's status in the list
  2. The app action sits at the bottom of the status menu
  3. When it finishes, the row gets a tag and POS shows a notification
IllustrationAction on each order list roworder.index.action

*.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>,....

  1. Select several orders in the list
  2. The bulk action bar shows the app action
  3. The app receives order_ids and opens its page
IllustrationAction for multiple selected ordersorder.index.selection-actionpresentation: navigate

*.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).
  • 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.
  1. The app's box sits right inside the order form
  2. POS shows a skeleton while the worker starts
  3. Editing the order sends data-change and the box updates instantly
IllustrationBlock in the order formorder.details.block

*.after_save

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

TypeScript
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.
  1. Save the order as usual
  2. After saving, POS notifies the app through order.after_save
  3. The hook runs in the background; the user never waits
IllustrationHook after savingorder.after_save

*.before_submit

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

TypeScript
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).
TypeScript
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() } };
});
  1. On Save, POS runs the app's hook before saving
  2. The hook returns ok: false, so the order is not saved
  3. Add the phone number and save again
  4. The hook returns ok: true and POS saves the order as usual
IllustrationValidation hook before savingorder.before_submit

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.

TypeScript
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.

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.

TypeScript
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.
TypeScript
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.

TypeScript
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): 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.
TypeScript
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 };
});