> ## Documentation Index
> Fetch the complete documentation index at: https://docs.laces.run/llms.txt
> Use this file to discover all available pages before exploring further.

# Embedded checkout

> Open Laces checkout in a modal on your own page with laces.js. The buyer never leaves your site.

<Info>
  **New.** Embedded checkout runs the same Laces checkout as
  [hosted checkout](/guides/hosted-checkout), in a modal instead of a
  redirect. Changes to `laces.js` are listed in the [changelog](/changelog).
</Info>

You add one script, call `LacesCheckout.open()` from a button, and Laces
checkout opens in an overlay on top of your page. When the buyer finishes, the
modal tells your page, and **your server verifies** the result.

**You need:** an API key with **Start hosted checkout**
(`checkout_sessions:write`), and **your page's exact origin in Allowed
domains**. Return URL domains are not needed for embedded checkout.

<Info>
  Every key, token and id on this site is a made-up example value. Your real
  keys are issued to you in the Laces app, under **Settings → Developers**.
</Info>

## The flow

```mermaid theme={null}
sequenceDiagram
    autonumber
    participant P as Your page + laces.js
    participant M as Laces modal (iframe on laces.run)
    participant L as Laces API (connect.laces.run)
    participant S as Your server

    P->>L: POST /checkout-sessions { ui_mode: "embedded" } (publishable key)
    L-->>P: { id, url }
    P->>M: Open overlay, iframe laces.run/checkout/{id}
    M-->>P: laces:ready
    M-->>M: Buyer fills the form and pays
    M-->>P: laces:success { session_id, order_id, order_code }
    P->>S: session_id
    S->>L: GET /checkout-sessions/{id} (bearer)
    L-->>S: status: complete
    L-)S: Webhook order.paid
```

## 1. Add the script

```html theme={null}
<script src="https://laces.run/v1/laces.js"></script>
```

Load it from `https://laces.run/v1/laces.js`. **Don't self-host it or copy it
into your bundle**: the script works out which Laces site to talk to from its
own `src` URL, and only accepts messages from that origin. A self-hosted copy
reports `misconfigured` or opens a frame that never loads. The script has no
dependencies, is under 6 KB, and defines one global, `LacesCheckout`.

## 2. Open the checkout

```html theme={null}
<button id="register">Register</button>

<script>
  document.getElementById("register").addEventListener("click", () => {
    LacesCheckout.open({
      publishableKey: "LCSPUBK-ABCDEFGHJKLMNPQRSTUVWXYZ23456789-X",
      eventId: "q3Zk9mVx",
      prefill: { buyer_name: "Amina Nakato", buyer_email: "amina@example.com" },
      clientReferenceId: "cart-8841",

      onSuccess: async (result) => {
        // result.session_id, result.order_id, result.order_code
        // NOT proof of payment: ask your server to verify it.
        const r = await fetch("/api/laces/verify", {
          method: "POST",
          headers: { "Content-Type": "application/json" },
          body: JSON.stringify({ session_id: result.session_id }),
        });
        showConfirmation(await r.json());
      },
      onCancel: () => {
        // The buyer pressed Cancel inside the checkout.
      },
      onClose: () => {
        // The modal closed, for any reason.
      },
      onError: (err) => {
        // err.error_code, err.message, err.status
        console.warn("Laces checkout:", err.error_code, err.message);
      },
    });
  });
</script>
```

With a publishable key, `laces.js` creates the session for you: a
`POST https://connect.laces.run/v1/checkout-sessions` with
`ui_mode: "embedded"`. The API takes your page's `Origin` as the session's
embed origin, so that origin must be in **Allowed domains**, or the call fails
with `403 origin_not_allowed`.

The overlay appears immediately with a "Loading secure checkout…" spinner while
the session is created and the checkout loads. Only one checkout can be open
at a time; calling `open()` while one is open does nothing.

<Frame caption="The checkout open in its modal over the partner's page (desktop). The example site and race are made up.">
  <img src="https://mintcdn.com/laces/TvNmP5tvH8tqleQI/images/embedded/modal-open-desktop.png?fit=max&auto=format&n=TvNmP5tvH8tqleQI&q=85&s=fd821466eaf021e23bdace3cc27b0e8a" alt="A 'Secure checkout' modal showing the Laces Register and Pay form for Kampala City 10K, over a dimmed event page" width="1130" height="666" data-path="images/embedded/modal-open-desktop.png" />
</Frame>

On phones the modal fills the screen:

<Frame caption="The same checkout on a 390 px wide phone screen.">
  <img src="https://mintcdn.com/laces/TvNmP5tvH8tqleQI/images/embedded/modal-open-mobile.png?fit=max&auto=format&n=TvNmP5tvH8tqleQI&q=85&s=ab81308e0dacd38c99ba0bd6e2682a20" alt="The Laces checkout form filling a narrow phone screen, with a close button at the top" width="390" height="713" data-path="images/embedded/modal-open-mobile.png" />
