Looking for current positions / open holdings? Use the RPC API’s
positions.getPositions. The endpoints below are historical and aggregated PnL analytics, not real-time position state.Base URL
Two pipelines
Which endpoint you call decides which backend answers, and the two backends cover different venues. Pick the pipeline first, then the endpoint./pnl/{user_id}— the legacy aggregator. Fans out per-provider to each venue’s own data source (including Kalshi’s REST API directly), merges the results. This is the only path that covers Kalshi, since Kalshi’s user ids are internal and non-public. (One exception: when onlypolymarket_walletis supplied and thenative_pnl_pipelineflag is on, this route serves from the materialized-view pipeline and falls back to the aggregator only if that returns nothing.)/pnl/hover/...and/pnl/wallet-totals/...— the newer wallet-address-keyed materialized-view pipeline. Coverspolymarket,opinion,predictfunonly — Kalshi and Hyperliquid are rejected with400on both.
The aggregator is keyed by Kairos user id and wallet parameters; the MV pipeline is keyed by a wallet address in the URL path.
Authentication
Every/pnl/* route accepts either a JWT (Authorization: Bearer <token>) or an API key (X-Client-Id/X-Api-Key/X-Api-Secret, scope position:read). Examples below read credentials from KAIROS_CLIENT_ID, KAIROS_API_KEY, and KAIROS_API_SECRET in your shell.
Every /pnl/* route is behind the server-side invite gate. Admin-secret and
API-key callers bypass it; a JWT session belonging to an uninvited user gets
403 Invite required while invite-only mode is on.
API-key requests are also subject to
platform access.
/pnl/providers checks every provider it would return. /pnl/{user_id} checks
the explicit provider filter or the providers implied by supplied wallets.
Hover and wallet-total routes check their path provider. Multi-provider checks
fail the whole request if any provider is disabled.
List providers
position:read.
Providers are discovered dynamically from the pnl_providers registry, not a
hardcoded list. For API-key callers, the endpoint checks every provider it
would return; if any provider is disabled, the entire request returns 403.
JWT/admin callers bypass the platform gate.
Request
No parameters.Response
Get user PnL
Authorization: Bearer <token>, sub claim) or an API key (X-Client-Id/X-Api-Key/X-Api-Secret, scope position:read).
The path user_id must match the authenticated caller’s own id — JWT sub for session auth, or the API key’s linked user_id. Any mismatch returns 403; this endpoint can only ever return the caller’s own PnL.
Request
*At least one of
polymarket_wallet, kalshi_wallet, or hyperliquid_wallet is required.
provider value must be both a registered venue and a registered PnL
provider (polymarket, kalshi, opinion, predictfun, hyperliquid). A
venue that exists but has no PnL provider fails at the service layer and
returns 400 Invalid PnL request parameters.
Gotcha: per-provider fetch failures are swallowed, not surfaced. If one venue’s upstream errors out, the aggregator logs it, drops that venue, and still returns
200 with the remaining providers merged — a provider missing from summary.by_exchange means “no data or fetch failed”, not “zero PnL”. Compare the returned keys against the providers you asked for before showing a user a total.Response
by_exchange[provider] object:
Hover PnL (wallet × market)
position:read.
Live PnL for a single wallet on a single market, read directly from the materialized-view pipeline — fast regardless of how far back the wallet’s earliest trade goes. Returns one row per token held (e.g. YES + NO on a binary market) plus aggregate totals.
Only polymarket, opinion, and predictfun are supported — kalshi and hyperliquid are rejected with 400. Use /pnl/{user_id} for those providers instead.
Gotcha: this route returns a well-formed zero payload, not an error, when the
native_pnl_pipeline feature flag is off. While the pipeline’s backfill is ramping, expect tokens: [] and all totals 0.0 rather than a 404 — this is the same “no data” state the frontend renders gracefully. A 200 here is not proof the wallet holds nothing.Request
Response
Token row:
Wallet totals
position:read.
Gotcha: this route reads from an hourly-refreshed snapshot, not a live scan — a fast point lookup, but figures can lag reality by up to an hour. For a single active market where freshness matters more than cost, use
/pnl/hover/... instead, which reads live data. Render snapshot_ts so the user can see how stale the number is./pnl/hover above (only polymarket/opinion/predictfun; kalshi and hyperliquid rejected with 400).
Request
Response
Numeric precision
Errors
Errors are returned as a FastAPIHTTPException body:
429 from the shared rate limiter is the one exception — it uses
{ "error": "Rate limit exceeded: <limit>" } plus X-RateLimit-* and
Retry-After headers.
The Code column holds the exact detail string where this page documents one, otherwise —.

