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

# Hosted checkout

> Send the buyer to a Laces checkout page and back to your site. No payment UI to build; works on a static site.

Hosted checkout is the quickest integration. Your page creates a **checkout
session** for an event, sends the buyer to the Laces page it returns, and gets
the buyer back when they finish. Laces handles the form, the waiver and the
payment: mobile money (MTN and Airtel) today, card coming soon.

**You need:** an API key with **Start hosted checkout**
(`checkout_sessions:write`), your site's origin in **Allowed domains** and in
**Return URL domains**. See [Getting your API keys](/api-keys).

<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 B as Buyer's browser
    participant S as Your server
    participant L as Laces API (connect.laces.run)
    participant C as laces.run checkout

    B->>L: POST /checkout-sessions (publishable key)
    L-->>B: 201 { id, url, expires_at }
    B->>C: Redirect to url
    C-->>C: Buyer fills the form and pays (mobile money)
    alt Paid
        C-->>B: "Return to {organization}", auto-redirect after ~5 s
        B->>S: GET on_success_url?session_id=LCSCS-…
        S->>L: GET /checkout-sessions/{id} (bearer)
        L-->>S: status: complete, order.status: paid
        S-->>B: "You're registered"
    else Cancelled
        C-->>B: Redirect to on_cancel_url
    end
    L-)S: Webhook order.paid
```

1. **Your page** creates a session: `POST /checkout-sessions` with the
   event and your two return URLs. From the browser, use the publishable key.
   From your server, use a bearer token.
2. Laces returns `{ id, url, expires_at }`, where `url` is
   `https://laces.run/checkout/{id}`.
3. **Redirect** the buyer to `url`.
4. On laces.run the buyer sees a registration form for exactly that event,
   with your `prefill` filled in and a "Cancel and return to
   *your organization*" link. They fill it in and pay.
5. **Cancel** sends them to `on_cancel_url`.
6. **Success** shows a confirmation with a "Return to *your organization*" button
   and redirects automatically after about 5 seconds to `on_success_url`,
   carrying the session id.
7. **Your server verifies** the session before fulfilling anything.

### What the buyer sees

<Steps>
  <Step title="Your page">
    The buyer clicks **Register** on your event page.

    <Frame caption="A partner's event page with its own Register button. The example site and race are made up.">
      <img src="https://mintcdn.com/laces/TvNmP5tvH8tqleQI/images/hosted/partner-page-register-button.png?fit=max&auto=format&n=TvNmP5tvH8tqleQI&q=85&s=59f55b012f2bbbdb32610adeed49c9d0" alt="A running club's event page for Kampala City 10K with a green Register button" width="1130" height="335" data-path="images/hosted/partner-page-register-button.png" />
    </Frame>
  </Step>

  <Step title="The Laces checkout form">
    Laces opens the form for exactly that event, labelled with your
    organization's name. Anything you sent in `prefill` is already filled in.

    <Frame caption="The hosted checkout form, with the buyer's details prefilled.">
      <img src="https://mintcdn.com/laces/TvNmP5tvH8tqleQI/images/hosted/checkout-form-prefilled.png?fit=max&auto=format&n=TvNmP5tvH8tqleQI&q=85&s=00e9b02f80b20da611a124604ff055b1" alt="Laces checkout headed 'Checkout for Kampala Road Runners' with a Register and Pay form showing name, phone, gender, age group and email fields" width="1130" height="713" data-path="images/hosted/checkout-form-prefilled.png" />
    </Frame>
  </Step>

  <Step title="Pay with mobile money">
    The buyer sees every fee before paying, enters their MTN or Airtel number
    and taps **Pay**.

    <Frame caption="The payment step. 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/hosted/pay-with-mobile-money.png?fit=max&auto=format&n=TvNmP5tvH8tqleQI&q=85&s=961fbad11548621390ecb5f0c7fe274b" alt="Order summary showing subtotal UGX 50,000, Laces Service Fee UGX 1,950 and total UGX 51,950, above a mobile money number field and a Pay UGX 51,950 button" width="1130" height="713" data-path="images/hosted/pay-with-mobile-money.png" />
    </Frame>

    A prompt then arrives on the buyer's phone, and the page waits while they
    enter their PIN.

    <Frame caption="Illustrative: the waiting screen while the buyer approves the prompt on their phone.">
      <img src="https://mintcdn.com/laces/TvNmP5tvH8tqleQI/images/hosted/check-your-phone-illustrative.png?fit=max&auto=format&n=TvNmP5tvH8tqleQI&q=85&s=161bec8b3e477df3d8eab44ee31142a8" alt="A 'Check your phone' screen with a countdown, asking the buyer to enter their Mobile Money PIN" width="536" height="615" data-path="images/hosted/check-your-phone-illustrative.png" />
    </Frame>
  </Step>

  <Step title="Confirmation, then back to you">
    The buyer gets their order code and ticket, and a **Return to** button labelled with your organization's name. Laces sends them back to your `on_success_url`
    automatically after about 5 seconds.

    <Frame caption="The confirmation (shown here for a free entry) with its Return to button.">
      <img src="https://mintcdn.com/laces/TvNmP5tvH8tqleQI/images/hosted/success-return-to-organizer.png?fit=max&auto=format&n=TvNmP5tvH8tqleQI&q=85&s=bd12b030b0ed1f4895a2fb06250c743a" alt="Confirmation panel with an order code, a QR code and a 'Return to Kampala Road Runners' button" width="1130" height="713" data-path="images/hosted/success-return-to-organizer.png" />
    </Frame>
  </Step>

  <Step title="Your success page">
    Your server verifies the session (step 2 below) and shows its own
    confirmation.

    <Frame caption="The partner's own success page after the return.">
      <img src="https://mintcdn.com/laces/TvNmP5tvH8tqleQI/images/hosted/partner-success-page.png?fit=max&auto=format&n=TvNmP5tvH8tqleQI&q=85&s=115cfc4c57db9e683790c35f4f79a2e6" alt="The club's page reading 'Thanks, you're in!' with the order code" width="1130" height="335" data-path="images/hosted/partner-success-page.png" />
    </Frame>
  </Step>
