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

# Errors

> The response envelope, the stable error_code values, and what to do about each.

## The envelope

Every route except the token endpoint answers in the Laces envelope.

<CodeGroup>
  ```json Success theme={null}
  {
    "code": 200,
    "message": "Events retrieved successfully",
    "data": { "...": "..." }
  }
  ```

  ```json Error theme={null}
  {
    "code": 403,
    "message": "Forbidden",
    "error": "Missing scope: orders:read",
    "error_code": "insufficient_scope"
  }
  ```
</CodeGroup>

| Field | Notes |
| - | - |
| `code` | The HTTP status, repeated in the body |
| `message` | A short outcome. On some payment errors it is a slug (for example `hold_expired`); on cart errors it is `"Checkout failed"`. Kept for backward compatibility. |
| `error` | Human-readable detail. **May be reworded at any time. Don't parse it.** |
| `error_code` | A stable, machine-readable code. **Branch on this.** |
| `data` | The success payload |
| `details` | Only on `prompt_in_progress`: `{ "retry_after_seconds": n }` |

Every error from the Partner API carries an `error_code`, including rate
limits (`rate_limited`), unknown paths (`not_found`), malformed bodies
(`invalid_json`) and unexpected failures (`server_error`).

The [token endpoint](/authentication#token-endpoint-errors) is the exception
to all of this: it uses the OAuth `{ "error", "error_description" }` shape
([below](#token-endpoint)).

## Request ids

**Every response** carries an `X-Request-Id` header, for example
`X-Request-Id: req_V1StGXR8Z5jdHi6BmyT2`. Log it with your errors, and quote it
when you contact [support@laces.run](mailto:support@laces.run): it lets Laces
find your exact request. Browsers can read it too: the API exposes
`X-Request-Id` and `Retry-After` to your page's scripts through CORS, so
`res.headers.get("X-Request-Id")` works in `fetch`.

## Wrong host

Partner credentials are accepted **only** on `https://connect.laces.run`. If
you send a bearer token, client credentials or a publishable key to another
Laces host (for example `https://api.laces.run`), the request is refused before
anything else:

```json theme={null}
{
  "code": 401,
  "message": "Unauthorized",
  "error": "Partner API credentials are not accepted on this host. Use https://connect.laces.run (see https://docs.laces.run).",
  "error_code": "partner_credential_not_accepted"
}
```

Fix your base URL to `https://connect.laces.run/v1`.

```js theme={null}
async function laces(method, path, { body, idempotencyKey } = {}) {
  const res = 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 res.json().catch(() => ({}));
  if (!res.ok) {
    const err = new Error(json.error_code ?? `http_${res.status}`);
    err.status = res.status;
    err.code = json.error_code ?? null; // stable
    err.retryAfter = Number(res.headers.get("Retry-After")) || null;
    err.requestId = res.headers.get("X-Request-Id"); // quote this to support
    err.body = json;
    throw err;
  }
  return json.data;
}
```

## Error codes

### Authentication (every route)

| HTTP | `error_code` | Meaning | What to do |
| - | - | - | - |
| 401 | `unauthorized` | No credential sent | Send a bearer token or publishable key |
| 401 | `invalid_token` | Token malformed, unknown, expired, revoked, or not a Laces token | Fetch a new token once, then retry |
| 401 | `invalid_key` | Publishable key malformed, unknown or revoked | Check the key in the app; it may have been replaced by **Regenerate keys** |
| 401 | `partner_credential_not_accepted` | A partner credential sent to a Laces host other than `connect.laces.run` | Use `https://connect.laces.run/v1` |
| 403 | `credential_not_permitted` | This route doesn't accept that credential, e.g. a publishable key on an order route | Call it from your server with a bearer token |
| 403 | `origin_not_allowed` | Publishable key with no `Origin`, or one not in your allowed domains | Add the exact origin in the app |
| 403 | `insufficient_scope` | The credential lacks the scope this route needs | Enable the permission on the key |

### Events

| HTTP | `error_code` | Meaning |
| - | - | - |
| 400 | `invalid_request` | `limit` is not a whole number of at least 1 |
| 400 | `invalid_cursor` | Cursor malformed or from another organization |
| 404 | `not_found` | No such event, another organization's event, or one the list wouldn't show (draft, code-locked, demo, ended, not on sale) |

### Checkout sessions

| HTTP | `error_code` | Meaning |
| - | - | - |
| 400 | `invalid_request` | `event_id` missing, or a bad `client_reference_id` or `prefill` |
| 400 | `invalid_ui_mode` | `ui_mode` is not `redirect` or `embedded` |
| 400 | `invalid_return_url` | A return URL is missing (redirect mode), not `https`, too long, malformed, or its origin isn't in your return URL domains |
| 400 | `invalid_embed_origin` | Embedded session: the page origin isn't in your allowed domains |
| 401 | `unauthorized` | The API client is not active |
| 403 | `payments_disabled` | Your organization isn't cleared to take payments yet |
| 404 | `not_found` | Event not yours, demo or code-locked; or (on read) no such session, another organization's session, or one deleted after expiry |
| 409 | `sale_closed` | Registration hasn't opened, has closed, or the event isn't on sale |

### Create an order

| HTTP | `error_code` | Meaning |
| - | - | - |
| 400 | `idempotency_key_required` | No `Idempotency-Key` header |
| 400 | `validation_failed` | Required fields missing or invalid (`event_id`, `lines`, `buyer_name`, `buyer_email`, `buyer_phone`) |
| 400 | `invalid_email` | `buyer_email` isn't a valid email |
| 400 | `invalid_payment_method` | `payment_method` isn't `momo`, `card` or `airtel` |
| 409 | `payment_method_unavailable` | The method isn't available to partners right now (today: `card`). Use one of the event's `payment_methods`. |
| 400 | `invalid_donation_amount` | `donation_amount` isn't a non-negative whole number |
| 400 | `invalid_partner_reference` | `partner_reference` isn't a string of at most 128 characters |
| 400 | `waiver_required` | The event has a waiver and `waiver_accepted` isn't `true` |
| 400 | `waiver_version_mismatch` | `waiver_version` isn't the current one. Re-read the event and ask the buyer again. |
| 400 | `ticket_type_not_in_event` | A ticket type belongs to another event |
| 400 | `invalid_discount` | The discount code isn't valid for this cart |
| 400 | `distance_required`, `invalid_distance`, `distance_not_offered` | The event has distances and a participant's `distance_km` is missing or not offered |
| 403 | `payments_disabled` | Your organization isn't cleared to take payments yet |
| 404 | `not_found` | The event doesn't exist or isn't yours |
| 404 / 409 | `ticket_type_not_found` | A ticket type id is unknown |
| 409 | `sale_closed` | Registration isn't open |
| 409 | `ticket_not_on_sale` | A ticket type is outside its sales window |
| 409 | `insufficient_inventory` | Not enough places left (sold out) |
| 409 | `invalid_quantity`, `wrong_event` | The cart couldn't be held as sent |
| 409 | `idempotency_in_progress` | The same key is still being processed. Retry shortly with the same key. |
| 422 | `idempotency_key_reuse` | The key was already used with different parameters |

### Pay an order

| HTTP | `error_code` | Meaning |
| - | - | - |
| 400 | `idempotency_key_required` | No `Idempotency-Key` header |
| 400 | `msisdn_required` | A paid mobile money order needs `msisdn` |
| 400 | `invalid_msisdn` | Not a valid Ugandan MTN or Airtel number |
| 400 | `validation_failed` | The body is invalid |
| 404 | `not_found` | No such order, or another organization's |
| 409 | `order_not_payable` | The order is not in a state that can be charged (already paid, cancelled, ...) |
| 409 | `hold_expired` | The place hold lapsed. Create a new order. |
| 409 | `reprice_required` | The number's network doesn't match the order's `payment_method` (an Airtel number on a `momo` order, or an MTN number on an `airtel` order). Create a new order with the right method. |
| 409 | `payment_method_unavailable` | The order is priced for a method that isn't available right now (today: `card`). Create a new order with `momo` or `airtel`. |
| 409 | `prompt_in_progress` | A prompt is already on the payer's phone. **Wait** for `Retry-After` / `details.retry_after_seconds`; this is not a failure. |
| 409 | `idempotency_in_progress` | The same key is still being processed |
| 422 | `idempotency_key_reuse` | The key was used with a different order or number |

When a payment attempt fails or expires, the pay response also has
`retry_safe`: `true` means a fresh attempt can't double-charge the payer;
`false` means don't retry automatically. Every pay response also has
`failure_kind`: `null` unless the attempt failed, otherwise
`provider_not_reached` (nothing was charged; safe to retry), `timed_out`,
`declined` or `other`. See
[Why it failed](/guides/custom-ui#why-it-failed-failure-kind).

### Orders list and read

| HTTP | `error_code` | Meaning |
| - | - | - |
| 400 | `validation_failed` | `limit` outside 1–100, or an unknown `status` |
| 400 | `invalid_cursor` | The cursor is malformed |
| 404 | `not_found` | No such order or event, or another organization's |

### Everywhere

| HTTP | `error_code` | Meaning | What to do |
| - | - | - | - |
| 400 | `invalid_json` | The request body isn't valid JSON. You get the normal JSON error envelope with an `X-Request-Id`, never an HTML page. | Fix the body. Send `Content-Type: application/json`. |
| 400 | `invalid_request` | The body couldn't be read | Fix the request |
| 404 | `not_found` | The path doesn't exist on the Partner API (check for a stray `/partner` segment or a wrong host). The old `https://api.laces.run/v1/partner/…` paths also answer `404 not_found`, with an `error` that points you to `connect.laces.run`. | Use `https://connect.laces.run/v1` |
| 405 | `method_not_allowed` | An HTTP method the API doesn't support | Use the documented method |
| 413 | `payload_too_large` | The body is over 1 MB | Send a smaller body |
| 429 | `rate_limited` | Rate limited | Wait the `Retry-After` seconds. See [Rate limits](/concepts/rate-limits). |
| 500 | `server_error` | Unexpected failure | Retry with backoff, reusing the same `Idempotency-Key`. If it persists, email [support@laces.run](mailto:support@laces.run) with the `X-Request-Id`. |

### Token endpoint

`POST /oauth/token` answers in the OAuth shape
([RFC 6749 §5.2](https://datatracker.ietf.org/doc/html/rfc6749#section-5.2)):
`{ "error": "invalid_client", "error_description": "Client authentication failed" }`.

| HTTP | `error` | Meaning |
| - | - | - |
| 400 | `invalid_request` | `grant_type` missing, both Basic and body credentials sent, an unreadable or oversized body, or the request carried an `Origin` header (a browser) |
| 400 | `unsupported_grant_type` | Anything other than `client_credentials` |
| 400 | `invalid_scope` | An unknown scope, or one the key doesn't hold |
| 401 | `invalid_client` | Wrong client id or secret, or a revoked key |
| 429 | `temporarily_unavailable` | Rate limited. Retry after the `Retry-After` seconds. |
| 500 | `server_error` | Unexpected failure. Retry with backoff. |

See [Authentication](/authentication#token-endpoint-errors).

## Retrying safely

| Kind of error | Retry? |
| - | - |
| `400`, `403`, `404`, `422` | No. Fix the request. |
| `401 invalid_token` | Once, with a fresh token |
| `409 idempotency_in_progress`, `409 prompt_in_progress` | Yes, after a short wait, with the **same** key |
| Other `409` | No. The state has changed; re-read and decide. |
| `429` | Yes, after `Retry-After` |
| `500`, timeouts, network errors | Yes, with backoff and the **same** `Idempotency-Key` |


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