> ## Documentation Index
> Fetch the complete documentation index at: https://docs.launchmystore.io/llms.txt
> Use this file to discover all available pages before exploring further.

# POS Extensions

> Iframe extensions for the LaunchMyStore Point-of-Sale app

# POS Extensions

POS extensions let your app inject UI into the LaunchMyStore
Point-of-Sale (POS) app — the in-person sales surface staff use at a
counter. Like [admin blocks](/extensions/admin-blocks), POS extensions
render as sandboxed iframes at named slots inside the POS UI.

<Note>
  POS extensions share the manifest shape and install pipeline with
  admin blocks. The only difference is the target prefix
  (`pos.<surface>.<placement>.render`) and a flag the host uses to
  branch rendering between admin and POS surfaces.
</Note>

## When to use POS extensions

Use a POS extension when staff at the register need information or
actions that aren't part of the default POS flow:

* Look up a loyalty member by phone and apply their tier discount
* Check warehouse stock from a partner system before promising backorder
* Print a custom gift receipt with a per-item warranty card
* Show recent purchases for the current customer

If your app only needs to add screens to the merchant admin (no
register flow), use [Admin Blocks](/extensions/admin-blocks) or
[Admin Actions](/extensions/admin-actions) instead.

## Manifest

POS extensions live under `extensions.posExtensions` in your `app.json`.
The install endpoint also accepts entries in this array; the local
manifest helper merges them with `adminExtensions` and tags POS entries
with `type: 'pos_extension'`.

```json theme={null}
{
  "handle": "loyalty-tools",
  "name": "Loyalty Tools for POS",
  "version": "1.0.0",
  "extensions": {
    "posExtensions": [
      {
        "handle": "loyalty-lookup",
        "target": "pos.checkout.block.render",
        "title": "Loyalty Lookup",
        "url": "https://my-app.example.com/pos/loyalty-lookup",
        "iconUrl": "https://my-app.example.com/icons/loyalty.svg",
        "permissions": { "read_customers": true }
      }
    ]
  }
}
```

### Manifest fields

<ParamField path="handle" type="string" required>
  Unique extension handle within this app. Used as the iframe id.
</ParamField>

