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

# Create (or preview) a self-serve cross-venue market link

> Declares that two markets on two different venues are the same real-world contract, making them routable together by `GET /orders/route-quote`, `POST /orders/route-buy` and `POST /orders/route-close`.
**Two-step by design.** `confirm: false` (the default) returns a verified preview and writes nothing. Show the user the resolved pairing, then call again with `confirm: true` AND the preview's `verification_fingerprint` as `fingerprint`. The confirm re-resolves against fresh metadata and requires the result to fingerprint-match what was reviewed, so index drift between the two calls becomes a `409` ("review the pairing again") rather than a silently different link.
**You attest; the server verifies.** Both legs are resolved against indexed market metadata and the link is REFUSED unless the venues' yes/no outcome labels pair exactly (case-insensitive). Outcome inversion is the one catastrophic failure mode here, so it is gated deterministically rather than by the user's click — a `Yes/No` vs `Trump/Harris` pair may well be the same market, but the mapping is not machine-provable and is refused rather than guessed.
**Scope of a created link.** Links are approved but USER-SCOPED: routable only by their creator, so a bad self-link's blast radius is the creator's own wallet. Capped at 25 links per user, enforced inside the insert transaction so concurrent confirms cannot overshoot it.
Expiry gaps and resolution-source divergence come back as `warnings`, not errors — for a self-scoped link they are the creator's risk to accept.
**Rate limited** at 15 requests/minute per user (`MARKET_LINK_RATE_LIMIT_PER_MIN`), fail-closed. Previews count: every call costs two indexed-metadata lookups plus duplicate checks, and the 25-link cap only bounds completed inserts.
> **Error shape.** This endpoint returns `{"message": "..."}` — NOT the `{"error": ...}` used everywhere else on this service, and with no `code` field. See `OrderMarketLinkErrorResponse`.
**Auth.** Requires authentication and the mutation gate (API-key triple, `X-Service-Token`, or CSRF token). It enforces **no scope** — any authenticated credential that clears the mutation gate can create links.



## OpenAPI

