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

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

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

## `*.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 }
  | { 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 every `message`.
- When `timeout_ms` is exceeded or the module fails, `on_error` decides (`allow` saves, `block` stops the save).
- This version can only **block**; it cannot modify the record.


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