</Frame>

### Options

| Option | Required | Notes |
| - | - | - |
| `publishableKey` | Yes, unless `sessionId` | Your `LCSPUBK-…-X` key |
| `eventId` | Yes, unless `sessionId` | The event to sell |
| `sessionId` | Instead of the two above | An embedded session your server already created ([below](#creating-the-session-on-your-server)) |
| `prefill` | No | `{ buyer_name, buyer_email, buyer_phone }`. Ignored with `sessionId` (set it when your server creates the session). |
| `clientReferenceId` | No | Your reference, up to 256 characters, returned when you read the session. Ignored with `sessionId`. |
| `onSuccess(result)` | No | See [Callbacks](#callbacks) |
| `onCancel()` | No | See [Callbacks](#callbacks) |
| `onClose()` | No | See [Callbacks](#callbacks) |
| `onError(error)` | No | See [Callbacks](#callbacks) |

`LacesCheckout.close()` closes the open checkout from your own code (it fires
`onClose`). It does nothing if no checkout is open.

### Callbacks

| Callback | When it fires |
| - | - |
| `onSuccess({ session_id, order_id, order_code })` | Once, when the order is paid. The modal stays open on a confirmation with a **Done** button; the buyer closes it. |
| `onCancel()` | The buyer pressed Cancel inside the checkout. The modal then closes, so **`onClose` fires right after `onCancel`**. |
| `onClose()` | **On every dismissal**: the close button, **Esc** (on your page or inside the checkout), **Done**, after a cancel, and `LacesCheckout.close()`. Use it to tidy up your page. |
| `onError({ error_code, message, status })` | The checkout could not start or load. See [Errors](#errors). |

A callback that throws doesn't break the modal; the error is re-thrown
asynchronously so it still shows in your console.

### Creating the session on your server

To keep the publishable key off the page, or to set `client_reference_id`
and `prefill` on the server, create an **embedded** session with a bearer
token and pass only its id to the page:

```bash theme={null}
curl -s -X POST "$BASE/checkout-sessions" \
  -H "Authorization: Bearer $TOKEN" \
  -H "Content-Type: application/json" \
  -d '{
    "event_id": "q3Zk9mVx",
    "ui_mode": "embedded",
    "embed_origin": "https://shop.example.com",
    "client_reference_id": "cart-8841"
  }'
```

```json theme={null}
{
  "code": 201,
  "message": "Checkout session created successfully",
  "data": {
    "id": "LCSCS-EXAMPLESESSN234EXAMPL",
    "url": "https://laces.run/checkout/LCSCS-EXAMPLESESSN234EXAMPL",
    "expires_at": "2026-10-10T08:14:03.000Z",
    "ui_mode": "embedded"
  }
}
```

```js theme={null}
LacesCheckout.open({
  sessionId: "LCSCS-EXAMPLESESSN234EXAMPL",
  onSuccess: (r) => verifyOnServer(r.session_id),
});
```

Rules for embedded sessions:

* `ui_mode: "embedded"`.
* `embed_origin` is **required with a bearer token**: the exact origin of the
  page that will show the modal. It must be in **Allowed domains**, or you get
  `400 invalid_embed_origin`. With a publishable key it is ignored, and the
  request's own `Origin` is used.
* `on_success_url` and `on_cancel_url` are **optional**. If you send them, the
  [return URL rules](/guides/hosted-checkout#return-url-rules) apply. (An
  empty string is not "omitted": leave the field out instead.)
* The session can be shown only inside a page on that one origin. Laces pins
  the checkout's `frame-ancestors` to it, so the same session in a frame on
  any other site is blocked by the browser.
* Sessions expire after 24 hours. Create one per click.

## 3. Messages from the modal

The checkout talks to your page with `postMessage`. `laces.js` turns these
into your callbacks, so most integrations never handle them directly.

| Message | Data | Callback |
| - | - | - |
| `laces:ready` | none | (the checkout has loaded; the spinner goes) |
| `laces:success` | `{ session_id, order_id, order_code }` | `onSuccess` |
| `laces:cancel` | none | `onCancel`, then `onClose` |
| `laces:close` | none | `onClose` |

* Laces always posts to your page's exact origin (the session's embed
  origin), never `*`.
* `laces.js` only accepts messages whose `event.origin` is the Laces site it
  was loaded from and whose source is its own iframe.
* An embedded success never redirects your page.

## 4. Verify on your server

<Warning>
  **Never fulfil an order on the browser's word alone.** A redirect to your
  success page, or an `onSuccess` callback in the page, is user experience,
  not proof of payment. Anyone can type your success URL. Before you mark
  anything paid, confirm it on your **server**: read the session with
  [`GET /checkout-sessions/{id}`](/api-reference/v1/checkout-sessions/get)
  and require `status: "complete"`, or act on a signed
  [`order.paid` webhook](/guides/webhooks).
</Warning>

`onSuccess` runs in the browser, which the buyer controls. Treat it as a cue to
ask your server, exactly like the hosted checkout return:

```js theme={null}
// POST /api/laces/verify  { session_id }
app.post("/api/laces/verify", express.json(), async (req, res) => {
  const r = await fetch(
    `${BASE}/checkout-sessions/${encodeURIComponent(req.body.session_id)}`,
    { headers: { Authorization: `Bearer ${await getLacesToken()}` } },
  );
  if (!r.ok) return res.status(400).json({ ok: false });
  const { data } = await r.json();
  const ok = data.status === "complete" && data.order?.status === "paid";
  if (ok) await fulfilOnce(data); // also triggered by the order.paid webhook
  res.json({ ok, order_code: data.order?.code ?? null });
});
```

## What the buyer sees

The buyer pays with **mobile money (MTN or Airtel) today; card is coming
soon**. Inside the modal they see the total with every fee, enter their
number, and approve the prompt on their phone.

<Frame caption="The payment step inside the modal. This screenshot was taken in test mode, which is why it shows a sandbox banner; live checkouts don't.">
  <img src="https://mintcdn.com/laces/TvNmP5tvH8tqleQI/images/embedded/modal-pay-mobile-money.png?fit=max&auto=format&n=TvNmP5tvH8tqleQI&q=85&s=bc6934253505cdebc40346188dfa1f4e" alt="The modal showing subtotal UGX 50,000, Laces Service Fee UGX 1,950 and total UGX 51,950, above a Pay with mobile money section" width="1130" height="666" data-path="images/embedded/modal-pay-mobile-money.png" />
</Frame>

When the payment is confirmed, the modal shows the order code and sends
`laces:success`, so your `onSuccess` fires. The modal stays open until the
buyer taps **Done** or closes it.

<Frame caption="Payment received, inside the modal (test mode, hence the banner).">
  <img src="https://mintcdn.com/laces/TvNmP5tvH8tqleQI/images/embedded/modal-payment-received.png?fit=max&auto=format&n=TvNmP5tvH8tqleQI&q=85&s=b2ce4df40461838480a278754e7c7e53" alt="A green 'Payment received' panel reading 'You've successfully paid for Kampala City 10K' with an order code and QR code" width="1130" height="666" data-path="images/embedded/modal-payment-received.png" />
</Frame>

<Frame caption="Further down the confirmation: the ticket links and the Done button, which closes the modal and fires onClose.">
  <img src="https://mintcdn.com/laces/TvNmP5tvH8tqleQI/images/embedded/modal-success-done.png?fit=max&auto=format&n=TvNmP5tvH8tqleQI&q=85&s=b80cb9d5178b4689bd2e1e48e29d8a11" alt="The confirmation's 'What happens next' note, a Done button, a View your ticket and QR button, and Save PDF, Share, Receipt and Save image buttons" width="1130" height="667" data-path="images/embedded/modal-success-done.png" />
</Frame>

A free ticket skips payment: the buyer confirms their entry and gets the same
confirmation.

<Frame caption="The confirm step for a free entry: no payment and no fees.">
  <img src="https://mintcdn.com/laces/TvNmP5tvH8tqleQI/images/embedded/modal-free-entry-confirm.png?fit=max&auto=format&n=TvNmP5tvH8tqleQI&q=85&s=44911a05115c3f4c48f28c6bdd3f66df" alt="The modal headed 'Confirm your entry for Kampala City 10K' with a note that the entry is free and the total shown as Free" width="1130" height="664" data-path="images/embedded/modal-free-entry-confirm.png" />
</Frame>

Your `order.paid` webhook fires either way, so fulfil from the webhook or a
server-side session read, never only from `onSuccess`.

<Note>
  **When card is enabled.** Card will open the card provider's own secure
  window inside the modal. If a browser won't show that window inside a frame,
  the buyer gets a **"Continue in a new tab"** link to the same order on
  laces.run; the modal keeps watching the order, so `onSuccess` still fires
  when they pay in the tab.
</Note>

## Content Security Policy

If your site sends a `Content-Security-Policy`, allow:

```http theme={null}
Content-Security-Policy:
  script-src 'self' https://laces.run;
  frame-src https://laces.run;
  connect-src 'self' https://connect.laces.run;
```

* `script-src https://laces.run` loads `laces.js`.
* `frame-src https://laces.run` lets the checkout iframe load (on older
  browsers, `child-src`).
* `connect-src https://connect.laces.run` lets `laces.js` create the session
  with your publishable key. Not needed if you only open server-created
  sessions with `sessionId`.

`laces.js` sets its styles through the DOM (`style.cssText`), so a strict
`style-src` without `'unsafe-inline'` doesn't break the overlay.

## Accessibility and behaviour

* The overlay is a `role="dialog"` with `aria-modal`, keeps keyboard focus
  inside it, and stops the page behind it from scrolling. When it closes, focus
  returns to the element that opened it.
* On small screens it fills the viewport; on larger ones it is a centred panel.
* The iframe is not sandboxed and carries `allow="payment"`, which card
  payments need.
* Embedded checkout doesn't rely on third-party cookies, so it works in
  browsers that block them.

## Local development

Your page must be on an origin in **Allowed domains**, and that includes your
dev server. Add its exact origin, for example `http://localhost:3000` (or
`http://127.0.0.1:3000`), and the embedded checkout opens and completes there
with your live keys, end to end. The port is part of the origin, so
`http://localhost:5173` needs its own entry. A domain you have just added can
take up to 5 minutes to pass the browser's CORS check. See
[Local development domains](/api-keys#local-development-domains) and
[Testing your integration](/concepts/testing).

## Errors

`onError` receives `{ error_code, message, status }`. `status` is the HTTP
status, or `0` when there was no response. For problems found inside the
overlay, the modal shows `message` to the buyer and stays open until they
close it.

| `error_code` | Cause | What to do |
| - | - | - |
| `invalid_options` | Neither `sessionId` nor both `publishableKey` and `eventId` were passed. Nothing opens. | Fix the call |
| `invalid_session` | `sessionId` isn't a session id, or the API returned one that isn't. | Pass the `id` from the create response |
| `misconfigured` | `laces.js` wasn't loaded from its laces.run URL. Nothing opens. | Load `https://laces.run/v1/laces.js` |
| `network_error` | The session request never got a response (offline, blocked by CSP or an extension). | Check `connect-src` and the connection |
| `load_timeout` | The checkout didn't load within **25 seconds**: usually framing was refused because the page origin doesn't match the session, or `frame-src` blocks laces.run. | Check Allowed domains and your CSP |
| `invalid_key` (401) | The publishable key is wrong, replaced by **Regenerate keys**, or revoked | Update the key on your page |
| `origin_not_allowed` (403) | This page's origin isn't in Allowed domains (`https://www.example.com` and `https://example.com` are different) | Add the exact origin |
| `invalid_embed_origin` (400) | Server-created session: `embed_origin` isn't in Allowed domains | Fix `embed_origin` |
| `payments_disabled` (403) | Your organization can't take payments yet | Hide the button (check the event's `payments_enabled`) |
| `sale_closed` (409) | Registration isn't open | Show "Registration closed" |
| `not_found` (404) | The event isn't yours, or isn't on sale | Check `eventId` |
| `rate_limited` (429) | Too many requests | Let the buyer try again shortly |
| `http_<status>` | Any other failure without an `error_code` | Quote the response's `X-Request-Id` to support |

The API error codes are the same as for [creating a checkout
session](/guides/hosted-checkout#errors); see also [Errors](/concepts/errors).

### Troubleshooting

When the key or the page origin is wrong, the modal opens and shows the
problem in place of the form, and `onError` fires with the matching code.

<Frame caption="invalid_key: the publishable key is wrong, was replaced by Regenerate keys, or the key was revoked.">
  <img src="https://mintcdn.com/laces/TvNmP5tvH8tqleQI/images/embedded/error-invalid-publishable-key.png?fit=max&auto=format&n=TvNmP5tvH8tqleQI&q=85&s=a80f35166c98708d1e73175120ae4888" alt="An empty Secure checkout modal with the message 'Invalid publishable key'" width="480" height="665" data-path="images/embedded/error-invalid-publishable-key.png" />
</Frame>

<Frame caption="origin_not_allowed: this page's exact origin isn't in the key's Allowed domains.">
  <img src="https://mintcdn.com/laces/TvNmP5tvH8tqleQI/images/embedded/error-origin-not-allowed.png?fit=max&auto=format&n=TvNmP5tvH8tqleQI&q=85&s=fb58aa02b9cd17fe2a5b9b917fce8165" alt="An empty Secure checkout modal with the message 'This origin is not allowed for this key'" width="480" height="665" data-path="images/embedded/error-origin-not-allowed.png" />
</Frame>


This documentation is built and hosted on [Mintlify](https://mintlify.com), a developer documentation platform.