````yaml /openapi/execution.yaml post /orders/market-links
openapi: 3.1.0
info:
  title: Kairos Order Execution API
  version: 1.0.0
  summary: >-
    Order entry, cancellation, fee quotes, and live position exposure across
    venues.
  description: >
    The Order Execution API at `execution.kairos.trade` places and manages

    orders across every venue Kairos integrates. Two lanes:


    - **Custodial** (`POST /orders`) — Kairos signs and routes on your behalf
      (custodial signing). Async ack: a 200 means validated + enqueued; track via
      `GET /orders/{order_id}` or the WebSocket order/fill stream.
    - **Self-custody / external signing** (`POST /v2/orders/intent` +
      `POST /v2/orders/submit`) — for allow-listed institutional accounts on
      Polymarket and Predict.fun: Kairos builds the EIP-712 payload, you sign
      with your own key, submission is synchronous with the venue's verbatim
      result. `POST /v2/onchain/intent` + `POST /v2/onchain/submit` are the
      same pattern for on-chain operations (approvals, redeem, split/merge,
      unwrap) on Polygon, where you also pay the gas.

    ## Authentication


    All endpoints require a Kairos API key (`X-Client-Id` + `X-Api-Key` +

    `X-Api-Secret`) or a first-party session JWT, plus the per-operation

    scope (`trade:execute`, `trade:read`, `position:read`) where the operation

    documents one. Synthetic Books accepts any otherwise-valid account

    credential without an extra scope or allowlist. There is

    no anonymous tier. Scope checks apply to API-key credentials only; a

    session JWT carries no scope list and is treated as holding every scope.


    The custodial mutation endpoints (`POST /orders`, the three cancel

    endpoints) and Synthetic Books create/refresh/release carry an additional

    `RequireServiceToken` gate satisfied by **any** of: the full API-key triple,

    an internal `X-Service-Token`, or a valid `X-Csrf-Token`. API-key consumers

    need no extra header — but a bare `Authorization: Bearer <jwt>` alone is NOT

    sufficient on those routes. The `/v2/orders/*` and `/v2/onchain/*`

    external-signing routes have no such gate: a session JWT alone works there.


    ## Rate limits


    Order submission is capped per user (default 5 orders per **second**,

    sliding window, `ORDER_RATE_LIMIT_PER_SEC`); some credentials carry an

    `orders` override, which is enforced on its own per-credential window of

    the same duration *in addition to* the per-user window — either one

    denying is a `429`. Idempotent replays (same `client_order_id`) return the

    existing order before the limiter runs and never consume a slot. The

    limiter is Redis-backed and **fails closed**: if Redis is unreachable the

    submission is denied with `429`.


    Separately, repeated *authentication failures* from one client IP are

    throttled at 10 failures per 60 s (also fail-closed). That limiter returns

    the minimal `{"error": "Too many authentication attempts"}` body.


    **No rate-limit headers.** This service does not emit `Retry-After`,

    `X-RateLimit-*`, or any other backoff hint on a `429` — back off on your

    own schedule.


    ## Error responses


    **Six different error body shapes are in use across this service and they

    are not interchangeable.** Check the shape documented on the specific

    operation before writing a parser; a client that assumes one shape will

    read `undefined` for the reason on the others.


    1. `OrderErrorResponse` — the structured envelope (`error`, `code`,
       `error_details{code,message,details?,metadata?,actions}`) returned by
       every handler that surfaces an `ExecutionError`/`ApiError`: order
       submission, cancel-all, the CTF endpoints, the Polymarket/Opinion
       onboarding endpoints, Hyperliquid withdraw/transfer, and most of the
       deposit-wallet family. Note the two `code` fields differ in case:
       top-level `code` is PascalCase (`"AuthInsufficientScope"`), while
       `error_details.code` is the SCREAMING_SNAKE_CASE wire code
       (`"AUTH_INSUFFICIENT_SCOPE"`). Match on `error_details.code`.
    2. `OrderSimpleErrorResponse` — the minimal `{"error": "..."}` (sometimes
       with `code`) used by the auth middleware (any endpoint's
       `401`/`403`/`429`), the whole `/v2/*` external-signing and on-chain
       lane, the Kalshi and Predict.fun endpoints.
    3. `OrderMarketLinkErrorResponse` — `{"message": "..."}`, with no `error`
       and no `code`. Used by `POST /orders/market-links` **only**.
    4. **Empty or plain-text, not JSON at all.** Several deposit-wallet
       endpoints — most importantly
       the RPC signed-batch submission and
       `POST /exchanges/polymarket/imported/relay-info` — return most 4xx/5xx
       responses with a **completely empty body** and `content-type:
       text/plain`. A handful of cases carry a bare plain-text sentence
       (`batch is not an allowed withdrawal or collateral conversion`,
       `relayer rejected batch: …`). Do not attempt to JSON-parse these.
    5. Handlers whose Rust signature returns a bare `StatusCode` likewise send
       **no body at all**; those responses are marked "empty body (status code
       only)".
    6. `SyntheticBookErrorResponse` — `{"error": "stable_snake_case_code",
       "message": "...", "details"?: {...}}` on `/v1/synthetics*`. Match the
       top-level `error`; upstream definition validation may be nested under
       `details`.

    A further wrinkle inside shape 1: some provider-access checks discard the

    specific reason and return a generic `"Request failed with status 403"` /

    `AUTH_CREDENTIALS_INVALID` body, while others preserve

    `AUTH_INSUFFICIENT_SCOPE` and the real message. Do not rely on the message

    text of an access denial being stable.


    The central `ExecutionError` → HTTP mapping (`ApiError::from`) is:


    | `ExecutionError` | Status | `error_details.code` |

    |---|---|---|

    | `InsufficientBalance` | 400 | `FUNDS_INSUFFICIENT_USDC` |

    | `CollateralLocation` | 400 | `FUNDS_COLLATERAL_LOCATION` |

    | `InvalidOrder` | 400 | `VALIDATION_INVALID_ORDER` |

    | `PositionShortfall` | 400 | `FUNDS_INSUFFICIENT_BALANCE` |

    | `MarketClosed` | 400 | `EXCHANGE_POLYMARKET_MARKET_CLOSED` |

    | `OrderAlreadyCancelled` | 400 | `VALIDATION_INVALID_ORDER` |

    | `UnsupportedExchange` | 400 | `EXCHANGE_UNSUPPORTED` |

    | `SlippageExceeded` | 400 | `MARKET_FOK_NOT_FILLED` |

    | `FokNotFilled` | 400 | `MARKET_FOK_NOT_FILLED` |

    | `AuthenticationError` | 401 | `AUTH_CREDENTIALS_INVALID` |

    | `CredentialError` | 401 | `AUTH_CREDENTIALS_NOT_FOUND` |

    | `MarketNotFound` | 404 | `VALIDATION_MARKET_NOT_FOUND` |

    | `OrderNotFound` | 404 | `VALIDATION_INVALID_ORDER` |

    | `MarketNotSettledOnChain` | 409 | `VALIDATION_MARKET_NOT_SETTLED_ON_CHAIN`
    |

    | `OrderbookUnavailable` | 422 | `ORDERBOOK_UNAVAILABLE` |

    | `ExchangeError` (code `429`/`RATE_LIMITED`) | 429 |
    `EXCHANGE_POLYMARKET_RATE_LIMITED` |

    | `ExchangeError` (code `401`/`UNAUTHORIZED`) | 401 |
    `AUTH_POLYMARKET_API_KEY_INVALID` |

    | `SigningError` | 500 | `SIGNATURE_ERROR` |

    | `DatabaseError` | 500 | `DATABASE_ERROR` |

    | `InternalError` / `LedgerReconciliationRequired` /
    `SponsoredRequestWedged` / `PreTradeError` | 500 | `INTERNAL_ERROR` |

    | `NetworkError`, `ExchangeError` (any other code) | 502 | `NETWORK_ERROR` /
    venue-classified |

    | `LockError` | 503 | `INTERNAL_ERROR` |

    | `Timeout` | 504 | `NETWORK_TIMEOUT` |


    A venue `ExchangeError` is further classified from the venue's own message

    text before it is mapped, so an "allowance is not enough" rejection becomes

    `ALLOWANCE_CTF_NOT_SET`, a "not enough balance" rejection becomes

    `FUNDS_INSUFFICIENT_BALANCE`, "post-only mode" becomes `MARKET_NOT_READY`,

    a "no liquidity" rejection becomes `MARKET_INSUFFICIENT_LIQUIDITY`, and so

    on. Match on `error_details.code`, never on `error`.


    ## Server-level guards


    Every request is subject to a 120 s timeout (`OE_REQUEST_TIMEOUT_SECS`), a

    2 MiB request-body cap (`OE_MAX_BODY_BYTES`, over-size bodies get `413`),

    and a 1024-request global concurrency ceiling

    (`OE_MAX_CONCURRENT_REQUESTS`). Every response carries
    `X-Content-Type-Options:

    nosniff`, `X-Frame-Options: DENY`, HSTS, and a `default-src 'none'` CSP.


    ## Conventions


    - Prices are decimal strings on the 0–1 scale; `*_bps` fields are the
      same value in basis points (× 10000).
    - Quantities/sizes are decimal strings.

    - Structured errors carry `error_details.code`
      (SCREAMING_SNAKE_CASE) plus actionable recovery `actions`.

    ## Changelog


    **2026-09-14 — collateral routing fields on `POST /orders`.** Four optional

    request fields and one optional response field. `collateral`

    (`skip` | `check` | `fund`) **defaults to** `skip`, which is exactly today's

    path: no affordability check, no hold, no funding, zero added latency — so

    no existing caller changes behaviour without opting in. `shard_funding`

    **defaults to** `true`, which is also exactly today's behaviour: the Kalshi

    shard move is same-venue, zero-fee and already runs for every account; send

    `false` to opt out. `collateral=fund` requires both `max_bridge_fee_usdc`

    and `max_funding_wait_ms` and is rejected `400` naming the missing cap;

    either cap without `fund` is likewise a `400`. The response gains

    `funding`, `null` whenever no funding work ran. FIX sessions carry the same

    fields on `NewOrderSingle(D)` as optional tags `5701`–`5704`; a session that

    sends none of them is unchanged.
  contact:
    name: Kairos
    url: https://app.kairos.trade/docs/api-reference
  termsOfService: https://kairos.trade/terms
