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

# Checkout and the return leg

> Hand the shopper to the hosted Quickbutik checkout, bring them back to your thank-you page and confirm the order.

The kit never takes payment. Your storefront builds a cart, hands the shopper to the **hosted Quickbutik checkout** (Swish, Klarna, Vipps MobilePay, iDEAL, Apple Pay, Google Pay and cards), and confirms the order when the shopper comes back.

```
                    ┌─ checkout-v2 ─▶ hosted checkout ─┐
cart ──start()──────┤                                  ├─▶ <successUrl origin>/success/<orderNumber>
                    └─ legacy ──────▶ legacy checkout ─┘                  │
                                                                 pollConfirmation()
```

| Your storefront owns | The hosted checkout owns |
| - | - |
| Catalog, product pages, cart, the checkout button, the thank-you page | Address, shipping choice, discount codes, payment, PCI scope, 3-D Secure, wallets, receipts |

<Warning>
  Never build a checkout form and never put the hosted checkout URL in an `<iframe>` of your own. To keep the shopper on your page, use the [embedded checkout](/kit/concepts/embedded-checkout).
</Warning>

## Starting a checkout

`checkout.start()` resolves the cart, asks the platform for a handoff, remembers the handle it gets back and **returns the URL without navigating**. That makes the same call work in a server action (`redirect(url)`), a route handler (a 302/303) and the browser (`location.assign(url)`).

<Tabs>
  <Tab title="Vanilla">
    ```ts theme={null}
    const { url } = await shopkit.checkout.start({
      successUrl: `${location.origin}/success`,
      cancelUrl: `${location.origin}/cart`,
      backUrl: `${location.origin}/`,
      language: "sv",
    })
    location.assign(url) // a normal same-tab navigation, never a fetch
    ```
  </Tab>

  <Tab title="React">
    ```tsx theme={null}
    const { redirectToCheckout, starting } = useCheckout()

    <button
      disabled={starting}
      onClick={() =>
        redirectToCheckout({
          successUrl: `${location.origin}/success`,
          backUrl: `${location.origin}/`,
        })
      }
    >
      Till kassan
    </button>
    ```
  </Tab>

  <Tab title="Web components">
    ```html theme={null}
    <qb-checkout-button success-url="/success" back-url="/">
      <button type="button">Till kassan</button>
    </qb-checkout-button>
    ```
  </Tab>
</Tabs>

The result always carries `url`, `checkout` (`"v2"` or `"legacy"`), `handoffId` (the handle to confirm with) and `cartId`. `session` is only present when `checkout === "v2"`, so check before destructuring it.

<Note>
  **Pass `cartId` when the client might not remember the cart.** Without it, `start()` checks out the cart this client remembers and creates a new, empty one when it remembers none, which the handoff refuses with `400 "Cannot hand off an empty cart to the checkout"`. In a browser with cookies that is fine; in a script, on a server without a cookie accessor, or inside an app builder's preview, pass `cartId: cart.id`.
</Note>

### Buy now

`buyNow(item, input)` is an add followed by `start()`: the "Köp nu" button. It uses the remembered cart, so an existing basket is carried along rather than replaced. It resolves to the same result plus `cart`, and checks `successUrl` before adding anything.

<Tabs>
  <Tab title="Vanilla">
    ```ts theme={null}
    const { url } = await shopkit.checkout.buyNow(
      { productId: "prod_27", variantId: 10, quantity: 1 },
      { successUrl: `${location.origin}/success`, backUrl: location.href },
    )
    location.assign(url)
    ```
  </Tab>

  <Tab title="React">
    ```tsx theme={null}
    // Inside <ProductProvider>: the selected variant is filled in for you
    const { buyNow, canAddToCart, buying } = useProductAddToCart()

    <button
      disabled={!canAddToCart}
      onClick={() => buyNow({ successUrl: `${location.origin}/success`, backUrl: location.href })}
    >
      {buying ? "Öppnar kassan…" : "Köp nu"}
    </button>
    ```
  </Tab>

  <Tab title="Web components">
    ```html theme={null}
    <qb-product slug="cotton-tee">
      <qb-buy-now success-url="/success" back-url="/products/cotton-tee">
        <button>Köp nu</button>
      </qb-buy-now>
    </qb-product>
    ```
  </Tab>
