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

# Custom UI

> Render events and take the order in your own UI. Your server creates and pays orders with an OAuth token.

Build your own event pages and checkout form, and drive the order from your
server. Mobile money is fully native: the buyer approves a prompt on their
phone without leaving your site. Buyers pay with **mobile money (MTN or
Airtel) today; card is coming soon**.

**You need:** a server, and an API key with **Read events** and **Create
orders**. Add **Read orders** only if you want to poll order status (it
returns personal data); webhooks work without it.

<Warning>
  **The client secret (`LCSSECK-…-X`) is server-only.** It mints tokens that
  can create and pay orders. Keep it in your server's environment or secret
  manager, never in a browser, a mobile app or source control. The token
  endpoint refuses any request that carries an `Origin` header, so it cannot
  be called from a web page even by mistake.
</Warning>

<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
    participant S as Your server
    participant L as Laces API (connect.laces.run)

    S->>L: POST /oauth/token
    L-->>S: access_token (cache 1 h)
    S->>L: GET /events/{eventId}
    L-->>S: ticket types + fees, waiver flag
    S-->>B: Ticket picker, form, waiver checkbox
    B->>S: Tickets, name, email, phone, waiver accepted
    S->>L: POST /orders (Idempotency-Key A)
    L-->>S: order (status cart, total, hold_expires_at)
    S->>L: POST /orders/{id}/pay (Idempotency-Key B, msisdn)
    L-->>S: next_action: await_confirmation
    L-->>B: Mobile money prompt on their phone
    B-->>L: Approves with PIN
    L-)S: Webhook order.paid
    S-->>B: "You're registered"
```

## 1. Get a token

Cache it for its hour. See [Authentication](/authentication#caching-the-token)
for the full helper.

```bash theme={null}
curl -s -X POST "$BASE/oauth/token" \
  -u "$LACES_CLIENT_ID:$LACES_CLIENT_SECRET" \
  -d "grant_type=client_credentials"
