Skip to main content
This page documents the balance and portfolio procedures: the unified wallet view (wallet.getPortfolio), the lower-level per-chain balances (balances.getWalletBalances), the per-venue spendable-cash view (balances.getAllBalances), and the PnL/stats summary (portfolio.getSummary) that the dashboard uses. Reach for it when you need to know how much cash an account holds, what its open positions are worth, or how to reproduce the dashboard’s headline number. Auth: every procedure on this page accepts API keys with the position:read scope, not the general read scope. See API Keys for the full scope map.
There is no single “cash + open positions” endpoint. The Kairos “portfolio value” a user sees on the dashboard is composed from two calls — a cash number and an equity number. See Reconstructing the dashboard total at the bottom; a bot that wants the same number assembles it the same way the web app does. The equity half no longer has to be summed client-side: portfolio.getSummary returns a server-side marketValue over the user’s complete position set.
Monetary fields are strings unless noted, to preserve decimal precision. Parse them with a big-decimal library, not parseFloat / float().

wallet.getPortfolio

The unified balance view. Returns every Kairos-custodied wallet the user holds — personal (Kairos-custodied) wallets across all EVM chains + Solana, plus provider-bound trading accounts (e.g. the Polymarket deposit-wallet / Safe) — and a single totalUsd summing the USD value of all of them. This is the right call when you want “how much cash does this account hold, everywhere, in one number.” It does not include the market value of open positions (those live in positions.getPositions).
Access: Invited · Scope: position:read · Rate limit: queries bucket (3600/min default) Cache: Per-chain balances are cached briefly. Pass forceRefresh: true to bust the cache (e.g. right after a transfer).

Input

Input is optional — an empty body returns the cached view.

Example — unified cash balance for a bot

Response

Field notes

  • totalUsd is the unified figure: the sum of every personal-chain token’s usdValue plus each trading account’s balanceUsd. It is rendered to 2 decimals and clamped to >= 0. It is cash only — it does not include open-position market value.
  • personal.evmAddress is shared across Polygon, BSC, Arbitrum, and Hyperliquid — one EVM key is derived per account, so all EVM chains use the same address. Solana has its own address under personal.solana.
  • Hyperliquid splits its USDC collateral into two token rows: USDC (spot, spendable on HL markets) and USDC_PERP (perps book; Arbitrum→HL bridge deposits land here and must be moved to spot before trading HL markets).
  • tokens[].usdValue is "0.00" when the token is unpriced. contractAddress is omitted for native gas tokens (isNative: true).
  • trading is always a JSON array ([] when the user has no provider trading accounts). Currently only Polymarket is wired; Kalshi / Opinion / Predict.fun cards will appear here as their custodial wallets land.

Gotchas

trading[].balanceUsd is the spendable balance, not the sum of tokens. For Polymarket it is the proxy’s pUSD only. Un-wrapped USDC / USDC.e sitting on the proxy are surfaced in tokens (so a client can offer a “wrap to pUSD” affordance) but are deliberately excluded from balanceUsd and totalUsd — the CLOB only spends pUSD, so counting un-wrapped stables would overstate buying power.

balances.getWalletBalances

The lower-level, per-chain balance read. Same underlying data as wallet.getPortfolio’s personal wallets, but shaped per chain and with an aggregate buyingPower figure (tradeable cash across all chains). Use this when you want raw per-chain token breakdowns or the single buying-power number, rather than the unified portfolio shape.
Access: Invited · Scope: position:read · Rate limit: queries Cache: Cached briefly per address.

Input

Example

Response

Field notes

  • buyingPower sums each chain’s stable + native-token USD. For a Polymarket deposit-wallet user, the Polygon contribution is the proxy polymarketBuyingPower (pUSD), not the EOA totalUsdc — the EOA stables aren’t spendable on the CLOB. Sponsored POL is subtracted out.
  • polymarketBuyingPower and proxyUsdc are present only for deposit-wallet (V2) Polymarket users; they’re null/absent otherwise.
  • usdce (bridged USDC.e) is excluded from totalUsdc and buying power — it must be wrapped to pUSD/USDC before it’s tradeable.

Errors