</Tabs>

The UI layers go through the shared cart store, so a cart badge is still right if the shopper comes back with the back button. After a navigating checkout or buy-now, the button stays busy until the page is restored from the back/forward cache, so a click while the checkout loads cannot add the item twice. A navigation that never unloads the page releases it after 10 seconds.

## `successUrl`: the two rules

<Steps>
  <Step title="It must be https">
    Any https host is accepted: no domain registration, no custom-domain setup. `http` is accepted only for loopback (`localhost`, `*.localhost`, `127.x.x.x`, `::1`), so local development works against the live API. A `javascript:` URL or any other scheme is rejected with a 400. The SDK needs an **absolute** URL (`${origin}/success`); the elements resolve relative values for you.
  </Step>

  <Step title="Only the origin is used">
    After payment the shopper lands on `<origin>/success/<orderNumber>?hash=…&t=…`. The **path** you passed is ignored, so mount your thank-you route at `/success/[orderNumber]`. Static and SPA hosts need a rewrite for `/success/*`.
  </Step>
</Steps>

Both rules apply to the default `successMode: "redirect"`. In inline mode the checkout never redirects and `successUrl` is optional.

## `backUrl`: where "continue shopping" goes

The hosted checkout's links back out (header logo, back arrow, empty-cart screen, "continue shopping" on the confirmation) all point to `backUrl`. Same URL rule as `successUrl`. It is optional: left out, the checkout falls back to the shop's storefront URL, then to the origin of `successUrl`. Set it when your storefront lives on a path, or when you want the shopper back on a specific page.

## `successMode`: redirect or inline

```ts theme={null}
await shopkit.checkout.start({ successUrl })                       // "redirect", the default
await shopkit.checkout.start({ successMode: "inline", backUrl })   // no successUrl needed
```

| | `"redirect"` (default) | `"inline"` |
| - | - | - |
| After payment | The shopper is sent to `<successUrl origin>/success/<orderNumber>` | The checkout renders the order confirmation itself and stays there |
| `successUrl` | Required | Optional (`null` on the session when omitted) |
| `parseReturnUrl()` | Fires on your success route | Never fires |
| You learn the order exists via | Your success route plus `confirmation()` | `confirmation()` / `pollConfirmation()` only |

A redirect session without a `successUrl` throws a `ShopkitConfigError` before any request. `successMode` is only sent when you set it. Because inline sessions can answer `successUrl: null`, `CheckoutSession.successUrl` is typed `string | null`.

<Note>
  Legacy-checkout shops do not support inline mode (the platform answers 400 with `details.reason: "legacy_checkout_unsupported"`). Keep passing a `successUrl` alongside `successMode: "inline"` until you have verified inline mode on your shop; it is accepted and simply unused.
</Note>

## `theme`: light or dark

```ts theme={null}
await shopkit.checkout.start({ successUrl, theme: "dark" })
```

**Omitting `theme` is not the same as passing `"light"`.** Omit it and the checkout follows the theme the merchant chose for their shop, and keeps following it. Pass a value and this session is pinned to it. Pass it when your pages decide the look, for example a dark storefront that would otherwise flash a white checkout.

Like `language` and `backUrl`, it is set once when the session is created. On a legacy shop it is accepted and ignored. `start()`, `createSession()` and `mount()` take it since 1.2.0; the `theme` prop and attribute on `<Checkout>`, `<qb-checkout>` and `<qb-checkout-button>` need 1.2.1.

## `currency`: the shopper's currency

