Skip to main content

Post-Purchase Extensions

A post-purchase extension is an HTML page hosted by your app, loaded into a sandboxed <iframe> on the unified order status (thank-you) page (/orders/<id>?thank_you=1) immediately after payment is confirmed. The thank-you view and the order status page are a single page; post-purchase iframes render only on that first post-checkout visit, below the order status card. It is the standard slot for one-click upsells, surveys, loyalty enrolment, app-download prompts, and similar “while-the-customer-is-still-engaged” moments. The host is the platform’s post-purchase App Bridge, mounted on the order status page. Your iframe talks to it over the same postMessage wire format used by every other App Bridge host. The action set is a subset of the in-checkout bridge — read-only data + clipboard + redirect + a one-line cart “add” used to drive Option B (separate-order) upsells.
Need a block that shows on every order status visit (not just the first one), or precise control over its position? Use a Checkout UI extension with a purchase.order-status.* / purchase.thank-you.* target instead.

How it loads

Manifest

Register one or more entries under extensions.postPurchaseExtensions in your app’s app.json:
{
  "handle": "my-upsell-app",
  "name": "Post-Purchase Upsells",
  "version": "1.0.0",
  "extensions": {
    "postPurchaseExtensions": [
      {
        "handle": "order-upsell",
        "appName": "Post-Purchase Upsells",
        "iframeUrl": "/extensions/my-domain/my-upsell-app/post-purchase/upsell.html",
        "target": "post-purchase",
        "timeout": 60000
      }
    ]
  }
}
FieldRequiredDescription
handleyesUnique handle within the app. Used as the manifest key.
iframeUrlyesAbsolute or relative URL to your iframe HTML. Relative URLs resolve against the storefront origin. The host appends ?orderId=<id>.
appIdnoApp id; defaults to the app folder name.
appNamenoDisplay name; falls back to manifest.name.
targetnoDefaults to post-purchase. Reserved for future placement targets.
timeoutnoAccepted by the manifest API but not enforced by the current host. The extension stays mounted until the customer dispatches DONE or REDIRECT, navigates away, or the page is closed. Reserved for future host enforcement — do not rely on it as an automatic exit.
Declarative entries (iframeUrl: null) are accepted by the manifest API but the post-purchase consumer skips them — only entries with an iframeUrl actually render. Use a hosted HTML page if you want UI on this slot.
Authoring from the developer portal / API. If you register extensions through the developer-portal endpoint (POST /apps/developer/:appId/extensions) instead of the app.json manifest, use the first-class extension type: "post_purchase" (with url pointing at your iframe HTML). It maps to the same postPurchaseExtensions category as the manifest above and surfaces on installed stores regardless of the app’s marketplace publish status. Do not author a post-purchase extension as type: "checkout_extension" with a post-purchase target — that lands in the checkout list and the checkout target validator rejects it.

Iframe environment

sandbox="allow-scripts allow-forms allow-popups allow-same-origin"
  • The iframe loads at whatever origin you ship from. If the URL is relative (e.g. /extensions/{domainSlug}/{appHandle}/post-purchase/upsell.html) it’s served same-origin from the storefront.
  • The page is given the orderId of the just-placed order in ?orderId=<id>.
  • The host starts the iframe at height: 120px and resizes it on demand — send APP_BRIDGE_RESIZE messages (see below) so your content isn’t clipped.

App Bridge actions available

Your iframe drives the host with the standard App Bridge wire format:
window.parent.postMessage(
  { type: 'APP_BRIDGE_ACTION', action: '<NAME>', id: '<unique-id>', payload: { /* ... */ } },
  '*'
);
Use the SDK (@launchmystore/app-bridge) instead of hand-rolling these in production — but every example below works as raw postMessage too.
ActionPurpose
BRIDGE_PINGHandshake. Responds { ok, host: 'post-purchase', orderId }.
ORDER_GETReturns a summary of the just-placed order (see below).
CUSTOMER_GETReturns { email, name } for the buyer.
CURRENCY_GETReturns { currency } (ISO 4217, e.g. "INR").
TOAST_SHOWShows a host toast. Payload { message, variant? }.
CLIPBOARD_WRITECopies a string into the clipboard. Payload { text }.
REDIRECTNavigates the parent page. Payload { url, newTab? }.
CART_LINES_CHANGEAdds lines to a fresh cart for the Option B upsell flow.
DONECloses the post-purchase slot and routes to /orders/<id>.
Other App Bridge actions (MODAL_OPEN, RESOURCE_PICKER_OPEN, CART_GET, discount/note/attribute changes, buyer-journey intercept, …) are not wired on the post-purchase host. Messages with unknown actions are dropped silently — your dispatchAndWait() will reject after the 10 s timeout.

