- Checkout
- Get started
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.
On this page you'll
- Create an order from your own server, with the secret never reaching the browser
- Render the returned html_snippet on your page and see the checkout load
- Suspend the checkout, update the order and resume it
- Handle the confirmation redirect and the push call, and acknowledge the order
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.
This page runs for real. Every call in the tutorial executes against a live Playground merchant, in a workbench that follows the text. Skip to the workbench ↓What you’ll build
Section titled “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
Section titled “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. 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.
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 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
Section titled “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.
{ "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
documents every field, and Merchant URLs explained
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.
// 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
Section titled “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.
// 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); });}<!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 has them all.
Update the order while the checkout is open
Section titled “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.
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 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
Section titled “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.
// 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_idin the URL says which order. The order’s ownstatusis the source of truth. - Compare the status case-insensitively. The API returns
checkout_completein 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
Section titled “You now have”- A server route that creates an order with all four required
merchant_urls, keeps theorder_idand 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.
- What to send — every field on the order.
- Render Checkout snippet — the injection reference, including the server-side variants.
- Shipping options in the checkout, Sell to businesses (B2B) and Charge a customer later — features to add once the checkout works.
- Order lifecycle — capture, refund and settlement, if you’re integrating Order Management too.
This page is also available as Markdown — append .md to its URL — and is indexed in llms.txt.