The checkout opens in the currency the shopper browsed in, with nothing to pass: every session and handoff carries the client's currency. Pass `currency` to override it for one call, or `currency: null` to send none.

```ts theme={null}
await shopkit.setCurrency("EUR")
await shopkit.checkout.start({ successUrl })   // the session is created for EUR
```

What that means depends on how the shop offers the currency. A `"charge"` currency prices and charges the session in it; a `"display"` currency leaves the session in the shop's currency and shows the converted amount as an approximation (`session.displayCurrency`). The currency is fixed when the session is created, and a second `start()` for the same cart in a different currency gets a new session. checkout-v2 only: a legacy shop ignores it. See [Currencies](/kit/concepts/currencies).

## Two checkouts, one call

Quickbutik shops are migrating between two checkouts, and which one a shop runs is the shop's setting, not your storefront's. `start()` returns the right URL either way, so `location.assign(url)` is the same line for both.

| | `v2` | `legacy` |
| - | - | - |
| The handoff is | A checkout session | An unpaid **order** |
| `result.session` | The session | `undefined` |
| Shopper returns to | `<successUrl origin>/success/<orderNumber>` | The same |
| "Back" links go to | Your `backUrl` | Your `backUrl` |
| `parseReturnUrl()` | Fires | Fires |
| `successMode: "inline"` | Supported | Refused with a 400 |
| A failed payment reads as | `failed` | `no_attempt` |
| Campaign prices | Kept | Re-priced from the catalog |

`handoffId` is the session id on `v2` and the order uuid on `legacy`; `confirmation()` accepts either, so one thank-you page serves both. The post-purchase-offer token (`&ppo=…`) is only appended when `successUrl` is on a host the shop owns, so a headless storefront on its own domain should not expect it.

<Info>
  Older kit docs (up to 1.3) said the legacy checkout sent shoppers to the shop's own platform storefront. The platform now honours `successUrl` and `backUrl` on both checkouts; no code change is needed.
</Info>

## Demo mode

A shop that has not activated Quickbutik Payments (every shop created through `POST /v2/shops`, and a claimed shop until its owner activates payments) takes the `v2` branch in **demo mode**: the hosted checkout renders in full, the payment step shows demo methods, no money moves and **no order is created**. `shop.get()` reports `demo.enabled: true` on the wire. Nothing changes in your code when payments go live. See [Going live](/kit/concepts/going-live).

## Sessions in detail

```ts theme={null}
shopkit.checkout.createSession(input)        // CheckoutSession
shopkit.checkout.getSession(sessionId)       // CheckoutSessionSnapshot | null
shopkit.checkout.currentSessionId()          // string | null
shopkit.checkout.syncCart(sessionId)         // refresh a stale snapshot
shopkit.checkout.hostedUrl({ sessionId, storeId: cart.storeId, language: "sv" })
```

### The stale-snapshot problem

Session creation is idempotent on `cartId`: a second create for the same cart returns the **first** session, with the cart as it looked back then. That is wrong for a shopper who went to checkout, came back and added something. The kit compares the session's cart snapshot with the live cart (product, variant, quantity; not price, which is recomputed server-side) and, when they differ, patches the session so it rehydrates from the Cart API. This happens automatically inside `createSession()` and `start()`. Call `syncCart(sessionId)` for a session you resume yourself.

### Reading a session