Gotchas

A null chain is not a zero balance. Any chain the user hasn’t onboarded — or that failed to fetch — comes back as null rather than failing the call. Don’t assume a chain key is present, and don’t read null as “no funds”.degradedReads is how you tell the two apart. A leg listed there was configured and failed this fetch; a leg that is simply absent was never onboarded. buyingPower excludes every degraded leg, so a non-empty degradedReads means the number under-reports — back off and re-read rather than sizing against it. Keys are polygon, polymarket_proxy, solana, bsc, arbitrum, hyperliquid; polymarket_proxy is the buying-power leg of Polygon, not a venue of its own.

balances.getAllBalances

The per-venue spendable-cash view: every chain (via the same fetch as getWalletBalances) plus the off-chain Kalshi balance, as one row per venue. Each row names its custody domain, and there is deliberately no grand total: chain USDC and Kalshi USD are different custody domains, so a single number spanning both is not spendable by any order.
Access: Invited · Scope: position:read · Rate limit: its own balances_agg bucket, 30/min — far tighter than queries, because each call fans out across several chain RPCs plus the order-execution service. Poll it accordingly.

Input

None.

Example

Response

Each venue’s usdValue is that venue’s own spendable cash. Add rows within one domain to size an order; adding across domains produces a figure no order can spend, which is why the server no longer returns one.

Gotchas

available: false silently lowers whatever you add up. It means the venue is configured but couldn’t be read right now (e.g. the Kalshi balance call failed, or an API-key caller with no session token). Its usdValue is then "0.00" — a partial outage lowers your figure rather than erroring. Check the flag on every row you add. The top-level degraded boolean is the one-field version of the same check: true means at least one row is unavailable and any sum you compute under-reports. A chain whose read fails is emitted as an explicit available: false row rather than vanishing from the list, so “venue absent” now means not configured and never failed to read.
A missing Kalshi row does not mean zero. The Kalshi row appears only when the account has an active Kalshi credential, and if that credential lookup fails the row is omitted rather than marked unavailable.
One disabled provider 403s the whole call. The provider gate checks polymarket, predictfun, and hyperliquid on every call, plus kalshi when a Kalshi credential exists. There’s no filter to narrow it.

There is also balances.getBalanceForAddress and balances.getGasEstimate. Both are position:read-scope queries on the queries bucket, like everything else on this page. getBalanceForAddress takes {chain, address} and returns {chain, polygon|solana|bsc|arbitrum, lastUpdated}. chain must be one of "polygon", "solana", "bsc", "arbitrum" — "hyperliquid" is not accepted here and yields 400 BAD_REQUEST (chain must be 'polygon', 'solana', 'bsc', or 'arbitrum'), as does an empty address (address is required). Ownership is verified against your account: an address you don’t own returns 404 NOT_FOUND (Address not found for user), never another user’s balance. There is no forceRefresh on this procedure — the shared cache generation still invalidates it after any fund movement.

portfolio.getSummary

The PnL / trading-stats summary that powers the dashboard header tiles (net PnL, volume, win rate, ROI, open-position count) over a date range. This is the procedure the portfolio page calls for its performance numbers. It is stats plus equity, not cash — it returns realized PnL and trade aggregates, and alongside them a server-side mark-to-market valuation (marketValue, unrealizedPnL, exposure) computed over the user’s complete position set. It does not return wallet cash.
Access: InvitedWithToken · Scope: position:read · Rate limit: queries The WithToken part applies to browser callers, who must present a session token in addition to being logged in. API-key callers satisfy the level with the three X-Api-* headers alone — no session token, and no invite gate.

Input

Example

Response

Numeric fields here are JSON numbers (not strings) — these are display aggregates, not on-chain amounts.