ORDER_GET response shape

{
  orderId: string,            // UUID of the placed order
  orderNumber: string | null, // human-readable invoice id (e.g. "#1042")
  status: string | null,      // 'paid' | 'pending' | …
  items: Array<{
    id: string,               // orderProductId
    productId: string,
    variantId: string,
    title: string,
    quantity: number,
    price: number             // unit, in display currency
  }>,
  itemCount: number,
  subtotal: number,
  shipping: number,
  tax: number,
  total: number,
  currency: string | null,
  email: string
}
The host first answers with what it has at success-page mount (verify-payment metadata — totals + email + currency, no line items). It then lazily fetches GET /orders/get-single-order/:orderId/:domainSlug in the background. A second ORDER_GET call (or one issued ~250 ms after BRIDGE_PING) will include items[]. If your upsell needs the line items, wait for them rather than relying on the first call.

Option B upsell flow

Use this when you want to offer “Add this to your order” after the order is already placed, without re-charging the original payment. The new line is added to a fresh cart and the customer is sent through /checkout again. From the customer’s perspective it’s a one-step add; from the back-office’s perspective it’s a second order against the same buyer.
import { createApp } from '@launchmystore/app-bridge';
import { Cart, Redirect } from '@launchmystore/app-bridge/actions';

const app = createApp({ apiKey: '<your-api-key>', host: 'post-purchase' });
const cart = Cart.create(app);
const redirect = Redirect.create(app);

async function addUpsellAndCheckout(merchandiseGid, quantity = 1) {
  await cart.applyCartLinesChange({
    changes: [
      {
        type: 'addCartLine',
        merchandiseId: merchandiseGid,   // gid://launchmystore/Variant/<uuid>
        quantity,
        attributes: [
          { key: '_source', value: 'post_purchase_upsell' },
          { key: '_parent_order', value: window.parentOrderId }
        ]
      }
    ]
  });
  redirect.dispatch({ url: '/checkout' });
}
The host:
  1. Resolves gid://launchmystore/Variant/<uuid> → variant UUID.
  2. Looks up the matching line in the placed order to copy name, image, originalPrice, salePrice, discountPrice onto the new cart line so /checkout doesn’t show a UUID-only placeholder.
  3. Primes the storefront cart state so cart persistence runs for the correct store.
  4. Adds the item to the storefront cart.
updateCartLine / removeCartLine are not allowed in the post-purchase host — they return { ok: false, error: 'only addCartLine supported post-purchase' }.
True one-click upsells (re-using the original payment method, single order, no second checkout) require saved-card / changeset APIs and are tracked separately. Until those land, Option B is the supported path.

Skipping straight to the order page

import { createApp } from '@launchmystore/app-bridge';
const app = createApp({ apiKey, host: 'post-purchase' });

document.getElementById('skip').addEventListener('click', () => {
  app.dispatch('DONE');
});
DONE calls router.push('/orders/<orderId>'). If the host has lost the orderId for some reason it falls back to /orders.

Resizing the iframe

The slot starts at 120 px. Send your real content height after layout:
<script>
  const sendHeight = () => {
    window.parent.postMessage(
      {
        type: 'APP_BRIDGE_RESIZE',
        height: document.documentElement.scrollHeight
      },
      '*'
    );
  };

  window.addEventListener('load', sendHeight);
  new ResizeObserver(sendHeight).observe(document.documentElement);
</script>
The host clamps to [80, 2000] px.

Minimal upsell example (raw HTML)