</Steps>

## 1. Create the session

<CodeGroup>
  ```html Browser (publishable key) theme={null}
  <button id="register">Register</button>

  <script>
    const LACES_BASE = "https://connect.laces.run/v1";
    // Publishable: safe in page source. Only works from your allowed domains.
    const LACES_PK = "LCSPUBK-ABCDEFGHJKLMNPQRSTUVWXYZ23456789-X";
    const EVENT_ID = "q3Zk9mVx";

    document.getElementById("register").addEventListener("click", async () => {
      const res = await fetch(`${LACES_BASE}/checkout-sessions`, {
        method: "POST",
        headers: {
          "Content-Type": "application/json",
          "X-Laces-Publishable-Key": LACES_PK,
        },
        body: JSON.stringify({
          event_id: EVENT_ID,
          on_success_url: "https://shop.example.com/thanks?session={CHECKOUT_SESSION_ID}",
          on_cancel_url: "https://shop.example.com/events/kampala-city-10k",
          client_reference_id: "cart-8841",
        }),
      });
      const body = await res.json();
      if (!res.ok) {
        showError(body.error_code); // see "Errors" below
        return;
      }
      window.location.assign(body.data.url);
    });
  </script>
  ```

  ```bash Server (bearer token) theme={null}
  curl -s -X POST "$BASE/checkout-sessions" \
    -H "Authorization: Bearer $TOKEN" \
    -H "Content-Type: application/json" \
    -d '{
      "event_id": "q3Zk9mVx",
      "on_success_url": "https://shop.example.com/thanks?session={CHECKOUT_SESSION_ID}",
      "on_cancel_url": "https://shop.example.com/events/kampala-city-10k",
      "client_reference_id": "cart-8841",
      "prefill": { "buyer_name": "Amina Nakato", "buyer_email": "amina@example.com" }
    }'
  ```
</CodeGroup>

Response `201`:

```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": "redirect"
  }
}
```

| Field | Required | Notes |
| - | - | - |
| `event_id` | Yes | Your organization's event |
| `on_success_url` | Yes | Where the buyer goes after paying |
| `on_cancel_url` | Yes | Where the buyer goes if they cancel |
| `client_reference_id` | No | Your reference, up to 256 characters. Returned when you read the session. |
| `prefill` | No | `{ "buyer_name", "buyer_email", "buyer_phone" }`, all optional (up to 120, 254 and 32 characters) |
| `ui_mode` | No | `redirect` (the default). `embedded` is for [embedded checkout](/guides/embedded-checkout). |

Sessions **expire after 24 hours**, and each session is for one event. Create
one when the buyer clicks, not when the page loads.

### Return URL rules

Both URLs must be absolute `https` URLs, at most 2048 characters, with no
username or password, whitespace or backslashes, and their **origin must
exactly match** one of your return URL domains. This is what stops Laces
checkout being used as an open redirect.

