Skip to main content
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.
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.

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

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

1

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

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

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

theme: light or 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.
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.

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

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.

Sessions in detail

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): 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:
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.

Prefill

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

Polling

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

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:
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. 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:
1

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

Poll when the shopper is plausibly back

On the page named as backUrl, or when the tab becomes visible again, take a quick look:
3

Clean up

pollConfirmation() already calls finalize() on completed. The receipt was shown by the checkout, so a small “thanks” is enough.
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. Every handoff to the checkout also carries the shopper’s consent decision.

Embedded checkout

Render the same checkout inside your own page.

Checkout reference

Every method, input and result type.