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 underextensions.postPurchaseExtensions in
your app’s app.json:
| 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. |
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
- 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
orderIdof the just-placed order in?orderId=<id>. - The host starts the iframe at
height: 120pxand resizes it on demand — sendAPP_BRIDGE_RESIZEmessages (see below) so your content isn’t clipped.
App Bridge actions available
Your iframe drives the host with the standard App Bridge wire format:@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>. |
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
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.
- Resolves
gid://launchmystore/Variant/<uuid>→ variant UUID. - Looks up the matching line in the placed order to copy
name,image,originalPrice,salePrice,discountPriceonto the new cart line so/checkoutdoesn’t show a UUID-only placeholder. - Primes the storefront cart state so cart persistence runs for the correct store.
- 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
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:[80, 2000] px.
Minimal upsell example (raw HTML)
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:Best practices
Always wait for line items if you need them
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.Send APP_BRIDGE_RESIZE before paint
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.
Always provide an out
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.Tag the second order
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.Test on real checkout, not JSON
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.Local dev
- 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). - Add the entry to
extensions/{domainSlug}/{appHandle}/app.jsonunderextensions.postPurchaseExtensions[]. - 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.
app.json if you want changes picked up faster.
See also
- App Bridge — Actions — full action catalogue.
- App Bridge for Checkout — wire format, cart actions, buyer-journey intercept.
- Checkout UI Extensions — for pre-purchase iframe slots.