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

# Submit your externally-signed order signature (step 2 of 2)

> Completes the self-custody order flow started by `POST /v2/orders/intent`: hand back `payload_id` and the 65-byte signature you produced over the returned digest. The server atomically claims the stored intent with a Redis `GETDEL` (single-use — a replayed or concurrent second submit for the same `payload_id` gets `400 payload_id not found or expired`), recomputes the EIP-712 digest itself (never trusting anything in this request beyond the signature), `ecrecover`s the signer, confirms it matches the intent's declared owner AND is a wallet registered to the authenticated caller, then forwards the assembled signed order to the venue CLOB.
**The claim happens BEFORE any verification**, so the `payload_id` is consumed even when the request goes on to fail — a bad signature burns the intent.
**Synchronous result.** `status` is the venue's immediate result string, passed through verbatim. For a marketable order that crossed immediately this IS your fill confirmation; for a resting order it confirms the order is live. `exchange_order_id` is the venue's handle — alongside the internal `order_id` it is the durable identifier for cancels.
**Async fills.** A resting order's later fills are NOT returned here — they stream over your authenticated `/ws` connection as `partially_filled` / `filled` / `status_changed` events (deduped on the venue trade id; automatic reconciliation ensures fills are never silently lost, just occasionally a beat slower without `/ws`). Poll `GET /orders/{order_id}` if you are not holding a WebSocket open.
**A persistence failure does NOT fail the request.** If the order lands at the venue but the Kairos row cannot be written, the `200` is still returned and the failure is logged — treat the venue as authoritative.
**Retries.** The claimed intent is single-use and deleted on claim — a retry or a reprice requires a brand-new `POST /v2/orders/intent` (fresh `salt`/`timestamp_ms` → fresh digest → fresh signature). There is no silent server-side re-sign. The rate-limit slot was already charged at `/intent` and is not charged again here.
**Auth & scope.** Requires the fastlane allowlist (re-checked here, independent of the check at `/intent`) and `trade:execute` for the provider recorded on the stored intent. No service token / CSRF token is required.



## OpenAPI

````yaml /openapi/execution.yaml post /v2/orders/submit
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:
  /v2/orders/submit:
    post:
      tags:
        - Orders
      summary: Submit your externally-signed order signature (step 2 of 2)
      description: >-
        Completes the self-custody order flow started by `POST
        /v2/orders/intent`: hand back `payload_id` and the 65-byte signature you
        produced over the returned digest. The server atomically claims the
        stored intent with a Redis `GETDEL` (single-use — a replayed or
        concurrent second submit for the same `payload_id` gets `400 payload_id
        not found or expired`), recomputes the EIP-712 digest itself (never
        trusting anything in this request beyond the signature), `ecrecover`s
        the signer, confirms it matches the intent's declared owner AND is a
        wallet registered to the authenticated caller, then forwards the
        assembled signed order to the venue CLOB.

        **The claim happens BEFORE any verification**, so the `payload_id` is
        consumed even when the request goes on to fail — a bad signature burns
        the intent.

        **Synchronous result.** `status` is the venue's immediate result string,
        passed through verbatim. For a marketable order that crossed immediately
        this IS your fill confirmation; for a resting order it confirms the
        order is live. `exchange_order_id` is the venue's handle — alongside the
        internal `order_id` it is the durable identifier for cancels.

        **Async fills.** A resting order's later fills are NOT returned here —
        they stream over your authenticated `/ws` connection as
        `partially_filled` / `filled` / `status_changed` events (deduped on the
        venue trade id; automatic reconciliation ensures fills are never
        silently lost, just occasionally a beat slower without `/ws`). Poll `GET
        /orders/{order_id}` if you are not holding a WebSocket open.

        **A persistence failure does NOT fail the request.** If the order lands
        at the venue but the Kairos row cannot be written, the `200` is still
        returned and the failure is logged — treat the venue as authoritative.

        **Retries.** The claimed intent is single-use and deleted on claim — a
        retry or a reprice requires a brand-new `POST /v2/orders/intent` (fresh
        `salt`/`timestamp_ms` → fresh digest → fresh signature). There is no
        silent server-side re-sign. The rate-limit slot was already charged at
        `/intent` and is not charged again here.

        **Auth & scope.** Requires the fastlane allowlist (re-checked here,
        independent of the check at `/intent`) and `trade:execute` for the
        provider recorded on the stored intent. No service token / CSRF token is
        required.
      operationId: submitSignedOrder
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/OrderSubmitSignedRequest'
      responses:
        '200':
          description: Venue's synchronous placement result.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/OrderSubmitSignedResponse'
        '400':
          description: >-
            Exact `error` strings:

            - `payload_id not found or expired` — wrong / stale /
            already-claimed id

            - `payload_id expired` — the id resolved but its wall-clock
            `expires_at_us` has passed

            - `payload_id does not belong to authenticated user`

            - `invalid signature hex` — not decodable hex

            - `invalid signature: signature must be 65 bytes (got N)` / `invalid
            signature: failed to parse signature: …` / `invalid signature:
            ecrecover failed: …`

            - `signature recovered 0x… does not match owner 0x…` — usually the
            EIP-191-vs-raw-digest mistake

            - `recovered signer is not a registered wallet for this user` — also
            returned when the wallet lookup itself errors

            - `polymarket credentials not configured`

            - Predict.fun only: `predict.fun trading is not enabled for this
            account — no venue signing wallet is registered`; `this predict.fun
            account is a Kernel smart account, which the external-signing lane
            does not support yet …`; `owner_address is not your registered
            predict.fun trading wallet`

            - `venue returned an empty order id; order not tracked`

            - Under `FASTLANE_ADMISSION=enforce`, the re-run admission
            rejections (same set as `/intent`, minus the rate limit).
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/OrderSimpleErrorResponse'
        '401':
          $ref: '#/components/responses/ExecUnauthorized'
        '403':
          description: >-
            `external-signing execution is not enabled for this account`;
            `external-signing authorization check failed`; `API key missing
            trade:execute scope`; `API-key access to <provider> is disabled`; or
            (enforce-mode admission) `Trading not enabled for user`.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/OrderSimpleErrorResponse'
        '500':
          description: >-
            `internal server error` — Redis claim failure, stored intent no
            longer deserializes or rebuilds, the recomputed digest differs from
            the stored one (refused), or an internal failure assembling the
            order. The order may already be LIVE on the venue — verify via `GET
            /orders/{order_id}` or `/ws` before retrying.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/OrderSimpleErrorResponse'
        '502':
          description: >-
            `CLOB error: <venue message>` — the venue rejected the signed order
            (balance/allowance/tick); the venue's message is passed through
            verbatim.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/OrderSimpleErrorResponse'
        '503':
          description: >-
            `API-key access to <provider> is unavailable` (access cache read
            failed); `verification temporarily unavailable` (an EIP-1271 RPC
            timeout — reachable only on a lane that accepted a non-EOA signature
            type, so not expected today); or an enforce-mode circuit-breaker
            rejection. NOTE: the `payload_id` was already consumed, so a retry
            needs a fresh intent.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/OrderSimpleErrorResponse'
