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

# Checkout UI Extensions

> Add iframe-rendered UI into the checkout at named target slots

# Checkout UI Extensions

A checkout extension is a **sandboxed iframe** rendered at a named slot in
the checkout. It loads from your app's domain, talks back to the
host via the [App Bridge](/app-bridge/overview) postMessage protocol, and
self-resizes through a `APP_BRIDGE_RESIZE` message.

There is **no `@launchmystore/checkout-ui-extensions-react` package** and no
custom hook surface. The host renders your iframe URL inside
`<iframe sandbox="allow-scripts allow-forms allow-popups allow-same-origin">`
and you build whatever UI you want with plain HTML/CSS/JS — or React, if you
ship your own React bundle.

<Note>
  For showing toasts, reading the live cart, applying discount codes, setting
  the order note, updating cart attributes, or changing cart lines from inside
  your iframe, see [App Bridge for Checkout](/app-bridge/checkout). That page
  documents every wired action and its postMessage payload.
</Note>

## Target Slots

The host renders extensions at any of the following targets. The validator
in `/api/apps/checkout-extensions` accepts target strings matching these
prefixes:

* `checkout-*` (legacy slot-style, current canonical names)
* `checkout.*` (bare dot-style alias)
* `purchase.checkout.*` (namespaced dot-style alias)
* `purchase.thank-you.*` and `purchase.order-status.*` (post-order)

**Currently wired slots**:

| Target                                              | Renders at                                                                 |
| --------------------------------------------------- | -------------------------------------------------------------------------- |
| `checkout-contact-after`                            | Between email and shipping address                                         |
| `checkout-shipping-after`                           | After shipping address form                                                |
| `checkout-shipping-method-before`                   | Above the Shipping Methods card                                            |
| `checkout-payment-before`                           | Above payment methods                                                      |
| `checkout-payment-after`                            | Below payment methods                                                      |
| `checkout-order-summary-before`                     | Top of the right-side summary                                              |
| `checkout-order-summary-after`                      | Bottom of the right-side summary                                           |
| `purchase.checkout.cart-line-list.render-after`     | After the cart line list                                                   |
| `purchase.checkout.reductions.render-after`         | After the discount-code row                                                |
| `purchase.checkout.actions.render-before`           | Above the place-order button                                               |
| `purchase.thank-you.block.render`                   | Order status page, below the status card — first visit after checkout only |
| `purchase.order-status.block.render`                | Order status page, below the status card — every visit                     |
| `purchase.thank-you.cart-line-list.render-after`    | Order status page, after the order summary line list — first visit only    |
| `purchase.order-status.cart-line-list.render-after` | Order status page, after the order summary line list — every visit         |

<Note>
  Targets not in this list won't render even if the API accepts them —
  they're reserved until the platform wires the matching slot.
</Note>

## Manifest

Declare checkout extensions under `extensions.checkoutExtensions[]` in your
app's `app.json`:

```json theme={null}
{
  "handle": "my-checkout-app",
  "name": "Checkout Enhancements",
  "version": "1.0.0",
  "extensions": {
    "checkoutExtensions": [
      {
        "handle": "trust-badges",
        "target": "checkout-payment-after",
        "iframeUrl": "https://my-app.example.com/extensions/trust-badges.html",
        "settings": { "showFreeShippingBadge": true }
      },
      {
        "handle": "upsell-products",
        "target": "purchase.checkout.cart-line-list.render-after",
        "iframeUrl": "https://my-app.example.com/extensions/upsell.html"
      }
    ]
  }
}
```

