> ## Documentation Index
> Fetch the complete documentation index at: https://quickbutik.dev/llms.txt
> Use this file to discover all available pages before exploring further.

# Embedded checkout

> Render the hosted Quickbutik checkout inside your own page, with redirect payment methods and fallbacks handled for you.

The embedded checkout is the same hosted checkout, rendered **inside your page** in an iframe instead of sending the shopper away. It needs kit **1.1.0** or newer.

<CodeGroup>
  ```ts Vanilla theme={null}
  const checkout = await shopkit.checkout.mount("#checkout", {
    successUrl: `${location.origin}/success`,
    backUrl: `${location.origin}/cart`,
  })
  checkout.on("complete", ({ orderNumber }) => track(orderNumber))
  ```

  ```tsx React theme={null}
  import { Checkout } from "@quickbutik/kit/react"

  <Checkout
    successUrl="/success"
    backUrl="/cart"
    lang="sv"
    onComplete={({ orderNumber }) => track(orderNumber)}
    onEmpty={() => setEmpty(true)}
  />
  ```

  ```html Web components theme={null}
  <qb-checkout success-url="/success" back-url="/cart" lang="sv"></qb-checkout>
  ```
</CodeGroup>

The document in the frame is served from the checkout's own origin, so the payment providers, 3-D Secure and wallet domain registrations work exactly as on the redirect path. `mount()` is `start()` plus a frame: **one** `POST /checkout/handoff`, with an `embed` block added.

<Note>
  The full-page redirect is still the default and the fallback. Use the embedded checkout on a route of its own; keep `<qb-checkout-button>` or `redirectToCheckout()` on the cart page. **Apple Pay and Google Pay are not available inline yet**; they run in the full-page checkout.
</Note>

## Who may frame a checkout

The session decides. `mount()` records your page's origin on the session it creates, and only a session that carries one is served a framable document. A hosted checkout URL you build yourself records nothing, so **a hand-built iframe around a hosted checkout URL is refused, and always will be**.

The recorded origin is a permission to frame, not a restriction on who frames: the document's `frame-ancestors` names https, so the checkout still renders when your page is itself inside something else (a CMS preview, a site builder's editor). Your origin decides which page the checkout exchanges messages with.

`mount()` records the origin every time, so a cart that `start()` opened earlier still embeds when `mount()` picks it up.

<Warning>
  **Attaching** an origin to a session that never had one can take up to a minute to be honoured everywhere. A shopper who mounts in that window sees the frame refused, waits out the 15-second handshake and is redirected to the hosted checkout.
</Warning>

## Redirect payment methods

Klarna, Swish, Vipps MobilePay, iDEAL, Trustly and full-page 3-D Secure take the **whole window** to the provider. A frame cannot do that on its own, so the kit does:

```
frame ── redirect { url, method, data } ──▶ host navigates the top window
                                            ▼
                        the provider, then back to the checkout's own origin
                                            ▼
      your page again, with ?qb_checkout_session=<id>&qb_checkout_shop=<n>
                                            ▼
             <qb-checkout> resumes that session and the frame shows the receipt
```

The bounce carries the session to resume **and** the shop's numeric id. The id is on the URL because the platform drops the cart the moment the order exists, normally before the shopper is back, so the browser cannot be relied on to have it. A second copy is also kept beside the remembered session (`<prefix>_checkout_store`).

Your page owes this flow two things:

1. **Render the checkout on load, and tolerate both parameters.** `<qb-checkout>` and `<Checkout>` pick them up, resume that session instead of creating a new one, and strip both from the address bar once the resumed frame has answered. A resume that fails before then leaves them, so a reload resumes again.
2. **Do not redirect that URL away.** A router that drops unknown query parameters, or a marketing redirect, breaks the return leg. The order is still created, but the shopper sees nothing.

### Routing the return leg yourself

```ts theme={null}
import {
  readReturnedCheckout,     // { sessionId, storeId } | null, consumes nothing
  readReturnedSessionId,    // the session id alone
  stripReturnedSessionId,   // removes both params with replaceState
  consumeReturnedSessionId, // read + strip in one step, not idempotent
  CHECKOUT_SESSION_PARAM,   // "qb_checkout_session"
  CHECKOUT_SHOP_PARAM,      // "qb_checkout_shop"
} from "@quickbutik/kit"

const returned = readReturnedCheckout()
if (returned) {
  const checkout = await shopkit.checkout.resume(container, returned.sessionId, {
    storeId: returned.storeId, // null is fine; storage is tried then
    successUrl: "/success",
  })
  checkout.on("ready", () => stripReturnedSessionId()) // only now
} else {
  await shopkit.checkout.mount(container, { successUrl: "/success" })
}
```

`readReturnedCheckout(url?)` accepts a URL (for example `request.url`), so a server component can decide before rendering whether this is a return leg. `storeId` is `null` when the URL carried nothing usable; `resume()` then falls back to the id remembered with the session, then the cart's.

<Tip>
  `checkout.parseReturnUrl()` is a different thing: it parses the post-payment landing at `/success/<orderNumber>`, not this bounce.
</Tip>

## It follows the cart

`<qb-checkout>` and `<Checkout>` watch the shared cart store. You write no code for this:

* **Nothing is mounted for an empty cart.** While the cart loads or is empty, no session is created. `<qb-checkout>` emits `qb:checkout-empty` and `<Checkout>` calls `onEmpty`, once per empty phase. The frame goes in by itself when an item exists.
* **Emptying the cart takes the frame down**, because the session names a cart that no longer exists. The next item starts a fresh checkout. A frame resuming a return leg, or showing a completed order, is left alone.
* **Changing the cart updates the checkout in place.** Add a line or change a quantity through the kit's cart store and the checkout re-reads the cart: lines, totals, shipping and the amount to pay follow, without losing what the shopper typed. If you change the cart some other way, or drive `mount()` by hand, call `checkout.cartUpdated()` after the change is saved.

## When it cannot embed

Four expected situations, none of them a thrown error. Each resolves to a handle with `state: "fallback"`, emits `fallback` / `qb:checkout-fallback` with the hosted checkout `url`, and (unless `fallbackRedirect: false`) sends the shopper to the full-page checkout:

| `reason` | What happened |
| - | - |
| `legacy` | The shop runs the legacy checkout, which is not embeddable. |
| `no-embed-url` | The platform returned no embed URL (the shop is not enabled for embedding, or the platform predates it). |
| `no-top-navigation` | Your page is inside a frame it may not navigate (an AI site builder's preview). The published site embeds normally. |
| `handshake-timeout` | The frame never answered within 15 seconds. Almost always the page is not the origin the session was created for: `www` versus the bare domain, an `embedOrigin` override, a resume on another origin. |

On `resume()` the fallback URL is built for **that** session, so the shopper lands on its confirmation, never on a new checkout.

## The handle

```ts theme={null}
const checkout = await shopkit.checkout.mount(container, input)

checkout.state          // "embed" | "fallback"
checkout.iframe         // the frame, or null on a fallback handle
checkout.sessionId
checkout.on(type, handler)  // returns an unsubscribe
checkout.off(type, handler)
checkout.focus()        // focus the frame's first control
checkout.cartUpdated()  // the cart changed out here: re-read it
checkout.destroy()      // remove the frame and every listener
```

One checkout per container: mounting again into the same element destroys the earlier handle first. Last mount wins.

### Events

| Event | Payload | Note |
| - | - | - |
| `ready` | `{ sessionId, shopId, step }` | The frame is listening |
| `height` | `{ height }` | Already applied to the frame |
| `scroll-to` | `{ top }` | Already scrolled |
| `navigate` | `{ url, reason }` | Back link, empty cart, "continue shopping" |
| `redirect` | `{ url, method, data }` | A payment provider wants the top window |
| `complete` | `{ sessionId, orderNumber, successUrl }` | The order exists |
| `demo-complete` | `{ sessionId, configureUrl }` | Demo mode on an unclaimed shop |
| `error` | `{ code, message, fatal }` | Reported by the frame itself |
| `event` | `{ name, params }` | GA4-shaped commerce events |
| `step` | `{ step }` | `"contact"` or `"payment"` |
| `fallback` | `{ reason, url }` | See the table above |

On the element these are `qb:checkout-ready`, `qb:checkout-step`, `qb:checkout-event`, `qb:checkout-complete`, `qb:checkout-error`, `qb:checkout-fallback` and `qb:checkout-empty`, all bubbling; `element.checkout` is the handle. In React they are `onReady`, `onStep`, `onEvent`, `onComplete`, `onError`, `onFallback` and `onEmpty`.

Every navigation is performed for you: `navigate` and a GET `redirect` with `location.assign`, a POST `redirect` with a hidden `target="_top"` form, and `complete` with a jump to the confirmed order. That landing is `<successUrl origin>/success/<orderNumber>?hash=…&t=…`, the same as the redirect checkout, so one thank-you route serves both.

### Options

| Option | Default | |
| - | - | - |
| `minHeight` | `600` | Height before the frame reports its own |
| `title` | `"Checkout"` | The frame's accessible name |
| `confirmation` | `"redirect"` | `"inline"` keeps the shopper on the page and shows the receipt in the frame |
| `fallbackRedirect` | `true` | Navigate to the hosted checkout when nothing can be framed |
| `handleNavigation` | `true` | `false` hands every navigation to you, including the redirect break-out |
| `embedOrigin` | This page's origin | Only for a session created by one page and framed by another |
| `returnUrl` | This page, minus the kit's parameters | Where a redirect payment method comes back to |

Everything `start()` takes (`successUrl`, `backUrl`, `cancelUrl`, `language`, `theme`, `prefill`, `cartId`) is taken too. Set `theme` when the page is dark: the frame inherits nothing from your CSS.

The element and component read their attributes and props **once**, when the session is created. Changing them later is ignored rather than discarding a checkout the shopper may be paying in.

## Not in the embed yet

* **Wallets.** Apple Pay and Google Pay are off inside the frame; cards and redirect methods work.
* **Merchant pixels inside the frame.** GA4, GTM and Meta are not injected into the frame: in a third-party context they would pollute attribution. Every commerce event is forwarded to your page as an `event` instead (`qb:checkout-event` on `<qb-checkout>`, `onEvent` on `<Checkout>`), and with the kit's analytics on (the default) it is tracked there automatically, the `purchase` included, gated on the shopper's consent. See [Consent and analytics](/kit/concepts/consent-and-analytics).
* **A hand-built iframe.** Use `mount()`, `<qb-checkout>` or `<Checkout>`.

## Content Security Policy

The embedded checkout needs `frame-src https://pay.quickbutik.com` in addition to the kit's usual `connect-src https://commerce.quickbutik.com` and `img-src https://cdn.quickbutik.com`.


This documentation is built and hosted on [Mintlify](https://mintlify.com), a developer documentation platform.