```

## 2. Show events and prices

List your events, then read one for its ticket types and fees.

```bash theme={null}
curl -s "$BASE/events?limit=20" -H "Authorization: Bearer $TOKEN"
curl -s "$BASE/events/q3Zk9mVx" -H "Authorization: Bearer $TOKEN"
```

```json Response (abbreviated) theme={null}
{
  "code": 200,
  "message": "Event retrieved successfully",
  "data": {
    "event_id": "q3Zk9mVx",
    "name": "Kampala City 10K",
    "currency": "UGX",
    "payments_enabled": true,
    "organizer_name": "Kampala Road Runners",
    "payment_methods": ["momo", "airtel"],
    "ticket_types": [
      {
        "ticket_type_id": "tt_10k",
        "name": "10K",
        "currency": "UGX",
        "price_minor": 50000,
        "laces_fee_minor": 2400,
        "total_minor": 52400,
        "card": null,
        "airtel": { "laces_fee_minor": 2400, "total_minor": 52400 },
        "is_free": false,
        "distance_km": 10,
        "capacity": 500,
        "remaining": 212
      }
    ],
    "additional_questions": [
      { "key": "kit_size", "label": "T-shirt size", "type": "select",
        "required": true, "options": ["S", "M", "L", "XL"], "help": "" }
    ],
    "waiver_required": true,
    "waiver_version": "2026-09-01"
  }
}
```

What to do with it:

* **Prices.** Show `price_minor`, `laces_fee_minor` and `total_minor` exactly
  as returned. The top-level pair is MTN mobile money; `airtel` has its own.
  **Never compute fees.** See [Fees and totals](/concepts/fees).
* **`payment_methods`** lists the methods you can sell right now. Today it is
  `["momo", "airtel"]`, and each ticket's `card` is `null`: card is coming
  soon. Offer only the methods in this list, and build your picker from it so
  card appears on its own once Laces turns it on.
* **`payments_enabled: false`** means your organization isn't cleared to take
  payments yet. Show "Registration not open" rather than a buy button that
  can't work.
* **`waiver_required: true`** means you must show the event's waiver (the text
  covered by your agreement with the organizer), collect the buyer's
  agreement, and send `waiver_accepted: true` with this exact
  `waiver_version` on the order.
* **`additional_questions`** are the organizer's questions. Ask them, and send
  answers in `custom_fields`, keyed by `key`.
* **Distances.** If ticket types have `distance_km`, send each participant's
  `distance_km` on the order.

Cache event reads for a minute or two.

<Frame caption="A partner page built from the event read: each ticket's price, mobile money fee and total, shown exactly as returned. The example site and race are made up.">
  <img src="https://mintcdn.com/laces/TvNmP5tvH8tqleQI/images/custom-ui/event-tickets-and-fees.png?fit=max&auto=format&n=TvNmP5tvH8tqleQI&q=85&s=daf1a415b83e11da3b39cbe4df69975d" alt="A Tickets table listing Free Fun Run at 0 UGX and 10K Entry at 50,000 UGX with a 1,950 UGX mobile money fee and a 51,950 UGX total" width="1280" height="490" data-path="images/custom-ui/event-tickets-and-fees.png" />
</Frame>

## 3. Create the order

When the buyer submits, create the order. This **holds their places** until
`hold_expires_at`; no money moves yet.

**Choose `payment_method` now**: it sets the price. `momo` is **MTN Mobile
Money only** (the default) and `airtel` is Airtel Money. Ask the buyer how
they'll pay, or infer it from their number's network, before you create the
order. Paying a `momo` order from an Airtel number (or an `airtel` order from
an MTN number) is refused with `409 reprice_required`, and you must create a
new order with the right method. `card` is refused with
`409 payment_method_unavailable` until card is available (check the event's
`payment_methods`).

<Frame caption="A partner's own registration form: ticket, buyer details, mobile money number and the waiver checkbox.">
  <img src="https://mintcdn.com/laces/TvNmP5tvH8tqleQI/images/custom-ui/registration-form.png?fit=max&auto=format&n=TvNmP5tvH8tqleQI&q=85&s=6cdb55d252da25818cbf8efc1a7fd43a" alt="A 'Your details' form with a ticket picker set to 10K Entry, full name, email and mobile money number fields, an 'I accept the race waiver' checkbox and a Register and pay button" width="1280" height="800" data-path="images/custom-ui/registration-form.png" />
</Frame>

<CodeGroup>
  ```bash curl theme={null}
  curl -s -X POST "$BASE/orders" \
    -H "Authorization: Bearer $TOKEN" \
    -H "Idempotency-Key: 6f1c3c9e-2f4b-4c64-9a2f-0d1c5a8b7e11" \
    -H "Content-Type: application/json" \
    -d '{
      "event_id": "q3Zk9mVx",
      "lines": [
        { "ticket_type_id": "tt_10k", "quantity": 1, "participants": [{ "distance_km": 10 }] }
      ],
      "buyer_name": "Amina Nakato",
      "buyer_email": "amina@example.com",
      "buyer_phone": "0763494235",
      "payment_method": "momo",
      "waiver_accepted": true,
      "waiver_version": "2026-09-01",
      "custom_fields": { "kit_size": "M" },
      "partner_reference": "cart-8841"
    }'
  ```

  ```js Node theme={null}
  const order = await laces("POST", "/orders", {
    idempotencyKey: cart.createKey, // generated once, stored with the cart
    body: {
      event_id: "q3Zk9mVx",
      lines: [{ ticket_type_id: "tt_10k", quantity: 1, participants: [{ distance_km: 10 }] }],
      buyer_name: cart.buyer.name,
      buyer_email: cart.buyer.email,
      buyer_phone: cart.buyer.phone,
      payment_method: "momo",
      ...(event.waiver_required && {
        waiver_accepted: true,
        waiver_version: event.waiver_version,
      }),
      custom_fields: { kit_size: cart.kitSize },
      partner_reference: cart.id,
    },
  });
  ```

  ```python Python theme={null}
  order = laces("POST", "/orders",
      idempotency_key=cart.create_key,  # generated once, stored with the cart
      body={
          "event_id": "q3Zk9mVx",
          "lines": [{"ticket_type_id": "tt_10k", "quantity": 1,
                     "participants": [{"distance_km": 10}]}],
          "buyer_name": cart.buyer_name,
          "buyer_email": cart.buyer_email,
          "buyer_phone": cart.buyer_phone,
          "payment_method": "momo",
          "waiver_accepted": True,
          "waiver_version": event["waiver_version"],
          "custom_fields": {"kit_size": cart.kit_size},
          "partner_reference": cart.id,
      })
  ```
</CodeGroup>

Response `201` (`200` on an idempotent replay):

```json theme={null}
{
  "code": 201,
  "message": "Cart created successfully",
  "data": {
    "order": {
      "id": "Hk2mQ8vR",
      "order_code": "LC-7K2M9X",
      "event_id": "q3Zk9mVx",
      "status": "cart",
      "currency": "UGX",
      "subtotal": 50000,
      "discount_total": 0,
      "donation_total": 0,
      "laces_fee": 2400,
      "total": 52400,
      "ticket_count": 1,
      "buyer": { "name": "Amina Nakato", "email": "amina@example.com", "phone": "+256763494235" },
      "attribution": {
        "channel": "partner_api",
        "api_client_id": "LCSCLI-EXAMPLECLNT2345EXAMP",
        "checkout_session_id": null,
        "partner_reference": "cart-8841"
      },
      "hold_expires_at": "2026-10-09T08:29:03.000Z",
      "created_at": "2026-10-09T08:14:03.000Z"
    },
    "checkout_lines": [
      { "label": "Subtotal", "amount": 50000 },
      { "label": "Laces Service Fee", "amount": 2400 },
      { "label": "Total", "amount": 52400 }
    ],
    "is_free": false,
    "is_test_mode": false,
    "fee_rate": { "flat_fee": 0, "percent_fee": 0, "currency": "UGX", "payment_method": "momo" },
    "registrations": [ { "id": "rg_5TfK2", "ticket_type_id": "tt_10k", "distance_km": 10, "status": "pending" } ],
    "event_name": "Kampala City 10K",
    "event_when_label": "Sun 15 Nov 2026, 7:00 AM"
  }
}
```

* **`order.total` is the amount due.** Show it on your confirm step. A
  multi-ticket cart costs less than the per-ticket quotes added up, because
  part of the fee is per order.
* `checkout_lines` is a ready-made breakdown for your UI.
* `order.id` is what you pay. `order_code` is what the buyer sees on their
  ticket and receipt.
* `order` is the same partner order view that
  [`GET /orders/{orderId}`](/api-reference/v1/orders/get) returns (without
  `registrations`): timestamps are ISO 8601 UTC, and `buyer.phone` is in E.164
  form (`+2567…`) whatever format the buyer typed.

| Field | Required | Notes |
| - | - | - |
| `event_id` | Yes | Your organization's event |
| `lines` | Yes | `[{ ticket_type_id, quantity, participants? }]`, quantity 1 to 500. `participants` is one object per ticket. |
| `buyer_name`, `buyer_email`, `buyer_phone` | Yes | Receipts and tickets go to `buyer_email` |
| `payment_method` | No | `momo` (default) or `airtel`. Sets the price. `card` is refused with `409 payment_method_unavailable` until card is available. |
| `waiver_accepted`, `waiver_version` | When `waiver_required` | `true`, and the event's current version |
| `custom_fields` | No | Answers to `additional_questions` |
| `discount_code` | No | |
| `donation_amount` | No | Whole number, minor units |
| `partner_reference` | No | Your reference, up to 128 characters. Echoed in order reads and webhooks. |

Every order you create is stamped `channel: partner_api` for audit.

## 4. Pay

Call pay with a **new** `Idempotency-Key` (not the create key).

<Tabs>
  <Tab title="Mobile money">
    Send the payer's number as `msisdn`: an MTN Uganda number for a `momo`
    order, an Airtel Uganda number for an `airtel` order.

    ```bash theme={null}
    curl -s -X POST "$BASE/orders/Hk2mQ8vR/pay" \
      -H "Authorization: Bearer $TOKEN" \
      -H "Idempotency-Key: 0b1b7d2e-8c3a-4a0e-9f55-3c1d6f2a9b40" \
      -H "Content-Type: application/json" \
      -d '{ "msisdn": "0763494235" }'
    ```

    ```json theme={null}
    {
      "code": 200,
      "message": "Payment initiated successfully",
      "data": {
        "order_id": "Hk2mQ8vR",
        "order_code": "LC-7K2M9X",
        "status": "prompt_sent",
        "total": 52400,
        "currency": "UGX",
        "payment_status": "prompt_sent",
        "next_action": { "kind": "await_confirmation" },
        "skipped_provider": false,
        "failure_kind": null
      }
    }
    ```

    Laces sends a prompt to the payer's phone, and the call returns at once
    with `payment_status: "prompt_sent"`. Tell the buyer to **check their phone
    and enter their PIN**, then wait for the result (step 5). Branch on
    `next_action.kind`.

    <Frame caption="Illustrative: a partner page telling the buyer to approve the prompt on their phone.">
      <img src="https://mintcdn.com/laces/TvNmP5tvH8tqleQI/images/custom-ui/check-your-phone-illustrative.png?fit=max&auto=format&n=TvNmP5tvH8tqleQI&q=85&s=08c9e336e9a23e498715c2ef56c6c5cc" alt="A message reading 'Check your phone to approve the payment of 51,950 UGX', with the order code" width="1280" height="230" data-path="images/custom-ui/check-your-phone-illustrative.png" />
    </Frame>

    A number on the wrong network for the order's `payment_method` (an Airtel
    number on a `momo` order, or an MTN number on an `airtel` order) is
    `409 reprice_required`, and no prompt is sent. Create a new order with the
    right `payment_method` (a new create idempotency key), then pay that one.

    `409 prompt_in_progress` means a prompt is already waiting on the phone.
    It is not a failure: wait the seconds in `Retry-After` (also in
    `details.retry_after_seconds`) before trying again.
  </Tab>

  <Tab title="Card (coming soon)">
    Card isn't available to partners yet. Today an order created with
    `"payment_method": "card"`, or a pay call on an order priced for card, is
    refused with `409 payment_method_unavailable`, and each ticket's `card`
    quote is `null`.

    When card is enabled, it will appear in the event's `payment_methods` and
    card details will still never go through the Partner API: pay will charge
    nothing and return `next_action: { "kind": "redirect", "redirect_url" }`,
    a Laces-hosted page where the buyer pays. The buyer is not sent back to
    your site from that page, so you would learn the result from the
    `order.paid` webhook or an order read.
  </Tab>

  <Tab title="Free">
    When `order.total` is 0, pay needs no number and confirms the order
    immediately:

    ```json theme={null}
    {
      "code": 200,
      "message": "Free order confirmed without payment",
      "data": {
        "order_id": "Hk2mQ8vR",
        "order_code": "LC-7K2M9X",
        "status": "paid",
        "total": 0,
        "currency": "UGX",
        "payment_status": "successful",
        "next_action": { "kind": "none" },
        "skipped_provider": true,
        "failure_kind": null
      }
    }
    ```

    The `order.paid` webhook fires for free orders too.
  </Tab>
</Tabs>

## 5. Learn the result

**Webhooks (recommended).** `order.paid` or `order.failed` arrives at your
endpoint. Match it to your cart with `attribution.partner_reference`. See
[Webhooks](/guides/webhooks).

**Polling (needs `orders:read`).** Read the order every few seconds until
`status` leaves `payment_initiated` / `prompt_sent`:

```js theme={null}
async function waitForOutcome(orderId, { timeoutMs = 180_000 } = {}) {
  const until = Date.now() + timeoutMs;
  while (Date.now() < until) {
    const order = await laces("GET", `/orders/${orderId}`);
    if (!["cart", "payment_initiated", "prompt_sent"].includes(order.status)) return order;
    await new Promise((r) => setTimeout(r, 4000));
  }
  return null; // still pending: keep waiting for the webhook
}
```

**Fulfil only when `status` is `paid`.**

<Frame caption="The partner page's own confirmation once the order is paid.">
  <img src="https://mintcdn.com/laces/TvNmP5tvH8tqleQI/images/custom-ui/registered-confirmation.png?fit=max&auto=format&n=TvNmP5tvH8tqleQI&q=85&s=a608e7cafcefee409134b42e66ad2987" alt="A green confirmation reading 'You're registered!' with an order code and the email the confirmation was sent to" width="1280" height="230" data-path="images/custom-ui/registered-confirmation.png" />
</Frame>

| Final `status` | Meaning |
| - | - |
| `paid` | Confirmed. Registrations are issued and the buyer is emailed. |
| `failed` | The payment failed or was declined. Offer to try again. |
| `expired` | The prompt or hold timed out. |

### Why it failed: `failure_kind`

A `failed` or `expired` order carries `failure_kind`, a short, safe reason you
can branch on. It is on order reads, on the `order.failed` webhook
(`data.order.failure_kind`) and on the pay response. It is `null` while the
order hasn't failed, and it is never the payment provider's own wording.

| `failure_kind` | What happened | What to tell the buyer |
| - | - | - |
| `provider_not_reached` | The charge never reached the payment provider. **Nothing was charged**, and it is safe to try again straight away. | "We couldn't reach the payment service. You haven't been charged. Please try again." |
| `timed_out` | The buyer didn't approve the prompt in time, or it expired | "The payment wasn't approved in time. Try again and approve the prompt on your phone." |
| `declined` | The buyer declined or cancelled the prompt, or can't pay (for example not enough balance or a wrong PIN) | "The payment was declined. Check your mobile money balance, or try another number." |
| `other` | Anything else | "The payment didn't go through. Please try again, or contact the organizer." |

Treat an unknown value as `other`: more kinds may be added.

### Trying again after a failure

If an attempt fails or expires, the pay response also includes `retry_safe`:

* `true`: a fresh attempt can't double-charge. Call pay again with a **new**
  idempotency key (and the same or a different number on the same network).
* `false`: don't retry automatically. Check the order's status first.

`failure_kind: "provider_not_reached"` always comes with `retry_safe: true`.

`409 hold_expired` means the places were released: create a new order.

## Errors

| HTTP | `error_code` | What to do |
| - | - | - |
| 400 | `validation_failed`, `invalid_email`, `invalid_payment_method`, `invalid_donation_amount`, `invalid_partner_reference`, `invalid_json` | Fix the request and show the buyer what's wrong |
| 400 | `idempotency_key_required` | Send an `Idempotency-Key` header |
| 400 | `invalid_discount` | The discount code isn't valid. Ask the buyer to check it. |
| 400 | `ticket_type_not_in_event` | A `ticket_type_id` belongs to another event |
| 400 | `waiver_required` | Collect the waiver agreement |
| 400 | `waiver_version_mismatch` | The waiver changed. Re-read the event and ask again. |
| 400 | `distance_required`, `invalid_distance`, `distance_not_offered` | Ask for each participant's distance, from the event's offered distances |
| 400 | `msisdn_required`, `invalid_msisdn` | Ask for a valid MTN or Airtel Uganda number |
| 403 | `payments_disabled` | Hide checkout; your organization isn't cleared yet |
| 404 | `not_found` | The event or order isn't yours, or doesn't exist |
| 404 | `ticket_type_not_found` | Re-read the event; the ticket type is gone |
| 409 | `sale_closed`, `ticket_not_on_sale` | Registration or that ticket isn't on sale |
| 409 | `insufficient_inventory` | Sold out. Re-read the event. |
| 409 | `reprice_required` | Wrong network for the order's `payment_method`. Create a new order. |
| 409 | `payment_method_unavailable` | That payment method (today, `card`) isn't available. Offer only the event's `payment_methods`. |
| 409 | `prompt_in_progress` | A prompt is already on the phone. Wait `Retry-After` seconds; this is **not** a failure. |
| 409 | `hold_expired` | Create a new order |
| 409 | `order_not_payable` | The order is already paid, in progress or closed. Read it before doing anything else. |
| 409 | `idempotency_in_progress` | Retry shortly with the same key |
| 422 | `idempotency_key_reuse` | You reused a key for a different request. Use a new key for a new action. |

The full list is on [Errors](/concepts/errors).

## A complete server example (Node)

```js theme={null}
// Node 18+. Server only.
import crypto from "node:crypto";

