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. Every resource offers the same six locations, so an app written for orders works the same way for purchases or warehouse transfers.
| 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 |
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> is one of order, product, purchase, export, warehouse_transfer.
Planned: order.print-action (custom print templates), order.index.column (custom columns in the order list), customer.details.block.
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.
// 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.
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:
{
"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 |
Types per resource
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_fieldsare removed fromOrderDraft(their value becomesundefined). - Context data only includes the minimum fields. Call the Open API with
pos.apiwhen you need more (see Runtime API). - If a record does not satisfy the app's
accesslimits (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).
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:
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 emitsdata-changewith the newpathandquerywithout recreating the worker. The browser back button also emitsdata-change.
*.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 witherror.message. refreshdefaults totrue: 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.
- Open an order and hover the Save button to open the action menu
- App actions sit in the Apps group of the menu
- Code runs in the worker and calls the Open API and the ERP
- When done, POS shows a notification and reloads the order
- Pick the Warranty card action in the action menu
- POS opens a modal whose content the app draws with pos-* elements
- Fill it in like any POS form
- The app calls pos.close() and shows a notification when done
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_atfor warehouse transfers). - Timeout is 300 ms; on timeout or error the item is hidden.
- Prefer
conditionsin the manifest; only useshould_renderfor 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>.
- Hover an order's status in the list
- The app action sits at the bottom of the status menu
- When it finishes, the row gets a tag and POS shows a notification
*.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>,....
- Select several orders in the list
- The bulk action bar shows the app action
- The app receives order_ids and opens its page
*.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.dataand emits adata-changeevent (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.
- The app's box sits right inside the order form
- POS shows a skeleton while the worker starts
- Editing the order sends data-change and the box updates instantly
*.after_save
Runs after a record is successfully created or updated on its detail form in POS web.
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.
- Save the order as usual
- After saving, POS notifies the app through order.after_save
- The hook runs in the background; the user never waits
*.before_submit
Runs before a record is created or updated on its detail form in POS web.
type BeforeSubmitInput<R extends Resource> = DetailsData<R>;
type BeforeSubmitResult =
| { ok: true }
| { ok: false; message: string }; // message up to 200 characters, shown to the user- Hooks from several apps run in parallel. If any hook returns
ok: false, the record is not saved; POS shows everymessage. - When
timeout_msis exceeded or the module fails,on_errordecides (allowsaves,blockstops the save). - This version can only block; it cannot modify the record.
- On Save, POS runs the app's hook before saving
- The hook returns ok: false, so the order is not saved
- Add the phone number and save again
- The hook returns ok: true and POS saves the order as usual
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.
