---
title: "Build an unsigned EIP-712 order payload for self-custody signing (step 1 of 2)"
method: POST
path: "/v2/orders/intent"
tags: ["Orders"]
---

# Build an unsigned EIP-712 order payload for self-custody signing (step 1 of 2)

`POST /v2/orders/intent`

## Request

- Base URL: `https://execution.kairos.trade`
- URL: `https://execution.kairos.trade/v2/orders/intent`
- Auth: API key in header `X-Client-Id` and API key in header `X-Api-Key` and API key in header `X-Api-Secret`

The first step of the "external-signing" / bring-your-own-key institutional lane (Polymarket and Predict.fun, EOA only): the server builds the canonical EIP-712 typed-data message for the order you describe and stashes it single-use in Redis under a fresh `payload_id` (60 s TTL). NO order is placed and NOTHING is signed by Kairos — the response gives you a 32-byte digest (or the full typed-data payload) to sign yourself, off your own key, then hand back to `POST /v2/orders/submit`. This removes the custodial signing hop from the order path for approved market makers / institutional desks.
**Venue.** `provider` selects the venue: `polymarket` (default) or `predictfun`. On Predict.fun you supply nothing venue-specific — the server resolves the market's `isYieldBearing`/`isNegRisk` pair (which selects one of four verifying contracts), its `feeRateBps` (part of the signed struct) and its price tick from authoritative metadata, and cross-checks your `intent.neg_risk` against the market. Predict.fun's signed `Order` struct is NOT the same shape as Polymarket's.
`market_id` and `outcome` are METADATA ONLY — they are never part of the signed digest, so they can't be tampered with post-signing — but both are REQUIRED: the server resolves `intent.token_id` against `market_id` and rejects a mismatch, a blank `market_id`, or a missing `outcome` with `400`.
Recommendation: omit `intent.salt` and `intent.timestamp_ms` and let the server stamp them — this guarantees a fresh digest per intent. Only set them yourself if you need to reproduce a digest deterministically (or if you're building the order fully client-side over the one-RTT WebSocket `submit_signed_order` command instead of this two-RTT REST flow, which REQUIRES you to set both — and is Polymarket-only).
**Allowlist.** Restricted to `User.executionFastlaneEnabled` accounts, flipped by a Kairos admin during onboarding — an unapproved account gets `403 external-signing execution is not enabled for this account`, never a bare `401`, so a valid-but-unapproved caller gets an unambiguous signal. The gate fails closed on a DB error, which is also a `403` (`external-signing authorization check failed`) rather than a `500`. The flag is read through a short-lived cache, so a revocation takes effect within a couple of seconds rather than instantly.
**Only `signature_type: 0` (EOA) is accepted** on this lane — Poly1271 / smart-wallet signature types are rejected at intake (`400`) rather than failing later at `/submit` with an opaque signer mismatch, and `signer_address` (if sent) must equal `owner_address`.
**Admission.** The same size/price bounds, time-in-force and post-only capability gates, circuit breakers and per-user order rate limit that guard `POST /orders` are applied here, gated by the `FASTLANE_ADMISSION` deployment setting (`off` | `shadow` | `enforce`). Under `off`/`shadow` a narrower gate still runs: the execution circuit breakers and the post-only capability check. A rate-limit slot is charged HERE, on `/intent`, not on `/submit`.
**Errors are the minimal `{"error": "..."}` shape** on this whole lane — no `error_details`, no `code`.
**Auth & scope.** Requires `trade:execute` for the selected provider, AND the fastlane allowlist above. Unlike `POST /orders`, this endpoint does NOT require a service token / CSRF token — a session JWT alone is sufficient.

## Request body

- OrderIntentRequest: Body for `POST /v2/orders/intent`.
  - `provider` 'polymarket' | 'predictfun': Target venue for the external-signing lane. Only these two venues are implemented; Kalshi and Solana variants exist in the internal types but are not reachable on `/v2/orders/*`. The one-RTT WebSocket `submit_signed_order` command and the whole `/v2/onchain/*` lane are Polymarket-only and have no `provider` field.
  - `intent` OrderIntentPayload, required: The unsigned order intent — `intent` field of `OrderIntentRequest`, mirrored inside the one-RTT WSS `submit_signed_order` command.
    - `token_id` string, required: Polymarket outcome-token id (uint256, decimal string).
    - `side` 'buy' | 'sell', required: Order/intent side. Lower-case on the wire.
    - `price` string, required: Decimal string, limit price in `[tick, 1]`. Caller is responsible for snapping to the market's tick — the server does not re-snap, so the digest you compute locally matches what the server recomputes.
    - `size` string, required: Decimal string, order size in shares.
    - `time_in_force` 'GTC' | 'GTD' | 'FOK' | 'FAK' | 'IOC', required: Time-in-force, always UPPER-CASE on the wire. `GTC`/`GTD` are resting (limit-style); `FOK`/`FAK`/`IOC` are immediate taker executions.
    - `post_only` boolean: Maker-only. NOT part of the signed EIP-712 digest — it rides on the outer venue payload. Requires a resting time-in-force (`GTC` or `GTD`); anything else is rejected with `400 post_only requires a resting time-in-force (GTC or GTD), not <tif>`.
    - `expiration_unix_secs` integer, nullable: UNIX seconds expiration. Required if (and only meaningful when) `time_in_force=GTD` — a GTD intent without it is rejected, and one already in the past is rejected with `400 expiration_unix_secs is in the past for this GTD order`. On Polymarket this value is NOT part of the signed `Order` struct; it travels on the outer payload.
    - `neg_risk` boolean, required: Whether the market is a neg-risk market — selects the EIP-712 verifying contract. On Polymarket the caller is expected to know this. On Predict.fun the value is CROSS-CHECKED against authoritative market metadata and a mismatch is a `400`, never a silent correction.
    - `owner_address` string, required: 0x-checksummed EOA address that will sign the digest. For `signature_type=0` (EOA) this MUST equal both the order maker and signer. On Predict.fun it must additionally be your registered Predict.fun trading wallet.
    - `signer_address` string, nullable: Signer address. On this lane it must equal `owner_address` (or be omitted); a different value is rejected with `400 signer_address must equal owner_address for signature_type 0 (EOA)`.
    - `signature_type` 0, required: Signature-type discriminant. Only `0` (EOA) is accepted on this lane — the intake validator rejects anything else with `400 only signature_type 0 (EOA) is supported on the external-signing lane`. (The underlying Polymarket builder also understands `2` = Poly1271, but the HTTP/WSS handlers never let it through, so verification here is always raw `ecrecover` and never EIP-1271.)
    - `salt` string, nullable: uint256 decimal string. If omitted, the server generates one and returns it in `unsigned_payload`. REQUIRED (along with `timestamp_ms`) if you build and sign the order fully client-side over the one-RTT WSS `submit_signed_order` command instead of this two-RTT REST flow.
    - `timestamp_ms` string, nullable: uint256 decimal string, order timestamp in milliseconds (CLOB V2 uses this in place of a nonce). If omitted, the server stamps it. See `salt` for when it's required.
  - `market_id` string, nullable: Condition id — METADATA ONLY, not part of the signed digest, but REQUIRED in practice. The server resolves `intent.token_id` against it and rejects a blank value with `400 market_id is required for external orders`, a token that does not belong to it with `400 token_id does not belong to the supplied market_id`. Max 256 chars.
  - `outcome` string, nullable: REQUIRED — outcome label (e.g. `"Yes"`/`"No"`); metadata only, not signed. A missing/empty value is `400 outcome is required for external orders`, and a label that does not match the resolved token is `400 token_id does not match the supplied outcome`. Max 64 chars.

## Response `200`

Unsigned EIP-712 payload + digest. Sign `eip712_digest_hex` as a raw 32-byte hash, or run `unsigned_payload` through `eth_signTypedData` — both yield an identical signature.

- OrderIntentResponse: Response for `POST /v2/orders/intent`.
  - `payload_id` string, uuid, required: Single-use handle for this stored intent. Pass back to `POST /v2/orders/submit`.
  - `unsigned_payload` OrderUnsignedPayload, required: Full EIP-712 typed-data payload — pass directly to `eth_signTypedData` as an alternative to raw-hash-signing `eip712_digest_hex`.
    - `domain` OrderEipDomain, required: EIP-712 domain separator fields.
      - `name` string, required
      - `version` string, required
      - `chain_id` integer, required
      - `verifying_contract` string, required
    - `primary_type` string, required
    - `types` object, required: The full EIP-712 type set for `eth_signTypedData`.
    - `message` object, required: The order struct's field values, as they will be hashed.
    - `eip712_digest_hex` string, required: 0x-prefixed 32-byte digest. Sign this raw (no EIP-191 prefix) to produce a signature identical to signing `message` via `eth_signTypedData`.
  - `eip712_digest_hex` string, required: Convenience copy of `unsigned_payload.eip712_digest_hex` — the digest to sign.
  - `expires_at_us` integer, required: Wall-clock expiry, UNIX microseconds (60 s out by default, `EXTERNAL_SIGNING_INTENT_TTL_SECS`). The stored intent is claimed with an atomic Redis `GETDEL` at the very top of `/submit`, BEFORE any signature verification — so it is consumed by ANY submit attempt, not only a successful one. A `/submit` that fails verification burns the `payload_id`; retrying needs a fresh `POST /v2/orders/intent`.

## Other responses

- `400`: Intake validation, metadata resolution, admission or payload-build failure. Exact `error` strings: - `only signature_type 0 (EOA) is supported on the external-signing lane` - `signer_address must equal owner_address for signature_type 0 (EOA)` - `outcome is required for external orders (e.g. "Yes" / "No")` / `outcome too long (max 64 chars)` - `market_id is required for external orders` / `market_id too long (max 256 chars)` / `token_id too long (max 256 chars)` - `token_id does not belong to the supplied market_id` / `token_id does not match the supplied outcome` - `expiration_unix_secs is in the past for this GTD order` - `invalid intent: …` — wraps the venue payload builder: price outside `(0, 1]`, size not positive or over 2 decimal places, GTD without `expiration_unix_secs`, `post_only` with a non-resting TIF, unparseable `token_id`, amount/notional overflow - Predict.fun only: `neg_risk mismatch: market <id> is neg_risk=<x>, intent said <y>`, `could not load predict.fun market <id>`, `expiration_unix_secs is required for time_in_force=GTD`, `invalid price: …` / `invalid size: …` / `invalid amounts: …` - Under `FASTLANE_ADMISSION=enforce`, the shared admission rejections: `Quantity must be positive`, `Minimum order quantity is N shares`, `Maximum order quantity is 1000000 shares`, `Price must be positive`, `Price is required for all orders`, `Price must be <= 1 for <venue>`, and the TIF/post-only capability messages. Self-signed orders have no minimum notional.
- `401`: No valid credential presented — missing/invalid API-key headers or an invalid/expired session token.
- `403`: `API key missing trade:execute scope`; `API-key access to <provider> is disabled`; `external-signing execution is not enabled for this account` (not allow-listed); `external-signing authorization check failed` (allowlist DB read failed — fails closed as a 403, not a 500); or, under `FASTLANE_ADMISSION=enforce`, `Trading not enabled for user`.
- `429`: `Order rate limit exceeded` — the per-user (and per-credential, if your key has an `orders` override) order window. Only charged under `FASTLANE_ADMISSION=enforce`. No `Retry-After` header is sent.
- `500`: `internal server error` — failed to resolve the outcome, serialize or store the intent, or a `payload_id` collision (astronomically unlikely). Under enforce-mode admission also `Exchange capabilities unavailable` and `Failed to get trading status`.
- `503`: `Execution disabled by circuit breaker: <reason>` or `Order rejected by circuit breaker: <reason>` (global/per-exchange halt, or a side/kind/TIF restriction — canary users bypass both); `API-key access to <provider> is unavailable` (access cache read failed); `predictfun execution is not available on this node`.

---

[API](https://skmtc.dev/kairos/apis/kairos-order-execution-api.md) - [All operations](https://skmtc.dev/kairos/apis/kairos-order-execution-api/llms.txt) - [OpenAPI document](https://skmtc.dev/kairos/apis/kairos-order-execution-api/revisions/9ba7d463d505?raw)