components:
  schemas:
    OrderSubmitSignedRequest:
      type: object
      description: Body for `POST /v2/orders/submit`.
      required:
        - payload_id
        - signature_hex
      properties:
        payload_id:
          type: string
          format: uuid
          description: >-
            The `payload_id` returned by `POST /v2/orders/intent`. Single-use —
            claimed atomically on the first `/submit` call.
        signature_hex:
          type: string
          description: >-
            0x-prefixed 65-byte secp256k1 signature (`r || s || v`) over
            `eip712_digest_hex`.
          example: 0x1234...1b
    OrderSubmitSignedResponse:
      type: object
      description: >-
        Response for `POST /v2/orders/submit` (and the equivalent one-RTT WSS
        `submit_signed_order` command).
      required:
        - order_id
        - status
      properties:
        order_id:
          type: string
          format: uuid
          description: >-
            Internal Kairos order id — usable for `GET /orders/{order_id}` and
            the cancel endpoints.
        exchange_order_id:
          type: string
          nullable: true
          description: >-
            Venue-assigned order handle. An EMPTY venue id is treated as a
            failed submission and returned as `400 venue returned an empty order
            id; order not tracked`, so on a `200` this is in practice always
            populated; it is `null` only on the idempotent-replay response path.
        status:
          type: string
          description: >-
            The venue's immediate result string, passed through verbatim —
            Kairos does not normalize or validate it. Polymarket's observed
            values are `matched`, `live` and `delayed`; `unmatched` appears on
            the fill-polling path. Treat this as an open string, not a closed
            enum.
          example: matched
    OrderSimpleErrorResponse:
      type: object
      description: >-
        Minimal error body (`{"error": "..."}`, optionally `{"code": "..."}`)
        used by the auth middleware (401/403/429 on any endpoint), the
        external-signing `/v2/orders/*` lane, and the Kalshi-offchain balance
        endpoint. Distinct from `OrderErrorResponse` — these endpoints do not
        emit the full structured `error_details` shape.
      required:
        - error
      properties:
        error:
          type: string
          description: Human-readable error message.
          example: Insufficient scope
        code:
          type: string
          nullable: true
          description: >-
            Present only on the Kalshi-offchain endpoints (omitted entirely, not
            null, when absent). Observed values: `PLATFORM_API_ACCESS_DENIED`,
            `PLATFORM_API_ACCESS_DISABLED`, `INSUFFICIENT_SCOPE`,
            `NOT_CONFIGURED`, `NO_CREDENTIALS`, `INVALID_USER_ID`,
            `MISSING_API_KEY_ID`, `INVALID_PRIVATE_KEY`, `INVALID_CREDENTIALS`,
            `CONFIG_ERROR`, `ENCRYPTION_ERROR`, `DB_ERROR`, `INTERNAL_ERROR`,
            `KALSHI_API_ERROR`.
  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.