When to use RPC instead of REST
Use the RPC API instead of the public REST/trader-stats/* endpoints when you need:
- Current open positions for a user (wallet + all linked wallets).
- Real-time order status, fills, and execution errors.
- Programmatic position close / liquidation flows.
- Bot configuration and history.
/trader-stats/* is a cached proxy over third-party on-chain indexing for Polymarket. It’s great for public-facing trader profiles, but it can lag and its volume numbers use Polymarket’s contract-count convention — not notional USD (notional is quantity × price). For anything operational, use RPC.
Base URL
<router>.<procedure> naming. Example: positions.getPositions, positions.closePosition, markets.getMarkets. Order submission and cancellation are the one exception — those live on the order execution REST service (https://execution.kairos.trade/), not under this <router>.<procedure> scheme; see API Keys.
Authentication
Programmatic clients authenticate with an API key — thekairos_ck_... triple — passed as three HTTP headers on every request:
positions.getPositions only ever returns that user’s positions, across every wallet linked to the account.
Scopes
Keys carry scopes with limited one-way inheritance:position:read/trade:read satisfy RPC read, and trade:execute satisfies
RPC trade. The reverse is not true — holding read does not grant
position:read. See the full scope table for the
exact mapping. Calling a procedure your key isn’t scoped for returns
403 FORBIDDEN.
Access levels
Each procedure declares an access level. For API-key callers:
Only the procedures in the API-key allowlist accept API keys; any other procedure returns
403 FORBIDDEN with This procedure does not accept API key authentication.
HTTP semantics (tRPC)
The server speaks tRPC v10 over plain HTTP. If you’re calling from TypeScript, use the generated@kairoslive/kairos client; from anything else, call the HTTP endpoints directly — the wire format is simple.
Queries — GET
Pass the input as a URL-encoded JSON object in?input=.... The input is wrapped in {"json": ...} by the standard tRPC client, but the server also accepts raw JSON without the wrapper.
httpx:
Mutations — POST
Send the input as JSON in the body, wrapped in{"json": ...}:
trade scope. No CSRF token is needed — header auth isn’t cookie-replayable.
Gotcha: mutations are strict about the envelope. Anything other than
POST returns 405 METHOD_NOT_SUPPORTED, and a missing or
non-application/json Content-Type returns 415 UNSUPPORTED_MEDIA_TYPE.
Bodies are capped at 1 MB (413 PAYLOAD_TOO_LARGE), enforced on both
Content-Length and the actual read, so chunked encoding can’t bypass it.Response shape
Successful calls return:data.code is the tRPC string code, data.httpStatus matches the HTTP status on the response, and data.path echoes the procedure (truncated to 128 characters for unrouted paths). The outer code is the JSON-RPC numeric equivalent:
Always extract
result.data for success or check for the error field on failure.
Gotcha: a missing
result.data with no error field is not an empty
result. It indicates a malformed response — treat it as a fatal error, not
as “no data”.Rate limits
Every procedure declares one bucket. Buckets are sliding one-minute windows in Redis, shared across replicas. The identity a window is keyed on depends on how you authenticated:- API key → the credential (
apikey:<credentialId>). Two credentials owned by the same user get separate budgets. - Session JWT → the user.
- Unauthenticated → the client IP.
RATE_LIMIT_*_RPM, and per credential by Kairos):
Exceeding a bucket returns
TOO_MANY_REQUESTS (HTTP 429).
Gotcha: there is noRetry-Afterheader on a 429. The wait is embedded in the message —Rate limit exceeded. Please retry in <N> seconds.Parse it, or back off exponentially.
Gotcha: the rate limiter fails closed. If its Redis is unreachable the server returns500 INTERNAL_SERVER_ERRORwithService temporarily unavailable. Please retry shortly.That is a retryable outage, not a bug in your payload.
Pagination
Queries that list entities use offset/limit pagination unless otherwise documented:limit— integer;<= 0falls back to the procedure default (50 on the position/portfolio lists), values above100are clamped to100.offset— integer, default0.positions.getPositionsrejects a negative offset withBAD_REQUEST; other list procedures treat out-of-range offsets as an empty page.
total and hasMore (offset + limit < total) so callers can loop until hasMore is false.
Errors you’ll see
Unrouted paths (
404) are themselves rate limited on a separate per-IP bucket, so a scanner probing for procedure names gets 429s rather than an enumeration oracle.
