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

> Live page: renders the `html_snippet` from the last created order.

You have an `html_snippet` from [Create the order](/checkout/integrate/create-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

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`](/checkout/client-side-events/) is what tells your panel the total moved
after a shipping choice inside the iframe.

## 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:

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

```js title="render-checkout.js" wrap
// 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

`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](#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.

```jsx title="KustomCheckout.jsx" wrap
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:

```js title="checkout-api.js" wrap
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

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](/checkout/integrate/validation-callback/#answer-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

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.

```js title="update.js" wrap
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:

> Live page: a request runner, which sends this call and shows the response inline. Programmatic equivalent: `POST https://api.playground.kustom.co/checkout/v3/orders/{order_id}`, authenticated with HTTP Basic using your API username and API password.

```json
{
  "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
}
```

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](/basics/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

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.

## 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](/checkout/client-side-events/) is the catalogue: every event name, its payload and how
to register a listener with the same accessor.

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

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](/checkout/integrate/validation-callback/) is where that request
arrives, and whether you need to answer it at all.

## Related

- [Create the order](/checkout/integrate/create-order/) — where the `html_snippet` and the
  `order_id` come from
- [Checkout JavaScript events](/checkout/client-side-events/) — every event the iframe emits,
  and its payload
- [Order lifecycle](/basics/order-lifecycle/) — every status an order moves through, including
  `checkout_incomplete`
- [Answer the callbacks](/checkout/integrate/validation-callback/) — a last look at the
  order before the purchase completes
- [Supported browsers](/checkout/supported-browsers/) — the browser versions the checkout runs in
- [Confirmation & push](/checkout/integrate/confirmation-and-push/) — what happens after the
  customer pays