const BASE = process.env.LACES_BASE; // https://connect.laces.run/v1
const CLIENT_ID = process.env.LACES_CLIENT_ID;
const CLIENT_SECRET = process.env.LACES_CLIENT_SECRET;

let cached = { token: null, exp: 0 };
async function getLacesToken() {
  if (cached.token && Date.now() < cached.exp - 60_000) return cached.token;
  const basic = Buffer.from(`${CLIENT_ID}:${CLIENT_SECRET}`).toString("base64");
  const r = await fetch(`${BASE}/oauth/token`, {
    method: "POST",
    headers: { Authorization: `Basic ${basic}`, "Content-Type": "application/x-www-form-urlencoded" },
    body: "grant_type=client_credentials",
  });
  const body = await r.json();
  if (!r.ok) throw new Error(`token: ${body.error}`);
  cached = { token: body.access_token, exp: Date.now() + body.expires_in * 1000 };
  return cached.token;
}

async function laces(method, path, { body, idempotencyKey } = {}) {
  const r = await fetch(`${BASE}${path}`, {
    method,
    headers: {
      Authorization: `Bearer ${await getLacesToken()}`,
      "Content-Type": "application/json",
      ...(idempotencyKey && { "Idempotency-Key": idempotencyKey }),
    },
    body: body && JSON.stringify(body),
  });
  const json = await r.json().catch(() => ({}));
  if (!r.ok) {
    const err = new Error(json.error_code ?? `http_${r.status}`);
    Object.assign(err, { status: r.status, code: json.error_code, body: json });
    throw err;
  }
  return json.data;
}

export async function checkout(cart, event) {
  // Keys are generated once and saved with the cart, so retries reuse them.
  cart.createKey ??= crypto.randomUUID();
  cart.payKey ??= crypto.randomUUID();
  await db.saveCart(cart);

  const { order } = await laces("POST", "/orders", {
    idempotencyKey: cart.createKey,
    body: {
      event_id: event.event_id,
      lines: cart.lines,
      buyer_name: cart.buyer.name,
      buyer_email: cart.buyer.email,
      buyer_phone: cart.buyer.phone,
      payment_method: cart.method, // "momo" | "airtel" (from event.payment_methods)
      ...(event.waiver_required && { waiver_accepted: true, waiver_version: event.waiver_version }),
      partner_reference: cart.id,
    },
  });

  const pay = await laces("POST", `/orders/${order.id}/pay`, {
    idempotencyKey: cart.payKey,
    body: order.total === 0 ? {} : { msisdn: cart.buyer.phone },
  });

  return { orderId: order.id, total: order.total, currency: order.currency, next: pay.next_action };
}
```


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