Skip to content

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.

Starting playground session…
  1. Build the payload
  2. Create order
  3. Render snippet
  4. Validate · optional
  5. Confirm & push

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
Rendered checkout
Create an order above and the 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}.

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.

RegionWhat goes there
HeaderLogo, a security badge, a help link. No navigation, no promotions
Above the iframeThe cart, editable in place: quantity, remove, discount code and the running total
The iframeAddress, shipping, payment, Pay order
Below the iframeReturns 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.

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.

render-checkout.js
// 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.

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.
KustomCheckout.jsx
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:

checkout-api.js
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.

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_id on 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 holdsDo
No orderCreate one. Store its order_id, status and started_at on the cart
An order, checkout_incomplete, touched within 48 hoursGET it. If its merchant_data version is behind the cart, POST the difference. Render the snippet it returns
An order that has expiredGET returns an empty 404. Create a new one and replace the order_id on the cart
An order that is checkout_completeThe 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.

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:

  1. The customer changes something.
  2. suspend() the checkout, so it stops rendering a state that’s about to be stale.
  3. POST the change to /checkout/v3/orders/{order_id}.
  4. 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.

update.js
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:

POST a change to the order
POST
/checkout/v3/orders/{order_id}
Create an order above first
Line 1 quantity3
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:

ConstraintWhat it means for your code
Updates are rejected once the order’s status is no longer checkout_incompleteA 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 ownSending 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.

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 409 from 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 finally above 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.

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.

RuleWhy
One snippet per pageA 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 ownKustom’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 itPut 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 sessionsIt’s tied to the order it came from, not to the customer or the page
Allow *.kustom.co in frame-src and script-srcWithout it the browser silently drops the iframe rather than erroring
If you send a Cross-Origin-Opener-Policy header, make it same-origin-allow-popupsPlain 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.

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.

This page is also available as Markdown — append .md to its URL — and is indexed in llms.txt.