Skip to main content

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 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.
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. That page documents every wired action and its postMessage payload.

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:
TargetRenders at
checkout-contact-afterBetween email and shipping address
checkout-shipping-afterAfter shipping address form
checkout-shipping-method-beforeAbove the Shipping Methods card
checkout-payment-beforeAbove payment methods
checkout-payment-afterBelow payment methods
checkout-order-summary-beforeTop of the right-side summary
checkout-order-summary-afterBottom of the right-side summary
purchase.checkout.cart-line-list.render-afterAfter the cart line list
purchase.checkout.reductions.render-afterAfter the discount-code row
purchase.checkout.actions.render-beforeAbove the place-order button
purchase.thank-you.block.renderOrder status page, below the status card — first visit after checkout only
purchase.order-status.block.renderOrder status page, below the status card — every visit
purchase.thank-you.cart-line-list.render-afterOrder status page, after the order summary line list — first visit only
purchase.order-status.cart-line-list.render-afterOrder status page, after the order summary line list — every visit
Targets not in this list won’t render even if the API accepts them — they’re reserved until the platform wires the matching slot.

Manifest

Declare checkout extensions under extensions.checkoutExtensions[] in your app’s app.json:
{
  "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"
      }
    ]
  }
}
FieldRequiredDescription
handleyesUnique handle for this extension within the app.
targetyesOne of the wired slots above.
iframeUrlyesHTTPS URL of the page rendered inside the iframe.
appIdnoApp id (defaults to install folder name).
appNamenoDisplay name (defaults to the manifest’s name).
settingsnoArbitrary JSON, exposed to your iframe via query string.

How the Host Renders It

The checkout host mounts a sandboxed iframe per matched extension:
<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; resize requests are clamped to [60, 2000]px (see Resize below). 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:
{ 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' }.
<!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 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. Quick reference:
ActionWhat it does
BRIDGE_PINGHandshake; returns { host: 'checkout' }
TOAST_SHOWShow a 200-char toast
CART_GETReturns {cartId, items[], itemCount, currency, note}
CHECKOUT_TOTALS_GETReturns subtotal / discounts / shipping / tax / finalPrice
CUSTOMER_GETReturns { email }
CURRENCY_GETReturns { currency }
CART_LINES_CHANGEaddCartLine / updateCartLine / removeCartLine ops
DISCOUNT_CODE_CHANGEaddDiscountCode (apply); remove not yet wired
NOTE_CHANGEupdateNote / removeNote
ATTRIBUTE_CHANGEupdateAttribute / removeAttribute
ORDER_NOTE_SETLegacy alias for updateNote
COUPON_APPLY_REQUESTLegacy alias for addDiscountCode
GIFT_CARD_CHANGEReturns { 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:
window.parent.postMessage({ type: 'APP_BRIDGE_RESIZE', height: 320 }, '*');
The host clamps to [60, 2000]px and applies the height to the iframe that posted the message — there is no extensionId field on the checkout host’s resize handler. So one iframe can’t resize another.

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:
{
  "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

Apps install extensions via POST /api/apps/install-extensions. The payload mirrors the manifest:
await fetch('https://store.launchmystore.io/api/apps/install-extensions', {
  method: 'POST',
  headers: {
    'Content-Type': 'application/json',
    Authorization: `Bearer ${accessToken}`
  },
  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

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.
iframeUrl must be HTTPS. The validator silently rejects HTTP URLs in production.
The bridge never exposes card numbers, payment tokens, or PII beyond what CART_GET / CUSTOMER_GET / CHECKOUT_TOTALS_GET return.
Anything the customer types into your extension is in their browser. Always validate again on your own backend before persisting.

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 — full action payload reference for the checkout host.
  • Post-Purchase Extensions — same iframe model on the order status (thank-you) page; smaller wired-action set.
  • Functions — declarative business logic that runs server-side instead of as a UI iframe.