Skip to main content
Realized and unrealized profit and loss across Polymarket, Kalshi, Predict.fun, Opinion, and Hyperliquid. Full parameter reference and a live tester: API Reference.
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 only polymarket_wallet is supplied and the native_pnl_pipeline flag 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. Covers polymarket, opinion, predictfun only — Kalshi and Hyperliquid are rejected with 400 on 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

The PnL providers this deployment can report on. Call it before hard-coding a provider filter. Auth: JWT or API key; API keys require 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

Merged PnL for the authenticated caller across every venue implied by the wallets they supply. This is the only endpoint that reports Kalshi or Hyperliquid PnL. Auth: accepts either a JWT (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.
A 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.
Hyperliquid PnL is available only through this aggregate endpoint. Open HIP-4 positions and unrealized PnL use Hyperliquid account balances and mark prices; historical fills use Kairos trade history. Realized PnL and fee fields are currently reported as zero for Hyperliquid.

Response

by_exchange[provider] object:
Gotcha: total is a page-scoped ceiling, not a full-history count. The record list is truncated to min(limit+offset, 1000) before slicing, so do not drive a pagination widget or a “N trades all-time” figure off it. Use has_more to decide whether to fetch another page.

Hover PnL (wallet × market)

Auth: JWT or API key; API keys require 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

Whole-wallet PnL totals for one provider, in a single point lookup. Auth: JWT or API key; API keys require 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.
Same provider restriction and feature-flag-gated zero payload as /pnl/hover above (only polymarket/opinion/predictfun; kalshi and hyperliquid rejected with 400).

Request

Response

Numeric precision

Gotcha: monetary fields on this page are JSON floats (USD), not decimal strings — this differs from Trader Stats, which returns PnL as decimal strings for precision. Parse accordingly, and do not compare a figure from this page byte-for-byte against one from Trader Stats.

Errors

Errors are returned as a FastAPI HTTPException 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 —.