| Field       | Required | Description                                              |
| ----------- | -------- | -------------------------------------------------------- |
| `handle`    | yes      | Unique handle for this extension within the app.         |
| `target`    | yes      | One of the wired slots above.                            |
| `iframeUrl` | yes      | HTTPS URL of the page rendered inside the iframe.        |
| `appId`     | no       | App id (defaults to install folder name).                |
| `appName`   | no       | Display name (defaults to the manifest's `name`).        |
| `settings`  | no       | Arbitrary JSON, exposed to your iframe via query string. |

## How the Host Renders It

The checkout host mounts a sandboxed iframe per matched extension:

```html theme={null}
<iframe
  src="<your-iframeUrl>"
  sandbox="allow-scripts allow-forms allow-popups allow-same-origin"
  loading="lazy"
  scrolling="no"
  style="width: 100%; border: 0; height: 60px;"
/>
```

The default height is **60px** (see [Resize](#resizing-the-iframe) below
for how growth is applied).

If your iframe fails to load — `onerror`, or it loads to an empty document
(`<body>` has no children and no `<title>`) — the slot hides itself. There
is no error message; the slot just disappears.

## Bootstrap: read the App Bridge handshake

When you load, **wait for `BRIDGE_PING`** from the host before dispatching
anything. The host posts:

```js theme={null}
{ type: 'APP_BRIDGE_HOST_INIT', host: 'checkout' }
```

In practice your iframe can also send a `BRIDGE_PING` action and wait for
the response — the host always replies `{ ok: true, host: 'checkout' }`.

```html theme={null}
<!DOCTYPE html>
<html>
<head><meta charset="utf-8"><title>My extension</title></head>
<body>
  <button id="apply">Apply WELCOME10</button>
  <script>
    function send(action, payload) {
      const id = `${action}-${Date.now()}-${Math.random().toString(36).slice(2)}`;
      return new Promise((resolve, reject) => {
        const handler = (ev) => {
          const d = ev.data;
          if (!d || d.type !== 'APP_BRIDGE_RESPONSE' || d.id !== id) return;
          window.removeEventListener('message', handler);
          d.error ? reject(new Error(d.error)) : resolve(d.payload);
        };
        window.addEventListener('message', handler);
        window.parent.postMessage({ type: 'APP_BRIDGE_ACTION', action, id, payload }, '*');
      });
    }

    document.getElementById('apply').addEventListener('click', async () => {
      try {
        await send('DISCOUNT_CODE_CHANGE', { type: 'addDiscountCode', code: 'WELCOME10' });
        await send('TOAST_SHOW', { message: 'Discount applied!', variant: 'success' });
      } catch (e) {
        await send('TOAST_SHOW', { message: e.message, variant: 'error' });
      }
    });
  </script>
</body>
</html>
```

You don't need an SDK — the wire format is small enough to inline. The
[`@launchmystore/app-bridge`](/app-bridge/overview) npm package wraps the
same protocol if you'd rather use a typed client.

## Wired Actions (Checkout host)

Full list with payload shapes is in
[App Bridge for Checkout](/app-bridge/checkout). Quick reference:

| Action                 | What it does                                               |
| ---------------------- | ---------------------------------------------------------- |
| `BRIDGE_PING`          | Handshake; returns `{ host: 'checkout' }`                  |
| `TOAST_SHOW`           | Show a 200-char toast                                      |
| `CART_GET`             | Returns `{cartId, items[], itemCount, currency, note}`     |
| `CHECKOUT_TOTALS_GET`  | Returns subtotal / discounts / shipping / tax / finalPrice |
| `CUSTOMER_GET`         | Returns `{ email }`                                        |
| `CURRENCY_GET`         | Returns `{ currency }`                                     |
| `CART_LINES_CHANGE`    | `addCartLine` / `updateCartLine` / `removeCartLine` ops    |
| `DISCOUNT_CODE_CHANGE` | `addDiscountCode` (apply); remove not yet wired            |
| `NOTE_CHANGE`          | `updateNote` / `removeNote`                                |
| `ATTRIBUTE_CHANGE`     | `updateAttribute` / `removeAttribute`                      |
| `ORDER_NOTE_SET`       | Legacy alias for `updateNote`                              |
| `COUPON_APPLY_REQUEST` | Legacy alias for `addDiscountCode`                         |
| `GIFT_CARD_CHANGE`     | Returns `{ ok:false, applicable:false }` (not yet wired)   |

Anything else (modal, resource picker, buyer-journey intercept,
shippingAddress edits, metafield writes) is **not** wired on the checkout
host — those actions are listed in the App Bridge SDK but the response will
time out after 10s on `/checkout`.

## Resizing the Iframe

The iframe starts at **60px**. Post `APP_BRIDGE_RESIZE` to grow. Include
your `extensionId` (available on the iframe URL query string) so the host
applies the height to the right iframe:

```js theme={null}
const extensionId = new URLSearchParams(location.search).get('extensionId');
window.parent.postMessage({ type: 'APP_BRIDGE_RESIZE', extensionId, height: 320 }, '*');
```

The extension slot that owns the iframe height filters resize messages by
`extensionId` (a message from a different iframe is ignored) and applies
`Math.ceil(height)` — the minimum is the 60px default and there is no upper
clamp on this path. (A separate App Bridge checkout handler additionally
clamps to `[60, 2000]px` for iframes it manages directly.)

## Reading Settings From `app.json`

The host does NOT forward `settings` into the iframe URL automatically.
If your extension needs runtime config, encode it in your own iframe URL
when you install:

```json theme={null}
{
  "handle": "trust-badges",
  "target": "checkout-payment-after",
  "iframeUrl": "https://my-app.example.com/extensions/trust-badges.html?showFreeShipping=1&theme=light"
}
```

Or fetch the install record from your backend by passing the merchant's
domain in the iframe and looking up settings server-side.

## Installing an Extension

Extensions are deployed by the install pipeline via
`POST /api/apps/install-extensions`. This is a **server-to-server**
endpoint gated by the platform's internal API key (no CORS) — it's invoked
by the backend during the app install flow, not called directly from a
browser with a merchant access token. The only browser-accepted variant is
a `fromCatalog: true` catalog copy, which also requires the merchant to own
the target store. The payload mirrors the manifest:

```javascript theme={null}
await fetch('https://store.launchmystore.io/api/apps/install-extensions', {
  method: 'POST',
  headers: {
    'Content-Type': 'application/json',
    'X-Internal-API-Key': INTERNAL_API_KEY
  },
  body: JSON.stringify({
    domainSlug: 'merchant-store',
    appHandle: 'my-checkout-app',
    checkoutExtensions: [
      {
        handle: 'trust-badges',
        target: 'checkout-payment-after',
        iframeUrl: 'https://my-app.example.com/extensions/trust-badges.html'
      }
    ]
  })
});
```

Local-only installs (file-system manifests under
`extensions/{domainSlug}/{appHandle}/app.json`) are also picked up
by `/api/apps/checkout-extensions` — useful for dev/test apps.

## Caching

`/api/apps/checkout-extensions` returns a 5-minute `Cache-Control` header
and the checkout client caches the extension list per session.
After publishing a manifest change,
the merchant won't see it for up to 5 minutes unless they hard-reload.

## Security

<AccordionGroup>
  <Accordion title="Iframe sandbox">
    Extensions render in a sandbox with only `allow-scripts allow-forms
            allow-popups allow-same-origin`. They cannot break out, drive the
    parent DOM, or read the parent's cookies — the only cross-frame
    channel is the App Bridge postMessage protocol.
  </Accordion>

  <Accordion title="Use HTTPS in production">
    Serve `iframeUrl` over HTTPS so it loads on the HTTPS checkout. The
    install pipeline does **not** enforce the scheme — absolute URLs pass
    through untouched (local dev may resolve to `http`) — so HTTPS is your
    responsibility.
  </Accordion>

  <Accordion title="No access to payment data">
    The bridge never exposes card numbers, payment tokens, or PII beyond
    what `CART_GET` / `CUSTOMER_GET` / `CHECKOUT_TOTALS_GET` return.
  </Accordion>

  <Accordion title="Validate user input server-side">
    Anything the customer types into your extension is in their browser.
    Always validate again on your own backend before persisting.
  </Accordion>
</AccordionGroup>

## Testing

1. **Install on a development store** — install your app on a dev store
   and reload `/checkout` to render the iframe.
2. **Browser end-to-end** — drive `/checkout` on your dev store with a
   headless browser and screenshot the slot. JSON manifests prove the
   install endpoint works; only a real screenshot proves the iframe
   rendered and the bridge handshake completed.
3. **Bridge handshake** — send `BRIDGE_PING` immediately on load and log
   the response. If the response never arrives the parent isn't the
   checkout host (e.g. you're previewing in a standalone tab) and you
   should render a graceful "preview mode" fallback.

## See Also

* [App Bridge for Checkout](/app-bridge/checkout) — full action payload
  reference for the checkout host.
* [Post-Purchase Extensions](/extensions/post-purchase) — same iframe
  model on the order status (thank-you) page; smaller wired-action set.
* [Functions](/functions/overview) — declarative business logic that runs
  server-side instead of as a UI iframe.