The one exception is local development: `http://localhost:<port>` and
`http://127.0.0.1:<port>` return URLs work, end to end, when you have
registered that exact origin in **Return URL domains**. See
[Local development domains](/api-keys#local-development-domains).

### Getting the session id back

* Put the literal placeholder `{CHECKOUT_SESSION_ID}` anywhere in
  `on_success_url`, and Laces substitutes the real id:
  `https://shop.example.com/thanks/{CHECKOUT_SESSION_ID}`. The URL-encoded
  form `%7BCHECKOUT_SESSION_ID%7D` works too, so a URL built with
  `new URL()` or `encodeURIComponent` is fine.
* With **no placeholder**, Laces adds `session_id=LCSCS-…` as a query
  parameter (replacing any `session_id` you already had there).
* `on_cancel_url` is used as given, with no id added.

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

On your success page's route, read the session with a bearer token:

<CodeGroup>
  ```js Node (Express) theme={null}
  // GET /thanks?session=LCSCS-...
  app.get("/thanks", async (req, res) => {
    const sessionId = String(req.query.session || "");
    const token = await getLacesToken(); // cached; see Authentication
    const r = await fetch(
      `${BASE}/checkout-sessions/${encodeURIComponent(sessionId)}`,
      { headers: { Authorization: `Bearer ${token}` } },
    );
    if (!r.ok) return res.status(400).send("We couldn't verify your payment.");
    const { data } = await r.json();

    const cart = await currentCart(req); // the buyer's cart, from your own session cookie
    if (!cart || data.client_reference_id !== cart.id) {
      return res.status(400).send("This payment doesn't belong to your cart.");
    }
    if (data.status !== "complete" || data.order?.status !== "paid") {
      return res.send("Your payment isn't complete yet. We'll email you when it is.");
    }
    await fulfil(cart, data.order); // idempotent: the webhook may get here first
    res.send(`You're registered. Order ${data.order.code}.`);
  });
  ```

  ```python Python (Flask) theme={null}
  @app.get("/thanks")
  def thanks():
      session_id = request.args.get("session", "")
      r = requests.get(
          f"{BASE}/checkout-sessions/{quote(session_id, safe='')}",
          headers={"Authorization": f"Bearer {get_laces_token()}"},
          timeout=30,
      )
      if not r.ok:
          return "We couldn't verify your payment.", 400
      data = r.json()["data"]

      cart = current_cart()  # the buyer's cart, from your own session cookie
      if not cart or data["client_reference_id"] != cart.id:
          return "This payment doesn't belong to your cart.", 400
      if data["status"] != "complete" or (data["order"] or {}).get("status") != "paid":
          return "Your payment isn't complete yet. We'll email you when it is."
      fulfil(cart, data["order"])  # idempotent
      return f"You're registered. Order {data['order']['code']}."
  ```
</CodeGroup>

Response `200`:

```json theme={null}
{
  "code": 200,
  "message": "Checkout session retrieved successfully",
  "data": {
    "id": "LCSCS-EXAMPLESESSN234EXAMPL",
    "status": "complete",
    "ui_mode": "redirect",
    "event_id": "q3Zk9mVx",
    "client_reference_id": "cart-8841",
    "order": {
      "id": "Hk2mQ8vR",
      "code": "LC-7K2M9X",
      "status": "paid",
      "total": 52400,
      "currency": "UGX"
    }
  }
}
```

| `status` | Meaning |
| - | - |
| `open` | Not paid yet and not expired. `order` is `null` until the buyer starts a cart. |
| `complete` | The linked order is paid. It stays `complete` if the order is later refunded, and after the 24 hours. |
| `expired` | 24 hours passed without a paid order |

`order` is the **most recent** order made through the session, so a buyer who
retries after a failed payment shows their latest attempt. Expired sessions
are deleted automatically some time after they expire, after which a read
returns `404 not_found`, so verify promptly and keep your own record.

Always send `client_reference_id` (your cart id) when you create the session,
and check it on return. That stops a session id from someone else's purchase
being replayed against your buyer's cart. If you create sessions from your
server, also store the returned session id with the cart.

<Tip>
  The redirect can be lost: the buyer closes the tab, or their connection
  drops. Subscribe to the [`order.paid` webhook](/guides/webhooks) as well, and
  match it to your cart with `attribution.checkout_session_id`. Fulfil on
  whichever arrives first, idempotently.
</Tip>

## Errors

| HTTP | `error_code` | What to do |
| - | - | - |
| 400 | `invalid_request` | `event_id` missing, or `prefill` / `client_reference_id` invalid |
| 400 | `invalid_return_url` | Fix the URL, or add its origin to **Return URL domains** |
| 401 | `invalid_key` | The publishable key is wrong, replaced by **Regenerate keys**, or revoked |
| 403 | `origin_not_allowed` | Add this page's exact origin to **Allowed domains** |
| 403 | `payments_disabled` | Your organization isn't cleared to take payments yet. Hide the button (check `payments_enabled` on the event first). |
| 404 | `not_found` | The event isn't yours, or is a demo or code-locked event |
| 409 | `sale_closed` | Registration isn't open. Show "Registration closed". |
| 429 | `rate_limited` | Wait the seconds in `Retry-After`, then let the buyer try again |

A session is never created for an organization that can't sell, so a buyer is
never sent to a checkout page that can't take their money. Read
`payments_enabled` from [`GET /events/{eventId}`](/api-reference/v1/events/get)
to decide whether to show the button at all.

## Test it

There is one environment, so you test with your live key and a real checkout.
See [Testing your integration](/concepts/testing) for the full approach.

1. Create an **unlisted** test event in the Laces app with a free or
   low-price ticket.
2. Add your site's origin to **Allowed domains** and **Return URL domains**:
   your staging site, or `http://localhost:<port>` while you develop.
3. Click Register, complete the checkout, and land on your success URL.
4. Confirm your server's session read says `complete`, and that the
   `order.paid` webhook arrived.
5. Refund the order from the Laces app if you paid for it.


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