pos-app.json lives at the project root. POS reads it on upload to learn which permissions the app needs, which domains it may call and where it attaches.
Full example
{
"handle": "debt-reconciliation",
"name": "Debt reconciliation",
"description": "Checks customer debt when creating orders and reconciles at the end of the day.",
"version": "1.3.0",
"api_version": "2026-01",
"access": {
"methods": ["GET", "POST", "PUT"],
"warehouse_ids": null,
"hidden_order_fields": ["bill_phone_number"]
},
"network": {
"allowed_origins": ["https://erp.example-customer.com"]
},
"extensions": [
{ "target": "nav.item", "id": "nav", "label": "Reconciliation", "page": "home" },
{ "target": "app.page", "id": "home", "title": "Debt reconciliation", "render": "iframe", "entry": "page/index.html" },
{
"target": "order.details.action",
"id": "push-erp",
"label": "Push to ERP",
"presentation": "headless",
"module": "extensions/push-erp.ts",
"conditions": { "status": [1, 2] }
},
{
"target": "order.details.action",
"id": "warranty",
"label": "Create warranty card",
"presentation": "modal",
"title": "Warranty card",
"module": "extensions/warranty-modal.tsx",
"should_render": "extensions/warranty-should-render.ts"
},
{ "target": "order.details.block", "id": "debt", "module": "extensions/debt-block.tsx" },
{
"target": "purchase.index.action",
"id": "push-purchase-erp",
"label": "Push to ERP",
"presentation": "headless",
"module": "extensions/push-purchase-erp.ts",
"conditions": { "status": [1] }
},
{
"target": "order.before_submit",
"id": "check-debt",
"module": "extensions/check-debt.ts",
"timeout_ms": 2000,
"on_error": "allow"
}
]
}Top-level fields
| Field | Type | Required | Description |
|---|---|---|---|
handle |
string | Yes | App identifier within the shop, matching ^[a-z0-9][a-z0-9-]{1,39}$. Used in the app page URL. Changing handle makes the next upload create a separate new app. |
name |
string | Yes | Display name, up to 60 characters. |
description |
string | No | Short description, up to 300 characters. Shown to the admin when activating. |
version |
string | Yes | Semver, for example 1.3.0. Every upload must use a version that does not exist yet. |
api_version |
string | Yes | The POS contract version (YYYY-MM). It determines the available targets, components and context schemas. |
access |
object | No | Limits on the data the app can use. See access. |
network |
object | No | External domains the app may call. See network. |
extensions |
array | Yes | Up to 30 extensions. See extensions. |
access
Additional limits on top of the current user's permissions. An app token never has more permissions than the user; access only narrows them further.
| Field | Type | Default | Description |
|---|---|---|---|
methods |
string[] | ["GET"] |
Allowed HTTP methods: GET, POST, PUT, DELETE. When omitted the app is read-only. |
warehouse_ids |
uuid[] | no limit | Only operate on these warehouses. Applies to orders, purchases, exports, warehouse transfers and stock by warehouse. |
order_sources |
string[] | no limit | Only see orders from these sources. |
hidden_order_sources |
string[] | none hidden | Hide orders from these sources. |
order_statuses |
int[] | no limit | Only see orders in these statuses. |
order_since |
datetime (ISO 8601) | no limit | Only see orders created from this time on. |
hidden_order_fields |
string[] | none hidden | Remove these fields from order data returned to the app, including context data. |
product_categories |
int[] | no limit | Only see products in these categories. |
product_brands |
int[] | no limit | Only see products of these brands. |
null or an omitted field means no additional limit (except methods). The limits that apply are the access of the active version.
network
| Field | Type | Description |
|---|---|---|
allowed_origins |
string[] | https:// origins the app may call with fetch, up to 10, no wildcards. The POS Open API is always allowed and does not need to be listed. Also applies to pos-image sources. |
Requests to origins not on the list are blocked by the browser. External servers must enable CORS for the app's sandbox domain (see Data access).
extensions
Common fields
| Field | Type | Required | Description |
|---|---|---|---|
target |
string | Yes | Where the extension attaches. See Extension points. |
id |
string | Yes | Unique within the app, matching ^[a-z0-9-]{1,40}$. |
module |
string | Depends on target | Source file path (.ts, .tsx, .js, .jsx). The CLI builds it into a standalone ES module. Required for Worker UI and headless targets. |
Action fields
Apply to *.details.action, *.index.action and *.index.selection-action of every resource.
| Field | Type | Required | Description |
|---|---|---|---|
label |
string | Yes | Menu item label, up to 40 characters. |
presentation |
string | Yes | headless, modal, fullscreen or navigate. See action types. |
title |
string | No | Modal title (modal, fullscreen). Defaults to label. |
page |
string | With navigate |
id of the app.page extension to open. |
path |
string | No | Path inside the page for navigate, for example /returns. |
conditions |
object | No | When to show the menu item, evaluated by POS. See conditions. |
should_render |
string | No | A module returning true/false to decide whether to show the item. Use it when conditions is not enough. |
Block fields
Apply to *.details.block.
| Field | Type | Required | Description |
|---|---|---|---|
conditions |
object | No | When to show the block. |
should_render |
string | No | Same as for actions. |
Page and menu fields
| Target | Field | Description |
|---|---|---|
app.page |
title (required) |
Page title. |
app.page |
render (required) |
iframe or worker (Worker UI pages are planned for phase 2). |
app.page |
entry |
The page's HTML file in the bundle, with render: "iframe". Declare entry or url. |
app.page |
url |
Self-hosted page: an https address on your own server that POS embeds instead of a file in the bundle. No credentials, query or #; no Pancake domains, localhost or IP addresses. Changing url requires a new version. See Self-hosted pages. |
app.page |
module |
Required when render: "worker". |
nav.item |
label (required) |
Label in the POS menu, up to 30 characters. |
nav.item |
page (required) |
id of the app.page extension. |
Hook fields
Apply to *.before_submit and *.after_save.
| Field | Type | Default | Description |
|---|---|---|---|
timeout_ms |
int | 2000 |
before_submit only, at most 5000. |
on_error |
string | allow |
before_submit only: allow lets the save continue when the hook fails or times out, block stops it. |
conditions
Keys are combined with AND; values in an array within one key are combined with OR. POS evaluates conditions while rendering, so menus never slow down.
| Key | Type | Resources | True when |
|---|---|---|---|
mode |
string[] | All (detail form targets) | create (new, unsaved record) or edit (existing record). |
status |
int[] | order, purchase, export, warehouse_transfer |
The record status is in the list (status codes as in the Open API). |
warehouse_ids |
uuid[] | order, purchase, export, warehouse_transfer |
The record's warehouse is in the list; for a transfer, either the source or the destination warehouse. |
tags_any |
(int | string)[] | order, product, purchase, export |
The record has at least one of the listed tags (tag IDs). |
type |
int[] | export |
The export type is in the list. |
category_ids |
int[] | product |
The product belongs to at least one of the listed categories. |
order_sources |
string[] | order |
The order source is in the list. |
has_customer |
boolean | order |
The order has / does not have a customer attached. |
Using a key that does not apply to the target's resource is a validation error (invalid_condition).
Example: only show for existing orders in status 1 or 2 that carry the VIP tag (ID 7):
{ "mode": ["edit"], "status": [1, 2], "tags_any": [7] }Example: only show for purchases that have been received (status 1) into one specific warehouse:
{ "status": [1], "warehouse_ids": ["a52f…"] }Manifest validation errors
| Code | Cause |
|---|---|
invalid_handle |
handle has an invalid format. |
invalid_field |
A field has an invalid value, for example it is too long, access has an unknown key, or access.methods is invalid. |
duplicate_version |
version already exists. |
unsupported_api_version |
api_version is no longer supported or does not exist. |
unknown_target |
target does not exist in the declared api_version. |
missing_field |
A field required by the target is missing. |
module_not_found |
The module/entry/should_render file cannot be found. |
bundle_too_large |
A module or the bundle exceeds the size limits. |
storage_quota_exceeded |
The shop's total app storage would exceed 500 MB. |
invalid_bundle |
The uploaded file is not a valid .zip package, or contains an unsafe file path. |
upload_failed |
The bundle could not be stored; upload again. |
invalid_origin |
An allowed_origins entry is not https:// or uses a wildcard. |
invalid_url |
The app.page url is not https with a domain name, contains credentials, a query or #, or belongs to a Pancake domain. |
invalid_condition |
A conditions key does not apply to the target's resource. |
