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

# Admin Print Action Extensions

> Custom print templates and printable web views for admin resources

# Admin Print Action Extensions

Admin print actions add a **"Print" button** to an admin resource page that
produces a print-ready document for that resource — packing slips, branded
invoices, gift receipts, return forms, pick lists, anything that ends in a
`window.print()` call.

Unlike [Admin Actions](/extensions/admin-actions) (which open an interactive
modal), print actions exist for one job: **render a printable view and hand
it to the browser's native print dialog**. The host page automatically
opens a new window, drops the rendered HTML into it, and triggers
`window.print()` as soon as the page paints.

<Note>
  The current build renders printable HTML and uses the browser's native
  print dialog. Headless-Chrome PDF generation, page-size hints, and direct
  receipt-printer support are tracked as future work — see
  [Roadmap](#roadmap--hardware-integration).
</Note>

## How it works

```mermaid theme={null}
sequenceDiagram
    participant Merchant
    participant Admin as Admin UI
    participant LMS as LaunchMyStore
    participant App as Your App (iframe mode only)

    Merchant->>Admin: Click "Print" → select your action
    Admin->>LMS: POST /api/apps/print-action
    alt template mode
      LMS->>LMS: Render Aqua template with resource context
      LMS-->>Admin: { type: "html", html, title }
    else iframe mode
      LMS-->>Admin: { type: "iframe", iframeUrl, title }
      Admin->>App: Open iframeUrl in new window
      App->>App: Fetch data, render, window.print()
    end
    Admin->>Merchant: Print dialog opens
    Merchant->>Merchant: Print or cancel
```

The host (the admin) opens the response in a new window:

* **Template mode** — the response includes `html` already wrapped in a
  print-ready envelope (with `<script>window.print()</script>` baked in).
  The new window writes the HTML, the script fires on `load`, and the
  print dialog opens automatically.
* **Iframe mode** — the host points the new window at your `iframeUrl`.
  Your app is responsible for fetching whatever it needs, rendering the
  printable view, and calling `window.print()` itself.

If the merchant cancels the print dialog, the new window stays open and
the user can re-trigger printing manually. There is no callback to your
app for cancellation.

## Two rendering modes

You choose **one** of these per print action. The choice is dictated by
which field you set on the manifest: `template` (string of Aqua/Liquid)
or `appUrl` (HTTPS URL).

<CardGroup cols={2}>
  <Card title="Template mode" icon="file-lines">
    Ship an Aqua/Liquid template string. The platform renders it
    server-side with the resource id in context and returns HTML wrapped
    in a print envelope. Zero app-server work at print time.
  </Card>

  <Card title="Iframe mode" icon="window">
    Ship a URL. The platform returns a signed URL the host opens in a new
    window. Your app generates the printable view dynamically, fetching
    whatever live data it needs.
  </Card>
</CardGroup>

Pick **template mode** for layouts that depend only on the resource id
and a few static fields (packing slip header, simple invoice). Pick
**iframe mode** when the printable content depends on data outside the
resource (live inventory, branded marketing pixels, customer-specific
copy).

A single print action **must not** set both `template` and `appUrl`. The
server checks `appUrl` first; if both are set it's used and `template` is
ignored. Pick one.

## Available targets

| Target                             | Where it renders                    |
| ---------------------------------- | ----------------------------------- |
| `admin.order-details.print.render` | Print menu on the order detail page |

<Note>
  Additional `admin.{resource}-details.print.render` targets for product,
  draft order, gift card, customer, and shipment pages are planned as the
  respective admin detail pages get wired in. Track this in the
  [Roadmap](#roadmap--hardware-integration).
</Note>

## Extension manifest

Each print action is one entry in `extensions.printActions` on your
`app.json`. After install, each entry is persisted as a single schema
file at:

```
extensions/{domainSlug}/{appHandle}/print-actions/{handle}.schema.json
```

This is the file the API reads at print time. Reinstalling overwrites
the file; uninstalling removes the parent extension directory.

### Template-mode manifest

```json theme={null}
{
  "handle": "warehouse-bundle",
  "name": "Warehouse Tools",
  "version": "1.0.0",
  "extensions": {
    "printActions": [
      {
        "handle": "packing-slip",
        "title": "Packing slip",
        "target": "admin.order-details.print.render",
        "template": "<h1>Packing slip — Order {{ order.id }}</h1><p>Pull and pack items for shipment.</p>",
        "icon": "https://warehouse-bundle.example.com/icons/box.svg"
      }
    ]
  }
}
```

### Iframe-mode manifest

```json theme={null}
{
  "handle": "invoice-toolkit",
  "name": "Invoice Toolkit",
  "version": "1.0.0",
  "extensions": {
    "printActions": [
      {
        "handle": "branded-invoice",
        "title": "Branded invoice",
        "target": "admin.order-details.print.render",
        "appUrl": "https://invoice-toolkit.example.com/print/invoice",
        "icon": "https://invoice-toolkit.example.com/icons/invoice.svg"
      }
    ]
  }
}
```

### Fields

| Field      | Required | Description                                                                                                |
| ---------- | -------- | ---------------------------------------------------------------------------------------------------------- |
| `handle`   | yes      | URL-safe identifier — unique per app. Used as the schema filename and the API `handle` parameter.          |
| `target`   | yes      | Print slot. See [Available targets](#available-targets).                                                   |
| `title`    | yes      | Print menu label shown to the merchant. Also used as the new window's `<title>`.                           |
| `template` | one of   | Aqua/Liquid string rendered server-side with the resource context. Pick this **or** `appUrl`.              |
| `appUrl`   | one of   | HTTPS URL loaded as the printable page. Must serve a self-printing HTML view. Pick this **or** `template`. |
| `icon`     | no       | URL of an SVG/PNG icon shown next to the menu item.                                                        |

The schema persisted on disk is the manifest entry verbatim (with `name`
and `title` merged for back-compat). Schema mode is detected at print
time by checking `appUrl` first, then falling back to `template`.

## Rendering endpoint

When the merchant clicks the print menu item, the admin host sends a POST
to the platform to resolve the action:

```http theme={null}
POST /api/apps/print-action
Content-Type: application/json

{
  "domainSlug": "acme-store",
  "appHandle": "warehouse-bundle",
  "handle": "packing-slip",
  "resourceId": "ord_8a7f...",
  "resourceType": "order"
}
```

Required fields: `domainSlug`, `appHandle`, `handle`. `resourceId` and
`resourceType` are surfaced to your template and as iframe query params.

The endpoint is CORS-open (`Access-Control-Allow-Origin: *`) so the
the admin can call it cross-origin. Auth-gating happens at the
admin UI layer, not on this endpoint — anyone who knows the manifest
handle can hit it.

### Template-mode response

```json theme={null}
{
  "type": "html",
  "html": "<!DOCTYPE html>\n<html>\n  <head>...</head>\n  <body>\n    <h1>Packing slip — Order ord_8a7f...</h1>\n    <p>Pull and pack items for shipment.</p>\n    <script>window.addEventListener('load', function () { window.print(); });</script>\n  </body>\n</html>",
  "title": "Packing slip"
}
```

The host pops a new window, writes the returned HTML into it, and the
embedded `<script>window.print()</script>` fires automatically once the
page paints.

### Iframe-mode response

```json theme={null}
{
  "type": "iframe",
  "iframeUrl": "https://invoice-toolkit.example.com/print/invoice?resourceId=ord_8a7f...&resourceType=order&domainSlug=acme-store&handle=branded-invoice",
  "title": "Branded invoice"
}
```

The host opens `iframeUrl` in a new window. The query params are appended
automatically — see [Iframe-mode details](#iframe-mode-details).

### Error responses

| HTTP | Body                                                                                 | Cause                                                       |
| ---- | ------------------------------------------------------------------------------------ | ----------------------------------------------------------- |
| 400  | `{ error: "Missing required fields: domainSlug, appHandle, handle" }`                | Caller didn't pass one of the required fields.              |
| 400  | `{ error: "Print action manifest must declare either \`template\` or \`appUrl\`" }\` | Manifest is on disk but has neither field set.              |
| 404  | `{ error: "Print action manifest not found: ..." }`                                  | The schema file doesn't exist — install probably never ran. |
| 500  | `{ error: "Failed to render print template", details: "..." }`                       | Liquid compilation/render error.                            |
| 500  | `{ error: "Invalid print action manifest JSON", ... }`                               | The schema file on disk is corrupted.                       |

## Template-mode details

In template mode your Aqua/Liquid template is rendered server-side using
the same engine that powers theme rendering. **You get the full filter
library** (`money`, `date`, `default`, `escape`, etc.) and the full
`render` / `include` / `for` syntax.

The context exposed to the template is intentionally minimal in the
current build:

```javascript theme={null}
{
  resource:    { id: "ord_8a7f...", type: "order" },
  order:       { id: "ord_8a7f..." },  // mirrored under resourceType for natural access
  domainSlug:  "acme-store",
  handle:      "packing-slip"
}
```

When `resourceType` is `"order"` you can write `{{ order.id }}`; when
it's `"customer"` you'd write `{{ customer.id }}`. The mirror always
maps to the literal `resourceType` string, so an unknown resource type
will surface as `{{ <resourceType>.id }}`.

<Warning>
  The MVP context does **not** hydrate the full resource. `{{ order.line_items }}`,
  `{{ order.shipping_address }}`, `{{ order.customer.email }}` are all
  empty. If your template needs more than the id, use [iframe mode](#iframe-mode-details)
  and fetch the resource yourself.

  A future release will hydrate the resource via the backend so templates
  can render line items, addresses, and totals without an extra round
  trip. Until then, plan around the bare id.
</Warning>

The rendered HTML is wrapped in a minimal envelope:

```html theme={null}
<!DOCTYPE html>
<html>
  <head>
    <meta charset="utf-8" />
    <title>Packing slip</title>
    <style>
      body { font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; padding: 24px; }
      @media print { body { padding: 0; } }
    </style>
  </head>
  <body>
    <!-- your rendered template here -->
    <script>window.addEventListener('load', function () { window.print(); });</script>
  </body>
</html>
```

The default `@media print` rule zeroes padding so your content prints
edge-to-edge. Override it in your own `<style>` block if you need
margins.

### Example: packing slip template

```aqua theme={null}
<style>
  body { font-family: Arial, sans-serif; }
  h1 { border-bottom: 2px solid #000; padding-bottom: 8px; margin-bottom: 16px; }
  .meta { color: #666; font-size: 12px; margin-bottom: 24px; }
  .items { width: 100%; border-collapse: collapse; }
  .items th, .items td { border: 1px solid #ccc; padding: 8px; text-align: left; }
  .items th { background: #f4f4f4; }
  .footer { margin-top: 32px; font-size: 11px; color: #888; }
  @page { size: A4; margin: 12mm; }
</style>

<h1>Packing Slip</h1>
<p class="meta">Order {{ order.id }} · {{ domainSlug }}</p>

<table class="items">
  <thead>
    <tr><th>SKU</th><th>Qty</th><th>Item</th></tr>
  </thead>
  <tbody>
    {% comment %}
      MVP context exposes only the resource id. To list line items,
      switch to iframe mode and fetch from your backend.
    {% endcomment %}
    <tr>
      <td>—</td>
      <td>—</td>
      <td>Line items render here once the platform hydrates orders into the print context.</td>
    </tr>
  </tbody>
</table>

<div class="footer">
  Pulled and packed on {{ "now" | date: "%Y-%m-%d %H:%M" }} · Auto-generated.
</div>
```

### Example: minimal invoice template

```aqua theme={null}
<style>
  body { font-family: Georgia, serif; max-width: 720px; }
  .invoice-header { display: flex; justify-content: space-between; align-items: flex-start; }
  .invoice-header img { height: 40px; }
  h1 { font-size: 28px; margin: 0; }
  .invoice-meta { color: #555; font-size: 13px; }
  table { width: 100%; border-collapse: collapse; margin-top: 24px; }
  th, td { padding: 10px 8px; border-bottom: 1px solid #eee; }
  .total-row td { font-weight: bold; border-top: 2px solid #000; }
</style>

<div class="invoice-header">
  <div>
    <h1>Invoice</h1>
    <p class="invoice-meta">
      Invoice for order {{ order.id }}<br />
      Issued by {{ domainSlug }}
    </p>
  </div>
  <img src="https://acme-store.example.com/logo.png" alt="Acme" />
</div>

<table>
  <thead>
    <tr><th>Item</th><th>Qty</th><th>Price</th></tr>
  </thead>
  <tbody>
    <tr><td colspan="3" style="color:#888">Line items pending platform hydration.</td></tr>
    <tr class="total-row"><td colspan="2">Total</td><td>—</td></tr>
  </tbody>
</table>
```

## Iframe-mode details

In iframe mode your `appUrl` is opened in a new window with these query
parameters appended:

| Parameter      | Description                           |
| -------------- | ------------------------------------- |
| `resourceId`   | The id of the resource being printed. |
| `resourceType` | The resource type (`order`, etc.).    |
| `domainSlug`   | The merchant's domain slug.           |
| `handle`       | The print action handle.              |

If your `appUrl` already contains a query string, the params are merged.
If `appUrl` is unparseable as a URL (relative path, missing scheme), it
is returned as-is and the host opens it verbatim.

Your printable page should:

1. **Read the params.** They tell you which resource to render.
2. **Authenticate.** If you need to call your own backend, obtain a
   session token through [App Bridge](/app-bridge/session-tokens) and
   include it in your fetch.
3. **Render the page.** Same DOM you'd use for any printable web view.
4. **Print.** Call `window.print()` once the DOM is fully painted —
   typically inside a `load` handler or a short `setTimeout` so images
   have time to lay out.

```html theme={null}
<!doctype html>
<html>
  <head>
    <meta charset="utf-8" />
    <title>Invoice</title>
    <style>
      body { font-family: system-ui, sans-serif; padding: 24px; }
      @media print { body { padding: 0; } }
      h1 { margin: 0 0 16px; }
    </style>
  </head>
  <body>
    <h1>Invoice</h1>
    <div id="invoice">Loading…</div>

    <script>
      window.addEventListener('load', async () => {
        const params = new URLSearchParams(location.search);
        const order = await fetch(
          '/api/orders/' + params.get('resourceId'),
        ).then((r) => r.json());

        document.querySelector('#invoice').innerHTML = `
          <p>Order ${order.id}</p>
          <p>Total: ${order.total}</p>
        `;

        // Give the browser a tick to paint, then print.
        setTimeout(() => window.print(), 100);
      });
    </script>
  </body>
</html>
```

### Why use iframe mode

* You need the full resource (line items, addresses, customer fields)
  that template-mode context doesn't expose yet.
* The printable view depends on data only your service has
  (subscription cadence, custom inventory state, multi-warehouse pick
  paths).
* You want to ship marketing/branded HTML that you control end-to-end
  without redeploying the merchant's app.

### Why use template mode

* Zero app-server work at print time. The platform is the only thing
  online.
* You only need the resource id stamped on a layout (gift receipt with
  no prices, simple thank-you slip).
* You want offline-friendly behaviour — no need for the merchant to
  have working internet to your app, only to LaunchMyStore.

## Installing print actions

Print actions install through the same `/api/apps/install-extensions`
endpoint as other extension types. Add a `printActions` array under
`extensions`:

```bash theme={null}
curl -X POST https://store.launchmystore.io/api/apps/install-extensions \
  -H "Content-Type: application/json" \
  -d '{
    "domainSlug": "acme-store",
    "appHandle": "warehouse-bundle",
    "extensions": {
      "printActions": [
        {
          "handle": "packing-slip",
          "title": "Packing slip",
          "target": "admin.order-details.print.render",
          "template": "<h1>Packing slip — {{ order.id }}</h1>"
        },
        {
          "handle": "branded-invoice",
          "title": "Branded invoice",
          "target": "admin.order-details.print.render",
          "appUrl": "https://invoice-toolkit.example.com/print/invoice"
        }
      ]
    }
  }'
```

After install, each entry lives in:

```
extensions/{domainSlug}/{appHandle}/print-actions/{handle}.schema.json
```

The platform picks these up and exposes them under the
`admin_print_action` type to the host, which renders them as menu items
inside the merchant's "Print" dropdown in the admin.

Subsequent installs **overwrite** existing schema files — there is no
"add to existing" semantics. Always send your full set of print actions
on each install.

## Multiple print actions per app

One app can register many print actions for the same target — they all
appear in the merchant's "Print" menu and are sorted by `handle`. This
is the typical pattern for warehouse / fulfilment apps that ship a
suite (packing slip + pick list + return form + gift receipt):

```json theme={null}
{
  "extensions": {
    "printActions": [
      { "handle": "packing-slip", "title": "Packing slip", "target": "admin.order-details.print.render", "template": "..." },
      { "handle": "pick-list",    "title": "Pick list",    "target": "admin.order-details.print.render", "template": "..." },
      { "handle": "return-form",  "title": "Return form",  "target": "admin.order-details.print.render", "appUrl":  "..." },
      { "handle": "gift-receipt", "title": "Gift receipt", "target": "admin.order-details.print.render", "template": "..." }
    ]
  }
}
```

## Use cases

* **Packing slips** — pulled at the warehouse, listing what to grab
  before sealing the box.
* **Branded invoices** — custom layouts with the merchant's logo, terms,
  bank details, tax IDs.
* **Gift receipts** — recipient-friendly receipts that hide prices.
* **Return labels / RMA forms** — pre-filled return paperwork the
  customer service rep can hand to the customer.
* **Pick lists** — multi-order picking sheets used by fulfilment teams.
* **Compliance documents** — country-specific export forms, dangerous
  goods declarations, customs invoices.

## Print action vs admin action

|                 | Admin print action                    | [Admin action](/extensions/admin-actions) |
| --------------- | ------------------------------------- | ----------------------------------------- |
| Output          | Printable HTML opened in a new window | Interactive modal iframe                  |
| Trigger         | Click "Print" menu item               | Click action button                       |
| Auto-prints     | Yes (`window.print()` on load)        | No                                        |
| Manifest field  | `extensions.printActions[]`           | `extensions.adminActions[]`               |
| `type` field    | `admin_print_action`                  | `admin_action`                            |
| Server endpoint | `POST /api/apps/print-action`         | (none — host loads iframe directly)       |
| Template mode   | Yes — Aqua/Liquid string              | No — iframe only                          |
| Use cases       | Packing slips, invoices, labels       | Refunds, resync, one-shot edits           |

## Roadmap & hardware integration

The current build is intentionally minimal — printable HTML + browser
print dialog. Several extensions are tracked for future releases:

* **Headless-Chrome PDF rendering.** Optional server-side flag on the
  manifest (`renderAs: 'pdf'`) that returns a PDF buffer instead of
  HTML, so the merchant can save / email the result without re-rendering
  client-side.
* **Resource hydration in template mode.** Expose the full order /
  product / customer object on the Aqua context so templates can render
  line items, addresses, and totals without iframe mode.
* **More targets.** Print actions on the product detail page (barcode
  labels), draft order page (proforma invoice), gift card page (printed
  gift card), and shipment page (carrier label).
* **Receipt-printer / thermal-printer integration.** Today the platform
  uses the browser print dialog only — any printer the OS exposes works
  (laser, inkjet, label printer, thermal receipt printer). Direct ESC/POS
  or ZPL output to a USB/network receipt printer (without the browser
  dialog round-trip) is **not** wired and will require a native bridge
  or a WebUSB-based POS-side helper. Track this in the roadmap; for now,
  point your thermal printer at the OS print queue and pick it in the
  browser dialog.
* **Print presets.** Save a merchant's preferred paper size, orientation,
  and margins per action so the dialog opens with the right defaults.
* **Bulk print.** Select N orders in the order list and run a single
  print action across all of them (useful for end-of-day fulfilment).

If you need any of these sooner, raise it on the developer forum so we
can prioritize.

## See also

* [Admin Actions](/extensions/admin-actions) — modal-triggered UI variant.
* [Admin Blocks](/extensions/admin-blocks) — inline iframe panels.
* [Aqua Filters](/aqua/filters) — for use inside `template` strings.
* [App Bridge: Session tokens](/app-bridge/session-tokens) — for
  authenticating iframe-mode requests to your backend.