Field notes

  • openPositionCount and marketsTraded are point-in-time / all-time, not bounded by dateRange. volume, fees, realizedPnL, netPnL, winRate, roiPercent, and the fill-size stats respect the window.
  • roiPercent is realized-PnL-over-volume (realizedPnL / volume * 100), not a capital-weighted return. It is 0 when volume is 0.
  • winRate is winningPositions / (winningPositions + losingPositions) * 100, and totalPositions is that same denominator — not a count of all positions.
  • marketValue counts open positions plus resolved-but-unredeemed winners. Redeemed positions (already cash), dust, and resolved losers contribute 0.
  • exposure is the cost basis of held positions in unresolved markets, minus the guaranteed payout of hedged pairs — matched shares on both sides of a binary market pay $1/pair regardless of outcome, so per market atRisk = max(0, cost − min(side0Shares, side1Shares)).

Errors

Gotchas

dateRange is not validated against an enum. Any unrecognised value (including a typo) falls through to the ALL branch, which looks back 5 years, and "custom" without a customStart does the same. Check your spelling — you get a plausible-looking answer for the wrong window rather than a BAD_REQUEST.
marketValue, unrealizedPnL, and exposure ignore dateRange entirely. They are point-in-time over the complete position set. This is deliberate: summing per-row currentValue from a paginated positions.getPositions silently undercounts large accounts, so the aggregate is computed server-side over every row.
A slow price feed depresses PnL rather than hiding holdings. The price fan-out is capped at 3 s. A position whose live quote doesn’t resolve in time is marked to cost, not to zero — so unrealizedPnL drifts toward 0 instead of positions vanishing from marketValue.
The provider gate here is account-wide. It checks every platform the user trades on, with no filter to narrow it. If any one is disabled for API access, the whole call fails.

Reconstructing the dashboard total

The big “portfolio value” on the web dashboard is not a single endpoint — it’s assembled client-side as:
where:
  • openPositionsMarketValue = portfolio.getSummary.marketValue (this is the off-chain / market-priced component). Prefer this over summing currentValue across positions.getPositions yourself: the summary values the complete position set, whereas a paginated positions read only covers the page you fetched — the exact bug that made large accounts read low.
  • walletCash = the user’s spendable cash. Add the balances.getAllBalances rows you intend to spend from — within one domain — to get that figure; it is API-key accessible with position:read. It applies buying-power semantics — for a Polymarket deposit-wallet user the Polygon contribution is the proxy’s pUSD only, not the EOA stables — and additionally covers the off-chain Kalshi balance, which no per-chain read can see.
balances.getWalletBalances.buyingPower is the on-chain-only version of the same figure: identical buying-power semantics, but no Kalshi leg. Use it when you don’t need Kalshi and want the cheaper queries bucket instead of balances_agg’s 30/min. Either way, use one of those two — not wallet.getPortfolio.totalUsd — if you want to match the dashboard’s cash number.
wallet.getPortfolio.totalUsd is a different quantity. It sums every personal-wallet token’s USD value (including EOA USDC/stables and gas tokens) plus each trading account’s spendable balanceUsd. For a deposit-wallet user holding EOA USDC alongside their proxy, totalUsd will read higher than the dashboard cash, because the dashboard counts only spendable (proxy) pUSD on Polygon. Pick totalUsd when you want “all cash held everywhere”; pick buyingPower when you want “cash I can trade with right now.”
So there are two valid net-worth recipes, depending on which cash definition you want:
There is deliberately no server-side “net worth” endpoint — keeping cash and positions as separate reads lets callers choose the cash definition (all-held vs. spendable, with or without Kalshi) rather than baking one convention into the API.

Other portfolio procedures

The rest of the portfolio.* router is API-key accessible with position:read on the queries bucket, and all of it shares getSummary’s account-wide provider gate and its dateRange / customStart / customEnd inputs (with the same unvalidated-fallthrough-to-ALL behaviour). All four return 500 INTERNAL_SERVER_ERROR with a procedure-specific message (Failed to fetch activity, Failed to fetch chart data, Failed to fetch settlements, Failed to fetch daily PnL breakdown, or Failed to resolve portfolio market IDs) when an underlying query fails, and 400 BAD_REQUEST (Invalid input: <detail>) on a schema mismatch.
Two pagination caveats. getActivity merges its three sources in memory and paginates the merged list, so total is the sum of the source row counts — it can drift slightly from the number of rows you can actually page through once duplicate wallet transactions are collapsed. getSettlements paginates in SQL but derives total from a separate count, falling back to the page length if that count fails.