<!doctype html>
<html lang="en">
<head>
  <meta charset="utf-8" />
  <title>Post-purchase upsell</title>
  <style>
    body { font: 14px/1.4 system-ui, sans-serif; padding: 16px; }
    .pp-card { display: flex; gap: 12px; align-items: center; }
    .pp-card img { width: 64px; height: 64px; object-fit: cover; border-radius: 8px; }
    .pp-actions { margin-top: 12px; display: flex; gap: 8px; }
    button { padding: 8px 14px; border-radius: 8px; border: 1px solid #d0d0d0; cursor: pointer; background: #fff; }
    button.primary { background: #111; color: #fff; border-color: #111; }
  </style>
</head>
<body>
  <h3 style="margin: 0 0 8px 0;">Add a matching pair — 25% off, today only</h3>
  <div class="pp-card">
    <img id="img" alt="" />
    <div>
      <div id="title" style="font-weight: 600;"></div>
      <div id="price" style="color: #666;"></div>
    </div>
  </div>
  <div class="pp-actions">
    <button class="primary" id="add">Add to my order</button>
    <button id="skip">No thanks</button>
  </div>

  <script type="module">
    import { createApp } from 'https://esm.sh/@launchmystore/app-bridge';
    import { Cart, Redirect } from 'https://esm.sh/@launchmystore/app-bridge/actions';

    const params = new URLSearchParams(location.search);
    const orderId = params.get('orderId');

    const app = createApp({ apiKey: 'YOUR_API_KEY', host: 'post-purchase' });
    const cart = Cart.create(app);
    const redirect = Redirect.create(app);

    // Resize once layout settles
    const sendHeight = () =>
      parent.postMessage(
        { type: 'APP_BRIDGE_RESIZE', height: document.documentElement.scrollHeight },
        '*'
      );
    addEventListener('load', sendHeight);
    new ResizeObserver(sendHeight).observe(document.documentElement);

    // Lazy-fetched line items take a tick; fetch your offer using your
    // own backend keyed on orderId.
    const offer = await (await fetch(`/api/upsell?orderId=${orderId}`)).json();
    document.getElementById('img').src = offer.image;
    document.getElementById('title').textContent = offer.title;
    document.getElementById('price').textContent = offer.priceFormatted;

    document.getElementById('add').addEventListener('click', async () => {
      await cart.applyCartLinesChange({
        changes: [{ type: 'addCartLine', merchandiseId: offer.variantGid, quantity: 1 }]
      });
      redirect.dispatch({ url: '/checkout' });
    });

    document.getElementById('skip').addEventListener('click', () => {
      app.dispatch('DONE');
    });
  </script>
</body>
</html>
Drop this file at extensions/{domainSlug}/{appHandle}/post-purchase/upsell.html and point the manifest’s iframeUrl at it.

Authoring with the SDK

The full SDK surface is at /app-bridge/actions. The actions you’ll use on this host are:
import { createApp } from '@launchmystore/app-bridge';
import {
  Cart,
  Redirect,
  Toast,
  Clipboard
} from '@launchmystore/app-bridge/actions';

const app = createApp({ apiKey, host: 'post-purchase' });

const order = await app.dispatchAndWait('ORDER_GET');
const cust  = await app.dispatchAndWait('CUSTOMER_GET');

Toast.create(app).show({ message: `Thanks ${cust.name || 'there'}!`, variant: 'success' });

await Cart.create(app).applyCartLinesChange({
  changes: [{ type: 'addCartLine', merchandiseId, quantity: 1 }]
});

Redirect.create(app).dispatch({ url: '/checkout' });

Best practices

ORDER_GET answers immediately with metadata but no items[]. Re-ask after ~300 ms (or right before computing the offer) — the host backfills items lazily.
The slot starts at 120 px. Without a resize message your content gets clipped or scrolled inside the iframe and looks broken on mobile.
Render a clearly labelled Skip / Continue button that dispatches DONE (or REDIRECT '/orders/<id>'). The manifest timeout field is currently not enforced, so a missing exit button traps the customer. Never block them — they came to see their order confirmation.
When you call applyCartLinesChange, pass attributes like _source: 'post_purchase_upsell' and _parent_order: <orderId> so the merchant can attribute the second order in reporting.
After payment, the buyer lands on /orders/<id>?thank_you=1. Drive Puppeteer through a real order to confirm your iframe shows up at the expected size and the upsell click cascades into the second /checkout flow.

Local dev

  1. Place your iframe HTML under extensions/{domainSlug}/{appHandle}/post-purchase/upsell.html (relative to LaunchMyStore, served from the storefront origin — same-origin with the host).
  2. Add the entry to extensions/{domainSlug}/{appHandle}/app.json under extensions.postPurchaseExtensions[].
  3. Place a test order. After payment you land on /orders/<id>?thank_you=1. Your iframe should render inside the post-purchase card below the order status card.
The manifest cache lives in-process with a 60 s TTL — restart the dev server after editing app.json if you want changes picked up faster.

See also