<ParamField path="target" type="string" required>
  POS injection point — must start with `pos.`. The host uses the
  `pos.` prefix to distinguish POS extensions from admin blocks (every
  entry whose target starts with `pos.` is tagged `type: 'pos_extension'`
  on the wire). See [Targets](#targets) below.
</ParamField>

<ParamField path="title" type="string">
  Tab/panel label shown in the POS UI. Defaults to the extension `name`
  or app `name` when omitted.
</ParamField>

<ParamField path="url" type="string">
  Absolute URL the iframe loads. Relative URLs (e.g. `/extensions/.../tool.html`)
  are absolutized against the LaunchMyStore origin before the response is
  sent — POS clients running on a different host resolve them
  correctly.
</ParamField>

<ParamField path="iconUrl" type="string">
  Small icon (SVG/PNG) shown next to the title in the POS tab strip.
</ParamField>

<ParamField path="navigationLabel" type="string">
  Override for the tab label when it appears in a list/nav strip. Falls
  back to `title` when omitted.
</ParamField>

<ParamField path="permissions" type="object">
  Free-form permission flags forwarded to the host. Consumers (POS app)
  decide which gates to enforce.
</ParamField>

## Targets

<Note>
  The full target catalog is being finalized as the POS app rolls out.
  Targets are validated as "starts with `pos.`" at the API layer — any
  target your host knows about will work, but unrecognized targets
  simply don't surface. Treat the list below as authoritative for
  shipping today; new placements will be added here as the POS app
  ships them.
</Note>

| Target                              | Surface              | When it renders                                    |
| ----------------------------------- | -------------------- | -------------------------------------------------- |
| `pos.home.block.render`             | POS home screen      | Loaded into a tile on the staff home screen.       |
| `pos.checkout.block.render`         | Active sale screen   | Sidebar block visible during the checkout flow.    |
| `pos.customer.details.block.render` | Customer detail card | Block on the in-POS customer view (during a sale). |
| `pos.product.details.block.render`  | Product quick view   | Block on the product quick-view modal in POS.      |
| `pos.cart.block.render`             | Cart drawer          | Below the line items in the cart drawer.           |
| `pos.order.action.render`           | Completed order      | Action button on a finalized order receipt screen. |

Custom targets are allowed — any string starting with `pos.` will be
returned by the admin-extensions API. The POS host is free to mount
unknown targets in a generic slot or ignore them.

## Iframe contract

POS extensions follow the same iframe contract as
[Admin Blocks](/extensions/admin-blocks):

<Steps>
  <Step title="Host fetches the extension list">
    The POS host calls
    `GET /api/apps/admin-extensions?target=pos.checkout.block.render&domainSlug=…`
    with a bearer token, receives the merged backend + local-manifest
    list, and filters by `type === 'pos_extension'` if it wants to keep
    POS UI separate.
  </Step>

  <Step title="Host renders an iframe per extension">
    The host creates a sandboxed `<iframe>` for each entry with
    `src = ext.url` (absolutized against the LaunchMyStore origin when
    relative), `extensionId = ext.id` in the query string, and the
    target-defined permission flags.
  </Step>

  <Step title="Iframe initializes App Bridge">
    Inside the iframe, call `createApp({ apiKey, host })` from
    `@launchmystore/app-bridge` to begin posting `APP_BRIDGE_ACTION`
    messages to the host (`dispatch`, `dispatchAndWait`, `subscribe`,
    `getSessionToken`). See [App Bridge](/bridge/overview).
  </Step>

  <Step title="Resize negotiation">
    Send `{ type: 'APP_BRIDGE_RESIZE', extensionId, height }` whenever
    the iframe's content height changes. The `extensionId` MUST match
    the `id` the host passed in via `?extensionId=` — otherwise the
    iframe stays at the 200px default and looks empty.
  </Step>
</Steps>

```javascript theme={null}
// Inside your POS extension iframe
import { createApp } from '@launchmystore/app-bridge';

const params = new URLSearchParams(window.location.search);
const app = createApp({
  apiKey: 'your-app-api-key',
  host: params.get('host'),
});

// Resize the iframe to fit content
const ro = new ResizeObserver(() => {
  window.parent.postMessage(
    {
      type: 'APP_BRIDGE_RESIZE',
      extensionId: params.get('extensionId'),
      height: document.body.scrollHeight,
    },
    '*'
  );
});
ro.observe(document.body);

// Look up a customer via App Bridge resource picker
async function pickCustomer() {
  const result = await app.dispatchAndWait('RESOURCE_PICKER_OPEN', {
    type: 'customer',
    multiple: false,
  });
  return result.selection?.[0];
}
```

## Install pipeline

POS extensions install through the same endpoint as every other
extension:

```bash theme={null}
curl -X POST "https://store.launchmystore.io/api/apps/install-extensions" \
  -H "Content-Type: application/json" \
  -H "Authorization: Bearer $ACCESS_TOKEN" \
  -d '{
    "domainSlug": "acme-store",
    "appHandle": "loyalty-tools",
    "extensions": {
      "posExtensions": [
        {
          "handle": "loyalty-lookup",
          "target": "pos.checkout.block.render",
          "title": "Loyalty Lookup",
          "url": "https://my-app.example.com/pos/loyalty-lookup"
        }
      ]
    }
  }'
```

The installer:

1. Persists the array into `app.json` under `extensions.posExtensions`.
2. Invalidates the local manifest cache so the next read picks up the
   new entries within the 60s TTL.
3. Returns `installed.posExtensions = <count>` in the response.

## Discovery API

POS hosts call the same admin-extensions endpoint with a `pos.` target:

```http theme={null}
GET /api/apps/admin-extensions?target=pos.checkout.block.render&domainSlug=mystore
Authorization: Bearer <merchant-jwt>
```

Response (truncated):

```json theme={null}
{
  "extensions": [
    {
      "id": "loyalty-tools::loyalty-lookup",
      "appId": "loyalty-tools",
      "appHandle": "loyalty-tools",
      "name": "Loyalty Tools for POS",
      "handle": "loyalty-lookup",
      "target": "pos.checkout.block.render",
      "type": "pos_extension",
      "title": "Loyalty Lookup",
      "url": "https://store.launchmystore.io/extensions/mystore/loyalty-tools/...",
      "appUrl": "https://store.launchmystore.io/extensions/mystore/loyalty-tools/...",
      "iconUrl": "https://my-app.example.com/icons/loyalty.svg",
      "permissions": { "read_customers": true }
    }
  ]
}
```

The host can filter to POS-only by passing `&type=pos_extension`.

## Security

<Warning>
  The admin-extensions API requires a bearer token. Unauthenticated
  requests get `401 Missing bearer token` with an empty extension list.
  This prevents leaking which apps a merchant has installed.
</Warning>

* All iframes load over HTTPS in production. The POS host disables
  `allow-same-origin` so iframes cannot read the host's cookies.
* App Bridge `postMessage` is gated by the host's `allowedOrigins`
  list — only registered app origins can dispatch actions.
* Relative `url` values are absolutized against the LaunchMyStore
  storefront origin before being returned — POS clients calling
  cross-origin still resolve the iframe correctly.

## See also

<CardGroup cols={2}>
  <Card title="Admin Blocks" icon="gauge" href="/extensions/admin-blocks">
    Same iframe contract for merchant admin pages.
  </Card>

  <Card title="App Bridge" icon="bridge" href="/bridge/overview">
    Iframe-to-host messaging SDK.
  </Card>

  <Card title="App Manifest" icon="file-code" href="/getting-started/app-manifest">
    Every field in `app.json`.
  </Card>

  <Card title="Resource Picker" icon="hand-pointer" href="/bridge/resource-picker">
    11 picker types including `customer` and `product`.
  </Card>
</CardGroup>
