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

# Pay an order

> Send a mobile money prompt, or confirm a free order. Card is coming soon.



## OpenAPI

````yaml api-reference/v1/openapi.json POST /orders/{orderId}/pay
openapi: 3.1.0
info:
  title: Laces Partner API
  version: 1.0.0
  summary: Show your organization's races and sell entries from your own website.
  description: >-
    The Laces Partner API, served at `https://connect.laces.run/v1`. Every path
    is relative to that server URL, which already ends in `/v1`.


    Authenticate with a **bearer token** from `POST /oauth/token` (server side)
    or, on the routes that allow it, a **publishable key** in
    `X-Laces-Publishable-Key` (browser side, from an allowed domain). Keys are
    uppercase and case-sensitive: send them exactly as issued.


    There is one environment, production. Payments made through the API are
    real.


    Money is integer minor units. Every route except the token endpoint answers
    in the Laces envelope `{ code, message, data | error, error_code }`; branch
    on `error_code`, never on `message` or `error`.


    Every response carries an `X-Request-Id` header (`req_…`). Quote it to
    support@laces.run when something goes wrong. Browsers can read
    `X-Request-Id` and `Retry-After` (both are CORS-exposed). Rate-limited
    routes also send the standard `RateLimit-Limit`, `RateLimit-Remaining` and
    `RateLimit-Reset` headers.


    Partner credentials are accepted on this host only. Sent to `api.laces.run`
    (the Laces app's own API), any partner credential is refused with `401
    partner_credential_not_accepted`.
  contact:
    name: Laces support
    email: support@laces.run
    url: https://docs.laces.run
  license:
    name: Proprietary
    identifier: LicenseRef-Proprietary
servers:
  - url: https://connect.laces.run/v1
    description: Production
security:
  - bearerAuth: []
tags:
  - name: Authentication
    description: OAuth 2.0 client credentials.
  - name: Events
    description: Your organization's races that are on sale.
  - name: Checkout sessions
    description: 'Laces-hosted checkout: redirect or embedded.'
  - name: Orders
    description: Create, pay and read orders from your own UI.
  - name: Webhook events
    description: Requests Laces sends to your webhook endpoint.
  - name: Health
    description: Liveness probe.
paths:
  /orders/{orderId}/pay:
    post:
      tags:
        - Orders
      summary: Pay an order
      description: >-
        Pay an order you created. What happens depends on the order's
        `payment_method` (fixed when the order was created; there is no method
        field here):


        - **`momo` / `airtel`**: Laces sends a payment prompt to `msisdn` and
        returns at once with `payment_status: "prompt_sent"` and
        `next_action.kind: "await_confirmation"`. The result arrives by webhook
        (`order.paid` / `order.failed`) or by polling the order. `momo` is MTN
        Mobile Money only and `airtel` is Airtel Money only: a number on the
        other network is `409 reprice_required` and nothing is sent. Create a
        new order with the matching `payment_method`.

        - **`card`** (coming soon): not yet available to partners. Paying an
        order priced for card answers `409 payment_method_unavailable`. When
        card is enabled, pay will charge nothing and return `next_action.kind:
        "redirect"` to a Laces-hosted page.

        - **Free** (total 0): confirmed immediately (`skipped_provider: true`),
        no `msisdn` needed.


        `Idempotency-Key` is required (a different key from create). Bearer
        token only, scope `orders:write`.


        Every response carries `failure_kind`: `null` unless the attempt failed
        or expired. `provider_not_reached` means nothing was charged and a retry
        is safe.
      operationId: payOrder
      parameters:
        - name: orderId
          in: path
          required: true
          schema:
            type: string
          example: Hk2mQ8vR
        - name: Idempotency-Key
          in: header
          required: true
          description: >-
            Any unique string you generate (a UUID is ideal), stored with your
            cart and **reused on every retry** of the same action. Scoped to
            your API client.
          schema:
            type: string
            maxLength: 255
          example: 6f1c3c9e-2f4b-4c64-9a2f-0d1c5a8b7e11
      requestBody:
        required: false
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/PayOrderRequest'
            example:
              msisdn: '0763494235'
      responses:
        '200':
          description: Payment started, hosted redirect, or free order confirmed.
          content:
            application/json:
              schema:
                type: object
                required:
                  - code
                  - message
                  - data
                properties:
                  code:
                    type: integer
                    description: HTTP status, repeated in the body.
                    examples:
                      - 200
                  message:
                    type: string
                    examples:
                      - Payment initiated successfully
                  data:
                    $ref: '#/components/schemas/PayOrderData'
              examples:
                mobile_money:
                  summary: Mobile money prompt sent
                  value:
                    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
                free:
                  summary: Free order confirmed
                  value:
                    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
          headers:
            X-Request-Id:
              $ref: '#/components/headers/X-Request-Id'
        '400':
          description: Invalid request.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
              examples:
                idempotency_key_required:
                  summary: idempotency_key_required
                  value:
                    code: 400
                    message: idempotency_key_required
                    error: Idempotency-Key header is required
                    error_code: idempotency_key_required
                msisdn_required:
                  summary: msisdn_required
                  value:
                    code: 400
                    message: msisdn_required
                    error: msisdn is required for mobile money
                    error_code: msisdn_required
                invalid_msisdn:
                  summary: invalid_msisdn
                  value:
                    code: 400
                    message: invalid_msisdn
                    error: Not a valid MTN or Airtel Uganda number
                    error_code: invalid_msisdn
                invalid_json:
                  summary: invalid_json
                  value:
                    code: 400
                    message: Validation failed
                    error: The request body is not valid JSON
                    error_code: invalid_json
          headers:
            X-Request-Id:
              $ref: '#/components/headers/X-Request-Id'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'
        '409':
          description: >-
            The order can't be charged now. `prompt_in_progress` carries
            `Retry-After`: wait, it is not a failure.
            `payment_method_unavailable`: the order is priced for a method that
            isn't available (today, card); create a new order with `momo` or
            `airtel`.
          headers:
            Retry-After:
              description: Whole seconds until you may retry.
              schema:
                type: integer
            X-Request-Id:
              $ref: '#/components/headers/X-Request-Id'
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
              examples:
                prompt_in_progress:
                  value:
                    code: 409
                    message: prompt_in_progress
                    error: A payment prompt is already waiting on the payer's phone
                    error_code: prompt_in_progress
                    details:
                      retry_after_seconds: 30
                reprice_required:
                  summary: Network doesn't match payment_method
                  value:
                    code: 409
                    message: reprice_required
                    error: >-
                      This number is Airtel Money, which is priced differently.
                      Please confirm the updated total.
                    error_code: reprice_required
                hold_expired:
                  value:
                    code: 409
                    message: hold_expired
                    error: The hold on these places has expired
                    error_code: hold_expired
                order_not_payable:
                  value:
                    code: 409
                    message: order_not_payable
                    error: This order cannot be paid
                    error_code: order_not_payable
                idempotency_in_progress:
                  value:
                    code: 409
                    message: Conflict
                    error: A request with this key is still being processed
                    error_code: idempotency_in_progress
                payment_method_unavailable:
                  summary: payment_method_unavailable
                  value:
                    code: 409
                    message: payment_method_unavailable
                    error: >-
                      This order is priced for card, which is not currently
                      available
                    error_code: payment_method_unavailable
        '413':
          $ref: '#/components/responses/PayloadTooLarge'
        '422':
          description: The key was used with a different order or number.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
              examples:
                idempotency_key_reuse:
                  summary: idempotency_key_reuse
                  value:
                    code: 422
                    message: Unprocessable
                    error: Idempotency-Key reused with different parameters
                    error_code: idempotency_key_reuse
          headers:
            X-Request-Id:
              $ref: '#/components/headers/X-Request-Id'
        '429':
          $ref: '#/components/responses/RateLimited'
        '500':
          $ref: '#/components/responses/ServerError'
      security:
        - bearerAuth: []
        - oauth2:
            - orders:write
components:
  schemas:
    PayOrderRequest:
      type: object
      properties:
        msisdn:
          type: string
          description: >-
            The payer's Ugandan mobile number (`07…`, `2567…` or `+2567…`). Must
            be on the network the order was priced for: MTN for `momo`, Airtel
            for `airtel`. Required for a paid mobile money order; not needed for
            a free order.
          examples:
            - '0763494235'
    PayOrderData:
      type: object
      properties:
        order_id:
          type: string
          examples:
            - Hk2mQ8vR
        order_code:
          type: string
          examples:
            - LC-7K2M9X
        status:
          type: string
          description: Order status.
          enum:
            - cart
            - payment_initiated
            - prompt_sent
            - paid
            - failed
            - expired
            - cancelled
            - refunded
            - partially_refunded
            - chargeback_flagged
        total:
          type: integer
        currency:
          type: string
        payment_status:
          type: string
          enum:
            - initiated
            - prompt_sent
            - pending
            - successful
            - failed
            - expired
            - cancelled
          description: >-
            Status of this payment attempt: `prompt_sent` once a mobile money
            prompt went out, `successful` for a free order.
        next_action:
          $ref: '#/components/schemas/NextAction'
        skipped_provider:
          type: boolean
          description: True for a free order confirmed without payment.
        retry_safe:
          type: boolean
          description: >-
            Only when the attempt failed or expired. `true`: a fresh attempt
            cannot double-charge. `false`: do not retry automatically.
        failure_kind:
          type:
            - string
            - 'null'
          enum:
            - provider_not_reached
            - timed_out
            - declined
            - other
            - null
          description: >-
            Always present. `null` while the attempt hasn't failed (prompt sent,
            free order confirmed); otherwise the sanitized reason.
            `provider_not_reached` means nothing was charged and comes with
            `retry_safe: true`.


            Why the latest payment attempt failed, as a sanitized bucket; `null`
            unless the order is `failed` or `expired`. Never the payment
            provider's own wording.


            - `provider_not_reached`: the charge never reached the payment
            provider. **Nothing was charged**; it is safe to retry at once.

            - `timed_out`: the payer didn't approve in time, or the prompt
            expired.

            - `declined`: the payer declined or cancelled, or can't pay
            (balance, PIN, limit).

            - `other`: anything else.


            Treat an unknown value as `other`. See [Why it
            failed](/guides/custom-ui#why-it-failed-failure-kind).
          examples:
            - null
      required:
        - order_id
        - order_code
        - status
        - total
        - currency
        - next_action
        - skipped_provider
        - failure_kind
    Error:
      type: object
      properties:
        code:
          type: integer
          description: HTTP status, repeated in the body.
        message:
          type: string
          description: Short outcome. Do not branch on it; it may be reworded.
        error:
          type: string
          description: Human-readable detail. May be reworded; never parse it.
        error_code:
          type: string
          description: >-
            Stable machine-readable code. **Branch on this.** Present on every
            Partner API error, including `429 rate_limited`, `404 not_found` for
            unknown routes and `500 server_error`. See the [error
            reference](/concepts/errors).
        details:
          type: object
          properties:
            retry_after_seconds:
              type: integer
          description: Only on `409 prompt_in_progress`.
      required:
        - code
        - message
        - error
        - error_code
      description: The Laces error envelope.
    NextAction:
      type: object
      properties:
        kind:
          type: string
          description: >-
            `await_confirmation`: the payer approves on their phone; wait for
            the webhook or poll. `none`: nothing to do (free order). `redirect`
            (send the buyer's browser to `redirect_url`) is reserved for card,
            which is not yet available to partners.
          enum:
            - await_confirmation
            - redirect
            - none
        redirect_url:
          type: string
          description: Only for `redirect`.
          format: uri
      required:
        - kind
      description: What to do next. Branch on `kind`.
  headers:
    X-Request-Id:
      description: >-
        Unique id of this request (`req_…`). On every response, readable from
        browsers. Quote it to support.
      schema:
        type: string
      example: req_V1StGXR8Z5jdHi6BmyTq
    Retry-After:
      description: Whole seconds to wait before retrying. Readable from browsers.
      schema:
        type: integer
    RateLimit-Limit:
      description: Requests allowed in the current window.
      schema:
        type: integer
    RateLimit-Remaining:
      description: Requests left in the current window.
      schema:
        type: integer
    RateLimit-Reset:
      description: Seconds until the window resets.
      schema:
        type: integer
  responses:
    Unauthorized:
      description: >-
        No credential, an invalid or expired token, or an invalid publishable
        key. Comes with `WWW-Authenticate: Bearer realm="laces-partner"`.
        Revoking or regenerating keys can take up to a minute to reach every
        server.
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/Error'
          examples:
            unauthorized:
              summary: unauthorized
              value:
                code: 401
                message: Unauthorized
                error: Missing credentials
                error_code: unauthorized
            invalid_token:
              summary: invalid_token
              value:
                code: 401
                message: Unauthorized
                error: Invalid or expired access token
                error_code: invalid_token
            invalid_key:
              summary: invalid_key
              value:
                code: 401
                message: Unauthorized
                error: Invalid publishable key
                error_code: invalid_key
      headers:
        X-Request-Id:
          $ref: '#/components/headers/X-Request-Id'
    Forbidden:
      description: >-
        The credential is not allowed here: a missing scope (scope changes take
        up to about a minute to apply), a publishable key on a bearer-only
        route, or a publishable key from an origin that is not an allowed
        domain.
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/Error'
          examples:
            insufficient_scope:
              summary: insufficient_scope
              value:
                code: 403
                message: Forbidden
                error: 'Missing scope: orders:read'
                error_code: insufficient_scope
            credential_not_permitted:
              summary: credential_not_permitted
              value:
                code: 403
                message: Forbidden
                error: This endpoint does not accept a publishable key
                error_code: credential_not_permitted
            origin_not_allowed:
              summary: origin_not_allowed
              value:
                code: 403
                message: Forbidden
                error: This origin is not allowed for this key
                error_code: origin_not_allowed
      headers:
        X-Request-Id:
          $ref: '#/components/headers/X-Request-Id'
    NotFound:
      description: >-
        Not found, or it belongs to another organization (the same answer, so
        existence never leaks).
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/Error'
          examples:
            not_found:
              summary: not_found
              value:
                code: 404
                message: Not found
                error: Event not found
                error_code: not_found
      headers:
        X-Request-Id:
          $ref: '#/components/headers/X-Request-Id'
    PayloadTooLarge:
      description: The body is over 1 MB.
      headers:
        X-Request-Id:
          $ref: '#/components/headers/X-Request-Id'
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/Error'
          examples:
            payload_too_large:
              summary: payload_too_large
              value:
                code: 413
                message: Validation failed
                error: The request body is too large
                error_code: payload_too_large
    RateLimited:
      description: >-
        Rate limited. Wait `Retry-After` seconds. Limits are per API client
        (reads about 300 a minute, writes about 60) and, for publishable keys,
        also per IP (about 120 a minute).
      headers:
        Retry-After:
          $ref: '#/components/headers/Retry-After'
        RateLimit-Limit:
          $ref: '#/components/headers/RateLimit-Limit'
        RateLimit-Remaining:
          $ref: '#/components/headers/RateLimit-Remaining'
        RateLimit-Reset:
          $ref: '#/components/headers/RateLimit-Reset'
        X-Request-Id:
          $ref: '#/components/headers/X-Request-Id'
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/Error'
          example:
            code: 429
            message: Too many requests, please slow down
            error: rate_limited
            error_code: rate_limited
    ServerError:
      description: >-
        Unexpected failure. Retry with backoff, reusing the same
        `Idempotency-Key`.
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/Error'
          examples:
            server_error:
              summary: server_error
              value:
                code: 500
                message: An unexpected error occurred
                error: Something went wrong
                error_code: server_error
      headers:
        X-Request-Id:
          $ref: '#/components/headers/X-Request-Id'
  securitySchemes:
    bearerAuth:
      type: http
      scheme: bearer
      bearerFormat: LCSTOK-…-X (opaque)
      description: >-
        A token from `POST /oauth/token`, valid 1 hour. Paste it as `LCSTOK-…-X`
        (without the word Bearer).
    oauth2:
      type: oauth2
      description: >-
        OAuth 2.0 client credentials. The token it yields is the `bearerAuth`
        token.
      flows:
        clientCredentials:
          tokenUrl: https://connect.laces.run/v1/oauth/token
          scopes:
            events:read: List and read events
            orders:write: Create and pay orders
            orders:read: >-
              List and read orders, including buyer personal data (off by
              default)
            checkout_sessions:write: Create and read checkout sessions

````

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