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

# Post-Purchase Extensions

> Show an upsell, survey, or loyalty offer right after payment on the order status page

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

<Note>
  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](/extensions/checkout-ui) with a
  `purchase.order-status.*` / `purchase.thank-you.*` target instead.
</Note>

## How it loads

```mermaid theme={null}
sequenceDiagram
    participant Customer
    participant Checkout
    participant PaymentGateway as Payment
    participant OrderStatus as /orders/&lt;id&gt;?thank_you=1
    participant Iframe as Your post-purchase iframe

    Customer->>Checkout: Submits order
    Checkout->>Payment: Charge
    Payment-->>Checkout: Authorised
    Checkout->>OrderStatus: Verify + redirect (thank_you=1)
    OrderStatus->>Iframe: Load extension(s) inline
    Iframe->>OrderStatus: ORDER_GET / CUSTOMER_GET (App Bridge)
    Iframe-->>Customer: Renders upsell / survey / etc.
    alt Customer accepts upsell
      Iframe->>OrderStatus: CART_LINES_CHANGE (addCartLine)
      Iframe->>OrderStatus: REDIRECT '/checkout'
      OrderStatus-->>Checkout: Fresh order flow
    else Customer skips / done
      Iframe->>OrderStatus: DONE (iframe dismissed; customer stays on the page)
    end
```

## Manifest

Register one or more entries under `extensions.postPurchaseExtensions` in
your app's `app.json`:

```json theme={null}
{
  "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
      }
    ]
  }
}
```

| Field       | Required | Description                                                                                                                                                                                                                                                                  |
| ----------- | -------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `handle`    | yes      | Unique handle within the app. Used as the manifest key.                                                                                                                                                                                                                      |
| `iframeUrl` | yes      | Absolute or relative URL to your iframe HTML. Relative URLs resolve against the storefront origin. The host appends `?orderId=<id>`.                                                                                                                                         |
| `appId`     | no       | App id; defaults to the app folder name.                                                                                                                                                                                                                                     |
| `appName`   | no       | Display name; falls back to `manifest.name`.                                                                                                                                                                                                                                 |
| `target`    | no       | Defaults to `post-purchase`. Reserved for future placement targets.                                                                                                                                                                                                          |
| `timeout`   | no       | **Accepted 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.

<Note>
  **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.
</Note>

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

```js theme={null}
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.

| Action              | Purpose                                                       |
| ------------------- | ------------------------------------------------------------- |
| `BRIDGE_PING`       | Handshake. Responds `{ ok, host: 'post-purchase', orderId }`. |
| `ORDER_GET`         | Returns a summary of the just-placed order (see below).       |
| `CUSTOMER_GET`      | Returns `{ email, name }` for the buyer.                      |
| `CURRENCY_GET`      | Returns `{ currency }` (ISO 4217, e.g. `"INR"`).              |
| `TOAST_SHOW`        | Shows a host toast. Payload `{ message, variant? }`.          |
| `CLIPBOARD_WRITE`   | Copies a string into the clipboard. Payload `{ text }`.       |
| `REDIRECT`          | Navigates the parent page. Payload `{ url, newTab? }`.        |
| `CART_LINES_CHANGE` | Adds lines to a fresh cart for the **Option B** upsell flow.  |
| `DONE`              | Closes 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

```typescript theme={null}
{
  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.

```js theme={null}
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

```js theme={null}
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:

```html theme={null}
<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)

```html theme={null}
<!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](/app-bridge/actions). The actions you'll use on this
host are:

```js theme={null}
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

<AccordionGroup>
  <Accordion title="Always wait for line items if you need them">
    `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.
  </Accordion>

  <Accordion title="Send APP_BRIDGE_RESIZE before paint">
    The slot starts at 120 px. Without a resize message your content gets
    clipped or scrolled inside the iframe and looks broken on mobile.
  </Accordion>

  <Accordion title="Always provide an out">
    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.
  </Accordion>

  <Accordion title="Tag the second order">
    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.
  </Accordion>

  <Accordion title="Test on real checkout, not JSON">
    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.
  </Accordion>
</AccordionGroup>

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

* [App Bridge — Actions](/app-bridge/actions) — full action catalogue.
* [App Bridge for Checkout](/app-bridge/checkout) — wire format, cart
  actions, buyer-journey intercept.
* [Checkout UI Extensions](/extensions/checkout-ui) — for pre-purchase
  iframe slots.
