# Checkout in 15 minutes

In this tutorial you build a working Kustom Checkout in three steps: create a checkout order from your server, show the checkout on your page, and confirm the order once the customer has paid. Every step runs live as you read, against a test store we've set up for you.

Kustom Checkout is a hosted checkout you embed in your own site: your server sends a cart and gets back a block of HTML, and your page renders it. This tutorial builds that end to end, and the pane beside the text executes the same calls for real against a live Playground merchant, so you can see each step work before you write it. Everything here builds the embedded checkout; the others are under [Integration models](/checkout/#integration-models).

<a class="kd-livejump not-content" href="#try-it-live">
  <span class="kd-livedot" aria-hidden="true"></span>
  <span><b>This page runs for real.</b> Every call in the tutorial executes against a live
  Playground merchant, in a workbench that follows the text.
  <span class="kd-livejump__cta">Skip to the workbench ↓</span></span>
</a>

## What you'll build

Three steps: create the order on your server, render the snippet it returns on your
checkout page and handle the completed order on your confirmation page. A fourth section
covers the customer who changes their cart while the checkout is still open — the case
every integration has to handle.

## Before you start

**Most readers should not write this code.** Kustom ships maintained plugins for roughly thirty e-commerce platforms — WooCommerce,
Magento, Litium, Centra, PrestaShop and more — and on those, installing the plugin *is* the
integration. [Find your platform](/partners/). Carry on here if none covers it, or you need
behavior a plugin doesn't expose. Want the whole picture before you start?
[All of Checkout](/checkout/).

To run the pane on this page: nothing. The Playground session in the strip above is yours
for two hours and these docs hold its credentials, so you never see or paste a secret.

To run the code on your own machine: a server you can make an outbound HTTPS request
from, in whatever language you already use, your own Playground credentials from
[Authentication & environments](/api/authentication/) and a public URL. Kustom calls back
to the URLs you register, so `localhost` alone won't receive them — put a tunnel in
front of it (`ngrok`, `cloudflared`) and use that hostname.

## Create the order

An order is your cart plus the URLs Kustom should call back. Amounts are in minor units, so
`25000` is 250.00 SEK, and the line totals must add up to `order_amount` exactly — Kustom
rejects a mismatch rather than correcting it.

```json title="the order" wrap
{
  "purchase_country": "SE",
  "purchase_currency": "SEK",
  "locale": "en-SE",
  "order_amount": 25000,
  "order_tax_amount": 5000,
  "order_lines": [
    {
      "type": "physical",
      "reference": "tshirt-001",
      "name": "Cotton T-Shirt",
      "quantity": 1,
      "unit_price": 25000,
      "tax_rate": 2500,
      "total_amount": 25000,
      "total_tax_amount": 5000
    }
  ],
  "merchant_urls": {
    "terms": "https://your-shop.example/terms",
    "checkout": "https://your-shop.example/checkout",
    "confirmation": "https://your-shop.example/confirmation?order_id={checkout.order.id}",
    "push": "https://your-shop.example/push?order_id={checkout.order.id}"
  }
}
```

Four `merchant_urls` are required: `terms`, `checkout`, `confirmation` and `push`. Kustom
substitutes `{checkout.order.id}` into any of them before calling, and `push` should be its
own path rather than your checkout or confirmation page — it fires server-to-server even
when the browser never comes back. [What to send](/checkout/integrate/the-order/)
documents every field, and [Merchant URLs explained](/checkout/integrate/merchant-urls/)
covers the optional ones.

Send it from your server. This call carries your secret, so it can never come from the
browser — the browser asks *your* backend, your backend calls Kustom with HTTP Basic auth
and the only thing that goes back is the `html_snippet`. Every integration that leaks a
merchant secret does it by calling Kustom from the front end to save a hop. There is no
browser-safe key.

```js title="server.js" wrap
// On your server, never in the browser: this needs your secret.
const auth = 'Basic ' + Buffer.from(`${id}:${secret}`).toString('base64');

const KUSTOM = 'https://api.playground.kustom.co';

const res = await fetch(`${KUSTOM}/checkout/v3/orders`, {
  method: 'POST',
  headers: { 'Content-Type': 'application/json', Authorization: auth },
  body: JSON.stringify(order),
});

const { order_id, html_snippet } = await res.json();

// Store order_id. Every later read and update of this order is keyed on it.
const orderUrl = (order_id) => `${KUSTOM}/checkout/v3/orders/${order_id}`;
```

Two things come back that the rest of the integration depends on. The `html_snippet` is what
your checkout page renders. The **`order_id`** is what every later read and update of this
order is keyed on — store it on the cart and build the order's URL from it,
`{base URL}/checkout/v3/orders/{order_id}`. The `201` also carries a `Location` header, but
it's that same URL, so there's nothing in it you need.

The pane has the same request. Switch the market, send it and watch what comes back.

## Render the snippet

The `html_snippet` is a `<div>` plus a `<script>` that loads the checkout iframe. Put it
on the page you registered as `merchant_urls.checkout`.

If you render server-side, echo it into your template unescaped and you're done. If you
fetch it from the browser, there's one trap: **scripts inserted through `innerHTML` never
execute**, so the snippet lands in the DOM and nothing happens. Each script has to be
re-created as a fresh element.

```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);
  });
}
```

```html title="index.html" wrap
<!doctype html>
<html>
  <body>
    <!-- Your container, your id. -->
    <div id="checkout" style="min-width: 320px"></div>

    <script src="/render-checkout.js"></script>
    <script>
      fetch('/checkout', { method: 'POST' })
        .then((r) => r.json())
        .then(({ html_snippet }) => {
          renderCheckout(document.getElementById('checkout'), html_snippet);
        });
    </script>
  </body>
</html>
```

Several things break this silently — a second snippet on the page, a style rule reaching
inside the container, a `Cross-Origin-Opener-Policy` header. The "Good to know" list in
[Render Checkout snippet](/checkout/integrate/render-checkout-snippet/) has them all.

## Update the order while the checkout is open

A customer who changes their cart mid-checkout is the case every integration has to handle.
Pause the iframe, POST the new lines to the order URL, then resume.

```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());
  }
}
```

`resume()` is doing more than unlocking the UI — it's what makes the iframe re-read the
order and redraw the totals, so skipping it leaves the customer looking at a stale price.
That's why it belongs in a `finally`.

Two more rules. **Never create a new order to reflect a change** — POST the update to the
existing one. And updates are only accepted while the order's `status` is
`checkout_incomplete`.

`suspend` and `resume` are commands the iframe accepts, not events it emits.
[Checkout JavaScript events](/checkout/client-side-events/) documents both halves — the
two commands in full, and the events you listen for to keep your own cart panel in sync.

## Confirm and acknowledge

When the customer presses **Pay order**, Kustom does two independent things: it redirects
the browser to `merchant_urls.confirmation`, and it calls `merchant_urls.push`
server-to-server. You may see both, only one, or the push first — so both paths do the
same work, idempotently.

```js title="server.js" wrap
// merchant_urls.confirmation -- the customer's browser lands here after paying.
app.get('/confirmation', async (req, res) => {
  const order = await readOrder(req.query.order_id);

  // The API returns lowercase; the spec's example is uppercase. Compare loosely.
  if (String(order.status).toLowerCase() !== 'checkout_complete') {
    return res.status(409).send('Order is not complete');
  }

  // Idempotent: the push below may have arrived first.
  await recordOrder(order);
  await acknowledge(req.query.order_id);

  // The same html_snippet field, now a receipt.
  res.send(`<div id="confirmation">${order.html_snippet}</div>`);
});

// merchant_urls.push -- server to server, independent of the browser. You may get this before the redirect, after it, or instead of it, so both paths do the same work.
app.post('/push', async (req, res) => {
  await recordOrder(await readOrder(req.query.order_id));
  await acknowledge(req.query.order_id);
  res.sendStatus(200);
});

// orderUrl(id) builds the order URL from the order_id you stored.
function readOrder(orderId) {
  return fetch(orderUrl(orderId), {
    headers: { Authorization: auth },
  }).then((r) => r.json());
}

// Stops the push retries.
function acknowledge(orderId) {
  return fetch(`${KUSTOM}/ordermanagement/v1/orders/${orderId}/acknowledge`, {
    method: 'POST',
    headers: { Authorization: auth },
  });
}
```

Three things here are easy to get wrong:

- **Read the order. Don't trust the query string.** The `order_id` in the URL says which
  order. The order's own `status` is the source of truth.
- **Compare the status case-insensitively.** The API returns `checkout_complete` in
  lowercase. The OpenAPI document's example shows it uppercase.
- **Acknowledge.** Until you do, Kustom keeps re-sending the push. Acknowledging on the
  confirmation page with the push as a backup is the pattern that survives a customer who
  closes the tab.

The confirmation snippet is the same `html_snippet` field on the order you just read, now
rendered as a receipt.

## You now have

- A server route that creates an order with all four required `merchant_urls`, keeps the
  `order_id` and returns only the snippet to the browser.
- A checkout page that injects the snippet and executes its scripts, inside a container
  you named.
- An update path that suspends, posts and resumes without ever re-creating the order.
- A confirmation route and a push endpoint that both record the order and acknowledge it.

That's a complete embedded Checkout integration. What is left is breadth — more payment
methods, shipping and everything that happens to the order after it's authorized.

<span class="kd-sited__step">before checkout</span>
<span class="kd-sited__step is-here">checkout</span>
<span class="kd-sited__step">authorized</span>
<span class="kd-sited__step">captured</span>
<span class="kd-sited__step">settled</span>
<a class="kd-sited__more" href="/basics/order-lifecycle/">the whole lifecycle →</a>

<h2 class="kd-cockpit__panetitle" id="try-it-live"><span class="kd-livedot" aria-hidden="true"></span>Try it live</h2>

> 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`, authenticated with HTTP Basic using your API username and API password.

```json
{
  "purchase_country": "SE",
  "purchase_currency": "SEK",
  "locale": "en-SE",
  "order_amount": 108000,
  "order_tax_amount": 21600,
  "order_lines": [
    {
      "type": "physical",
      "reference": "tshirt-001",
      "name": "Cotton T-Shirt",
      "quantity": 2,
      "quantity_unit": "pcs",
      "unit_price": 25000,
      "tax_rate": 2500,
      "total_amount": 50000,
      "total_discount_amount": 0,
      "total_tax_amount": 10000
    },
    {
      "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": -12000,
      "tax_rate": 2500,
      "total_amount": -12000,
      "total_discount_amount": 0,
      "total_tax_amount": -2400
    }
  ],
  "options": {
    "allow_separate_shipping_address": true
  }
}
```

## Next

- [What to send](/checkout/integrate/the-order/) — every field on the order.
- [Render Checkout snippet](/checkout/integrate/render-checkout-snippet/) — the injection
  reference, including the server-side variants.
- [Shipping options in the checkout](/checkout/shipping/),
  [Sell to businesses (B2B)](/checkout/use-cases/b2b/) and
  [Charge a customer later](/checkout/recurring/) — features to add once the checkout works.
- [Order lifecycle](/basics/order-lifecycle/) — capture, refund and settlement, if you're
  integrating Order Management too.