servers:
  - url: https://execution.kairos.trade
    description: Production (central primary, us-east-1)
  - url: https://eu-west-1-polymarket.executor.kairos.trade
    description: >-
      Production regional execution node — Ireland, colocated with Polymarket.
      Same API surface; assigned at onboarding.
  - url: https://ap-northeast-1-predictfun.executor.kairos.trade
    description: >-
      Production regional execution node — Tokyo, colocated with Predict.fun.
      Same API surface; assigned at onboarding.
  - url: https://staging-execution.kairos.trade
    description: Staging
security:
  - apiKeyClientId: []
    apiKeyKey: []
    apiKeySecret: []
paths:
  /orders/market-links:
    post:
      tags:
        - Routing
      summary: Create (or preview) a self-serve cross-venue market link
      description: >-
        Declares that two markets on two different venues are the same
        real-world contract, making them routable together by `GET
        /orders/route-quote`, `POST /orders/route-buy` and `POST
        /orders/route-close`.

        **Two-step by design.** `confirm: false` (the default) returns a
        verified preview and writes nothing. Show the user the resolved pairing,
        then call again with `confirm: true` AND the preview's
        `verification_fingerprint` as `fingerprint`. The confirm re-resolves
        against fresh metadata and requires the result to fingerprint-match what
        was reviewed, so index drift between the two calls becomes a `409`
        ("review the pairing again") rather than a silently different link.

        **You attest; the server verifies.** Both legs are resolved against
        indexed market metadata and the link is REFUSED unless the venues'
        yes/no outcome labels pair exactly (case-insensitive). Outcome inversion
        is the one catastrophic failure mode here, so it is gated
        deterministically rather than by the user's click — a `Yes/No` vs
        `Trump/Harris` pair may well be the same market, but the mapping is not
        machine-provable and is refused rather than guessed.

        **Scope of a created link.** Links are approved but USER-SCOPED:
        routable only by their creator, so a bad self-link's blast radius is the
        creator's own wallet. Capped at 25 links per user, enforced inside the
        insert transaction so concurrent confirms cannot overshoot it.

        Expiry gaps and resolution-source divergence come back as `warnings`,
        not errors — for a self-scoped link they are the creator's risk to
        accept.

        **Rate limited** at 15 requests/minute per user
        (`MARKET_LINK_RATE_LIMIT_PER_MIN`), fail-closed. Previews count: every
        call costs two indexed-metadata lookups plus duplicate checks, and the
        25-link cap only bounds completed inserts.

        > **Error shape.** This endpoint returns `{"message": "..."}` — NOT the
        `{"error": ...}` used everywhere else on this service, and with no
        `code` field. See `OrderMarketLinkErrorResponse`.

        **Auth.** Requires authentication and the mutation gate (API-key triple,
        `X-Service-Token`, or CSRF token). It enforces **no scope** — any
        authenticated credential that clears the mutation gate can create links.
      operationId: createMarketLink
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/OrderCreateMarketLinkRequest'
      responses:
        '200':
          description: >-
            A verified preview, a newly created link, or an existing routable
            link — read `status` to tell which.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/OrderMarketLinkResponse'
        '400':
          description: >-
            `invalid user id`; `exactly two legs required`; or `confirm requires
            the preview's verification fingerprint`.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/OrderMarketLinkErrorResponse'
        '401':
          $ref: '#/components/responses/ExecUnauthorized'
        '409':
          description: >-
            `one of these markets is already claimed by another link`; `one of
            these markets was just claimed by another link` (lost insert race);
            or `market metadata changed since the preview — review the pairing
            again` (fingerprint mismatch).
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/OrderMarketLinkErrorResponse'
        '422':
          description: >-
            The pairing is not verifiable. Messages: `<provider> is not a
            routable venue`; `legs must be on two different venues`;
            `<provider>: market <id> is not indexed`; `<provider>: market is
            settled|closed|resolved`; `<provider>: self-serve linking not
            supported`; `polymarket: not a binary market (N outcomes)`;
            `polymarket: outcome labels unavailable` / `token ids unavailable` /
            `condition id unavailable`; `predictfun: yes token unavailable` /
            `no token unavailable` / `outcome labels unavailable`; `outcome
            labels differ (A/B vs C/D) — the side mapping can't be verified`;
            `link limit reached (25 per user)`.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/OrderMarketLinkErrorResponse'
        '429':
          description: >-
            `too many link requests — wait a moment and try again`. No
            `Retry-After` header.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/OrderMarketLinkErrorResponse'
        '500':
          description: '`link lookup failed` or `link insert failed`.'
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/OrderMarketLinkErrorResponse'
        '502':
          description: '`market metadata lookup failed` — the indexed-metadata query failed.'
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/OrderMarketLinkErrorResponse'
components:
  schemas:
    OrderCreateMarketLinkRequest:
      type: object
      description: Body for `POST /orders/market-links`.
      required:
        - legs
      properties:
        legs:
          type: array
          description: Exactly two legs, on two DIFFERENT venues.
          minItems: 2
          maxItems: 2
          items:
            $ref: '#/components/schemas/OrderMarketLinkLegRef'
        title:
          type: string
          nullable: true
          description: >-
            Optional display title. Trimmed, truncated to 512 characters; a
            blank value falls back to the first leg's market name.
        similarity:
          type: number
          format: double
          nullable: true
          description: Matcher confidence, carried through for provenance only.
        confirm:
          type: boolean
          description: >-
            `false` (the default) returns a verified preview and writes nothing.
            `true` inserts the link, and additionally requires `fingerprint`.
          default: false
        fingerprint:
          type: string
          nullable: true
          description: >-
            REQUIRED when `confirm` is `true` — the `verification_fingerprint`
            from the preview. The confirm re-resolves against fresh metadata and
            requires the result to match what you reviewed, so index drift
            becomes a `409` rather than a silently different link.
    OrderMarketLinkResponse:
      type: object
      description: Response for `POST /orders/market-links`.
      required:
        - status
        - legs
        - warnings
        - verification_fingerprint
      properties:
        status:
          type: string
          description: >-
            `preview` (nothing written), `created` (inserted), or `exists` (a
            link the caller can already route).
          enum:
            - preview
            - created
            - exists
        link_id:
          type: string
          format: uuid
          nullable: true
          description: Null on a `preview`.
        scope:
          type: string
          nullable: true
          description: >-
            On `exists`, whether the existing link is `global` or the caller's
            own `user` link. Always `user` on `created`; null on `preview`.
          enum:
            - global
            - user
        title:
          type: string
          nullable: true
          description: Null on an `exists` response.
        legs:
          type: array
          items:
            $ref: '#/components/schemas/OrderMarketLinkLegPreview'
        warnings:
          type: array
          description: >-
            Non-blocking advisories (venue expiry gaps, resolution-source
            divergence). Always includes the standing "venues may resolve on
            different data sources" notice.
          items:
            type: string
        verification_fingerprint:
          type: string
          description: >-
            16-hex-character hash of the verified identity material (ids,
            tokens, case-folded outcome labels). Echo it back as `fingerprint`
            on confirm. Change detection, not security.
    OrderMarketLinkErrorResponse:
      type: object
      description: >-
        Error body for `POST /orders/market-links` ONLY. The key is `message`,
        NOT `error` — this endpoint uses a fourth error shape found nowhere else
        in this API, and a client written against `OrderErrorResponse` or
        `OrderSimpleErrorResponse` will silently read `undefined` for the
        reason. There is no `code` field.
      required:
        - message
      properties:
        message:
          type: string
          example: exactly two legs required
    OrderMarketLinkLegRef:
      type: object
      required:
        - provider
        - market_id
      properties:
        provider:
          type: string
          description: Routable venue. Only `polymarket` and `predictfun` are accepted.
          enum:
            - polymarket
            - predictfun
        market_id:
          type: string
          description: >-
            Either the venue-native executor id or the stream-side alias (the
            web terminal's Polymarket ids are the numeric Gamma form) — both are
            matched against the index.
    OrderMarketLinkLegPreview:
      type: object
      required:
        - provider
        - market_id
        - stream_key
        - outcome_yes_label
        - outcome_no_label
        - market_name
        - expires_at
      properties:
        provider:
          type: string
        market_id:
          type: string
          description: Executor-convention market id (the condition id on Polymarket).
        stream_key:
          type: string
          description: >-
            The stream-side id clients hold (the numeric Gamma id on
            Polymarket).
        outcome_yes_label:
          type: string
        outcome_no_label:
          type: string
        market_name:
          type: string
        expires_at:
          type: string
          description: '`YYYY-MM-DD HH:MM:SS`, as indexed — not RFC 3339.'
  responses:
    ExecUnauthorized:
      description: >
        No valid credential presented — missing/invalid API-key headers or an
        invalid/expired session token.
      content:
        application/json:
          schema:
            type: object
            required:
              - error
            properties:
              error:
                type: string
                example: Unauthorized
  securitySchemes:
    apiKeyClientId:
      type: apiKey
      in: header
      name: X-Client-Id
      description: >-
        Credential client id (`kairos_ck_...`). Must be sent together with
        X-Api-Key and X-Api-Secret.
    apiKeyKey:
      type: apiKey
      in: header
      name: X-Api-Key
      description: 64-char hex API key.
    apiKeySecret:
      type: apiKey
      in: header
      name: X-Api-Secret
      description: 64-char hex API secret.

````

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