- Checkout
- Get started
Render Checkout snippet
Put the html_snippet on your page, server-rendered or mounted once in an SPA, then keep its order correct: one per cart, suspend, POST all its lines, resume.
On this page you'll
- Embed the snippet in a server-rendered or client-rendered page
- Suspend and resume the checkout around an order update, and know why both steps are required
- Recognize the two constraints that apply once a customer is looking at the checkout
- Mount the snippet once in a single-page app, and keep one order per cart across reloads and tabs
html_snippet renders here.You have an html_snippet from Create the order and the
order_id you stored alongside it. This page puts the snippet on your page, and then
keeps the order behind it correct for as long as the customer is looking at it — every update
from here on is a POST to /checkout/v3/orders/{order_id}.
Where the checkout lives on the page
Section titled “Where the checkout lives on the page”The iframe carries the customer through address, shipping and payment. What surrounds it should help them finish, and nothing on the page should compete with the Pay order button inside it. Build this layout unless you’ve measured something better on your own traffic.
| Region | What goes there |
|---|---|
| Header | Logo, a security badge, a help link. No navigation, no promotions |
| Above the iframe | The cart, editable in place: quantity, remove, discount code and the running total |
| The iframe | Address, shipping, payment, Pay order |
| Below the iframe | Returns policy and a support contact. Nothing that leaves the page |
Five rules follow from it:
- A dedicated route.
/checkout, reached from the cart page. Don’t embed the iframe on the cart page itself: every visitor with no intent to buy creates an order, and cross-sell content competes with the buy button. - The cart stays editable above the iframe. A wrong quantity is fixed in place through the update sequence below, never by sending the customer back — a read-only summary beside the iframe sends them back to fix it, and that’s where they leave.
- One column, full width on a phone. The iframe sets its own height. A drawer, a modal or a tab constrains it, breaks scrolling under a mobile keyboard, and hides the cart while the customer pays.
- One buy button. It is inside the iframe. No newsletter box, no upsell carousel, no second call to action on the same screen.
- Render immediately. No lazy loading and no waiting for a consent banner to close. A snippet that isn’t in the first paint is an empty page to the customer.
Show the order total outside the iframe as well, and update both together:
order_total_change is what tells your panel the total moved
after a shipping choice inside the iframe.
Put it on the page
Section titled “Put it on the page”The snippet is a <div> plus a <script> that loads the checkout iframe. Keep the script tags
exactly as received. The container you inject into is yours, and its id is your choice.
Server-side — render it straight into your template:
<div id="checkout">{{ kustom_order.html_snippet }}</div>Client-side — innerHTML doesn’t execute scripts, so recreate them. Copy the attributes
across too: a bootstrap script that loses its src, async or nonce fails silently, leaving
an empty container and no error.
// Scripts inserted via innerHTML never execute, so re-create each one.function renderCheckout(container, htmlSnippet) { container.innerHTML = htmlSnippet; container.querySelectorAll('script').forEach((inert) => { const script = document.createElement('script'); for (const a of inert.attributes) script.setAttribute(a.name, a.value); script.textContent = inert.textContent; inert.replaceWith(script); });}Render the snippet on the page whose URL you gave as merchant_urls.checkout. See When it
breaks below for what happens if it runs more than once.
Mount it in a single-page app
Section titled “Mount it in a single-page app”renderCheckout above is enough for a page that fetches the snippet once. A React, Vue or Svelte
route has three more ways to get it wrong, and each one leaves a blank container or two iframes
with nothing in the console.
- Mount once per page view. Development modes run effects twice (React StrictMode, fast refresh), and a second injection is the first row of When it breaks. Guard the mount, and clear the container on unmount.
- Key the mount on the order, not on the cart. A cart update changes the order behind the
iframe, and the iframe re-reads it on
resume(). Remounting on every cart change flashes the checkout and loses whatever the customer had typed. - Fetch the snippet on mount, every time. Never keep it in application state across navigation: the stored copy may belong to an order that has since completed or expired. A query-string change on the same route — the notice a rejected validation sends the customer back with — isn’t a remount.
import { useEffect, useRef } from 'react';import { renderCheckout } from './render-checkout';
export function KustomCheckout({ orderId, htmlSnippet }) { const container = useRef(null); const mountedFor = useRef(null); // which order is on screen right now
useEffect(() => { const el = container.current; if (!el || mountedFor.current === orderId) return; // StrictMode runs this twice mountedFor.current = orderId; renderCheckout(el, htmlSnippet); return () => { el.innerHTML = ''; mountedFor.current = null; }; }, [orderId, htmlSnippet]); // not the cart: cart changes go through suspend and resume
return <div ref={container} style={{ minWidth: 320 }} />;}The accessor the snippet installs — and with it suspend, resume and every event — exists only
once the snippet’s script has run. Resolve it before calling anything on it:
export function checkoutApi() { return new Promise((resolve) => { const tick = () => window._klarnaCheckout ? window._klarnaCheckout(resolve) : setTimeout(tick, 50); tick(); });}v-html and {@html} insert scripts without running them, so Vue and Svelte call
renderCheckout from the mounted hook rather than binding the snippet into the template. The
mount-once guard and the cleanup apply unchanged.
The route that renders this component asks your server for the order when it mounts — the same
POST the quickstart’s index.html makes — and your server decides whether that means creating
an order or reusing one. That decision is the next section’s.
Who owns the cart
Section titled “Who owns the cart”Your cart is the source of truth. The Kustom order is a projection of it that exists so the customer can pay, and that one rule decides where the order lives, when it’s created and when it’s thrown away.
- The order belongs to the cart, not to the session. Store the
order_idon the cart record. A customer with two tabs open, or one who logs in halfway through, still has one cart and therefore one order. - Create it when the checkout route opens, not when something is added to the cart. Most carts never reach the checkout.
- Mark the order with the cart’s version. Every cart mutation increments a counter of yours.
Send it in
merchant_data("cart:crt_8a1f:v17") on every create and update. The validation callback carries it back in the order it posts you, so you can compare it with the cart and catch an update that never landed.
When the checkout route loads, the cart is in one of four states:
| The cart holds | Do |
|---|---|
| No order | Create one. Store its order_id, status and started_at on the cart |
An order, checkout_incomplete, touched within 48 hours | GET it. If its merchant_data version is behind the cart, POST the difference. Render the snippet it returns |
| An order that has expired | GET returns an empty 404. Create a new one and replace the order_id on the cart |
An order that is checkout_complete | The customer already paid. Send them to the confirmation route for that order and start a fresh cart. Never render the checkout snippet for a completed order |
Never create a second order for a cart that already has a live one — two orders is two possible payments. The first-load race in a single-page app is where it happens: a route mounted twice calls your session endpoint twice, so that endpoint has to serialize order creation per cart and let the second call find the first call’s order.
When the cart changes
Section titled “When the cart changes”The device at the top of this page is a live iframe, already rendering totals from the order you created. The moment the customer changes a quantity or applies a coupon, those totals are wrong until you tell the checkout otherwise — and the update is not a fire-and-forget call, it is a sequence:
- The customer changes something.
suspend()the checkout, so it stops rendering a state that’s about to be stale.POSTthe change to/checkout/v3/orders/{order_id}.resume(), which re-reads the order and redraws the totals.
Suspending first is not optional. Without it, the customer can keep interacting with an iframe whose totals no longer match the order Kustom is about to hold, and Kustom’s own write can land in the middle of whatever the iframe was doing.
async function updateOrder(orderId, changes) { window._klarnaCheckout((api) => api.suspend()); try { await fetch(`/checkout/${orderId}`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(changes), }); } finally { // Always. resume() is what re-reads the order and redraws the totals. window._klarnaCheckout((api) => api.resume()); }}/checkout/${orderId} above is your own route — the browser can’t hold the Basic auth
credentials the actual call needs, so your server receives this request and relays it,
server-side, to /checkout/v3/orders/{order_id} for the order you created.
resume() belongs in a finally because it has to run even when the POST fails — an iframe
left suspended stays frozen. Skip it and a ten-second auto-resume fires on its own. Pass
api.suspend({ autoResume: { enabled: false } }) when an update legitimately takes longer than
that.
The call itself is a plain POST of the fields you’re changing — fields you leave out keep
their current values. A cart update might change a quantity, add or remove a line, or swap the
shipping option a customer picked.
order_lines is one field, and sending it replaces every line on the order. Lines aren’t
merged: a line you leave out of the array is removed. So a quantity change on one line means
sending the whole cart, every line, with order_amount and order_tax_amount recalculated to
match — Kustom checks the totals against the new lines and rejects a mismatch. What you
shouldn’t resend is the rest of the order you originally created: a full resend can silently
overwrite an address or an options value the customer has since changed, which is exactly the
failure mode the next paragraph names. And never create a new order to reflect a cart change —
update the one the customer is already in.
Here it is live, changing the first line’s quantity on the order rendered above. The request carries all of the lines and both totals, not only the line that changed:
curl -X POST 'https://api.playground.kustom.co/checkout/v3/orders/{order_id}' \
-u "$KUSTOM_MERCHANT_ID:$KUSTOM_SECRET" \
-H 'Content-Type: application/json' \
-d '{
"order_lines": [
{
"type": "physical",
"reference": "tshirt-001",
"name": "Cotton T-Shirt",
"quantity": 3,
"quantity_unit": "pcs",
"unit_price": 25000,
"tax_rate": 2500,
"total_amount": 75000,
"total_discount_amount": 0,
"total_tax_amount": 15000
},
{
"type": "physical",
"reference": "chinos-001",
"name": "Slim Fit Chinos",
"quantity": 1,
"quantity_unit": "pcs",
"unit_price": 70000,
"tax_rate": 2500,
"total_amount": 70000,
"total_discount_amount": 0,
"total_tax_amount": 14000
},
{
"type": "discount",
"reference": "discount-10pct",
"name": "10% off",
"quantity": 1,
"quantity_unit": "pcs",
"unit_price": -14500,
"tax_rate": 2500,
"total_amount": -14500,
"total_discount_amount": 0,
"total_tax_amount": -2900
}
],
"order_amount": 130500,
"order_tax_amount": 26100
}'The docs proxy injects your session's credentials. In your own code, use your Playground API username and API password.
Two constraints apply only once a customer is in the checkout, rather than at creation:
| Constraint | What it means for your code |
|---|---|
Updates are rejected once the order’s status is no longer checkout_incomplete | A completed order can’t be patched back open. This one does tell you: 403 with error_code: READ_ONLY_ORDER. Treat it as the signal to send the tab to your confirmation route. An expired order is gone instead, and answers an empty 404. Order lifecycle covers every status an order moves through |
billing_address and shipping_address updates are ignored once the customer has entered their own | Sending an address you collected on your own guest-checkout form does nothing after that point — don’t rely on it to change what the customer already typed |
The address case is the silent one: the POST answers 200 and the address is dropped, so the
way to catch it is a GET of the order afterward, not the POST response. A merchant-side cart that assumes its own
write always lands will drift quietly out of step with what the customer is actually looking at.
One change at a time
Section titled “One change at a time”Two updates in flight for the same order race, and the last one to reach Kustom wins whether or not it was the newer one. Four habits keep the order and the cart telling the same story:
- Serialize per cart on your server. One lock, or one queue, around the whole update including the call to Kustom.
- Version every mutation. The browser sends the cart version it last saw. A stale version
gets a
409from your server, and the client reloads the cart and replays the customer’s intent. That’s what stops a slow quantity stepper from resurrecting a line the customer already removed. - Debounce, but never drop the last change. A trailing debounce of a few hundred milliseconds on the stepper is fine. The final state is always sent.
- Resume only after the update completed, and on failure too. Resuming early makes the iframe
re-read the old order, so the two totals disagree until the next event. Not resuming leaves a
frozen checkout, which the
finallyabove prevents.
If the update is rejected because the order is no longer checkout_incomplete, the customer
completed the purchase in another tab. Send this tab to the confirmation route rather than
showing an error.
Keep your own cart panel in sync
Section titled “Keep your own cart panel in sync”The suspend() and resume() calls above are two commands into the same accessor your
snippet’s script installs on the page. That object also fires events as the customer acts, so a
cart summary you render outside the iframe — a total in a sticky header, an item count — can
follow along without you polling the order. Checkout JavaScript
events is the catalogue: every event name, its payload and how
to register a listener with the same accessor.
When it breaks
Section titled “When it breaks”| Rule | Why |
|---|---|
| One snippet per page | A second injection leaves two iframes and leaked listeners. Re-initializing needs a page load, not a second call to your render function |
| Never inside an iframe of your own | Kustom’s iframe expects to talk directly to the top frame it’s embedded in, not through another one of yours |
| Don’t style the container or anything inside it | Put width rules on a wrapper, allow at least 320px and let the height change freely. A global iframe { … !important } rule, or a transform on body, breaks the checkout’s fullscreen states |
Don’t cache html_snippet across sessions | It’s tied to the order it came from, not to the customer or the page |
Allow *.kustom.co in frame-src and script-src | Without it the browser silently drops the iframe rather than erroring |
If you send a Cross-Origin-Opener-Policy header, make it same-origin-allow-popups | Plain same-origin makes popup payment methods open twice |
None of these fail loudly. A missing CSP directive drops the iframe with nothing in your own console. A stale cached snippet renders a checkout for an order that no longer exists. Treat this table as the first thing to check when the snippet renders wrong rather than not at all.
Prove it
Section titled “Prove it”The Add one more of line 1 button on the device at the top of this page runs the exact
sequence this page describes — suspend, POST, resume — against the order you created. Click
it and watch the total redraw without a page reload. That’s the whole update story working end
to end. The POST above is the same call written out: run it, then check its response body —
the new order_amount and the recalculated line are exactly what a real integration would use
to decide whether the update landed.
What you’re left with is a checkout that stays correct through as many cart changes as the customer makes. The one thing it doesn’t yet handle is Kustom asking you something mid-session — Answer the callbacks is where that request arrives, and whether you need to answer it at all.
Related
Section titled “Related”- Create the order — where the
html_snippetand theorder_idcome from - Checkout JavaScript events — every event the iframe emits, and its payload
- Order lifecycle — every status an order moves through, including
checkout_incomplete - Answer the callbacks — a last look at the order before the purchase completes
- Supported browsers — the browser versions the checkout runs in
- Confirmation & push — what happens after the customer pays
This page is also available as Markdown — append .md to its URL — and is indexed in llms.txt.