`CheckoutSessionSnapshot` carries `fields` (shopper-supplied values with the server's verdict) and `data` (server-computed nodes):

| Node | What |
| - | - |
| `cart_products` | The lines as the session snapshotted them |
| `pricing` | Product-only subtotals, and `taxOnTop` |
| `order_total` | **The authoritative total**: products, shipping, fees and discounts, with `lines` |
| `shipping_cost` / `payment_cost` / `discount` / `promocode` | The individual order lines |

`order_total.total` is what the shopper is charged; never recompute it. `isComplete` says whether the checkout can take payment, and `blockingFields` names what stands in the way. This is also the natural way to render "what was bought" on a thank-you page, since the cart is cleared by then.

### Building the hosted URL yourself

`hostedUrl()` builds a checkout-v2 URL without a round trip:

```ts theme={null}
shopkit.checkout.hostedUrl({ sessionId, storeId: cart.storeId, language: "sv" })
// → https://pay.quickbutik.com/checkout/<storeId>/<sessionId>?lang=sv
```

<Warning>
  `storeId` is the shop's **numeric** id (`cart.storeId`), not the prefix in the publishable key (`shopkit.shopPrefix`). The kit rejects the prefix by name and throws a `ShopkitConfigError` when no numeric id is known. It is v2 only; prefer `start()`, which works on both checkouts.
</Warning>

### Prefill

```ts theme={null}
await shopkit.checkout.start({
  successUrl,
  prefill: {
    customer: { email, postalCode, customerType: "b2c" },
    shipping_address: { firstName, lastName, line1, city, postalCode, country, phone },
    billing_address: { /* same shape */ },
  },
})
```

A value the checkout rejects lands as `status: "invalid"` on that field. It never fails the create call, so a stale saved address cannot block a shopper.

## The return leg

Order creation is asynchronous: the payment provider authorizes, then the platform builds the order (typically 6–17 seconds).

| Status | Meaning |
| - | - |
| `completed` | The order exists |
| `processing_payment` | Payment authorized, order being created |
| `no_attempt` | No payment attempt known for this session yet |
| `failed` | The payment attempt failed terminally |

### Polling

```ts theme={null}
const outcome = await shopkit.checkout.pollConfirmation(sessionId, {
  onUpdate: (snapshot) => setStatus(snapshot.status),
  maxPolls: 20,         // default
  maxTotalMs: 90_000,   // default
  signal,
})
```

Backoff is front-loaded (1 s, 1.5, 1.5, 2, 2, 3, 3, 5 …) because orders usually land early. The server's `retryAfterMs` hint can raise a delay (capped at 15 s) but never lower it. A single failed poll is not terminal.

```ts theme={null}
type ConfirmationOutcome =
  | { kind: "completed"; orderNumber: number | null; confirmation }
  | { kind: "failed"; confirmation }
  | { kind: "timeout"; lastStatus }
  | { kind: "aborted" }
```

<Warning>
  **`timeout` is not a payment failure.** The order may still land. Render "we're still processing your order" and let the shopper refresh. Never tell a paying customer their payment failed unless the outcome is `failed`.
</Warning>

On `completed` the kit calls `finalize()`: the cart id and session id are forgotten so the next visit starts a clean basket. The shop's numeric store id is kept. `orderNumber` can be `null` on `completed`; fall back to the number in the URL for display.

### `parseReturnUrl`

```ts theme={null}
const ret = shopkit.checkout.parseReturnUrl(location.href)
// { orderNumber: 10042, hash: "…", timestamp: 1735689600 } | null
```

Use it for display only. **Treat the order as real once `confirmation()` says `completed`**, never on the strength of the URL.

### The thank-you page

Mount it at `/success/[orderNumber]` and confirm against the API:

<Tabs>
  <Tab title="Vanilla">
    ```ts theme={null}
    const sessionId = await shopkit.checkout.currentSessionId()
    if (!sessionId) render("Thank you for your order.") // direct visit: nothing to poll
    else {
      const outcome = await shopkit.checkout.pollConfirmation(sessionId)
      if (outcome.kind === "completed") render(`Order #${outcome.orderNumber} confirmed.`)
      if (outcome.kind === "failed") render("The payment did not go through.")
      if (outcome.kind === "timeout") render("Your order is still being created. Refresh in a moment.")
    }
    ```
  </Tab>

  <Tab title="React">
    ```tsx theme={null}
    // app/success/[orderNumber]/page.tsx (server component)
    const sessionId = await shopkit.checkout.currentSessionId()
    const initialConfirmation = sessionId
      ? await shopkit.checkout.confirmation(sessionId).catch(() => null)
      : null

    return (
      <>
        <SEO title="Tack för din order" noIndex noFollow />
        <OrderConfirmation sessionId={sessionId} initialConfirmation={initialConfirmation} />
      </>
    )

    // OrderConfirmation ("use client"): a completed or failed snapshot is final and
    // renders from the first paint; anything else polls until the order lands.
    const { status, orderNumber, outcome, loading } = useOrderConfirmation(sessionId, {
      initialConfirmation,
      enabled: sessionId !== null,
    })
    ```
  </Tab>

  <Tab title="Web components">
    ```html theme={null}
    <qb-order-confirmation>
      <p data-qb-show="confirmation.loading" hidden>Skapar din order…</p>
      <p data-qb-show="confirmation.completed" hidden>
        Tack! Ordernummer <b data-qb-text="confirmation.orderNumber"></b>
      </p>
      <p data-qb-show="confirmation.failed" hidden>Betalningen gick inte igenom.</p>
      <p data-qb-show="confirmation.timedOut" hidden>Din order behandlas fortfarande.</p>
    </qb-order-confirmation>
    ```
  </Tab>
</Tabs>

Taking the first snapshot on the server means a shopper who lands after the order exists (the common case) sees the final state on first paint. Whichever path settles it, a completed order forgets the bought cart and fires the `purchase` analytics event once. The full Next.js version is in [Next.js → Thank-you page](/kit/react/nextjs#7-thank-you-page). The session id lives in a cookie, so **the storefront and the thank-you page must share an origin**. Never index the page: it belongs to one shopper.

### Inline confirmation

With `successMode: "inline"` nothing arrives on a success route. The only way to learn the order exists is the confirmation endpoint:

<Steps>
  <Step title="Start, remember, open">
    `start({ successMode: "inline", backUrl })` remembers the handoff (`currentSessionId()`). Open `url` as a top-level navigation, or in a new tab if your page must stay alive.
  </Step>

  <Step title="Poll when the shopper is plausibly back">
    On the page named as `backUrl`, or when the tab becomes visible again, take a quick look:

    ```ts theme={null}
    const sessionId = await shopkit.checkout.currentSessionId()
    if (sessionId) {
      const outcome = await shopkit.checkout.pollConfirmation(sessionId, {
        maxPolls: 3,
        maxTotalMs: 6_000,
      })
      if (outcome.kind === "completed") celebrate(outcome.orderNumber)
    }
    ```
  </Step>

  <Step title="Clean up">
    `pollConfirmation()` already calls `finalize()` on `completed`. The receipt was shown by the checkout, so a small "thanks" is enough.
  </Step>
</Steps>

Here a `timeout` or `no_attempt` is the normal result for a shopper who came back without buying. Do not present it as a failure.

## Analytics in the hosted checkout

The hosted checkout fires its own commerce events (`begin_checkout`, `add_shipping_info`, `add_payment_info`) on its own origin, with the merchant's own GA4, GTM and Meta ids from the shop's settings. The kit does not fire `begin_checkout` itself, so checkouts are not counted twice. Your thank-you page owns the `purchase`, and `useOrderConfirmation` / `<qb-order-confirmation>` fire it for you, deduplicated across reloads; see [The purchase](/kit/concepts/consent-and-analytics#the-purchase). Every handoff to the checkout also carries the shopper's consent decision.

## Related

<CardGroup cols={2}>
  <Card title="Embedded checkout" icon="window-maximize" href="/kit/concepts/embedded-checkout">
    Render the same checkout inside your own page.
  </Card>

  <Card title="Checkout reference" icon="book" href="/kit/reference/checkout">
    Every method, input and result type.
  </Card>
</CardGroup>


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