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.getSummaryreturns a server-sidemarketValueover the user’s complete position set.
Monetary fields are strings unless noted, to preserve decimal precision. Parse them with a big-decimal library, notparseFloat/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 singletotalUsd 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).
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
totalUsdis the unified figure: the sum of every personal-chain token’susdValueplus each trading account’sbalanceUsd. It is rendered to 2 decimals and clamped to>= 0. It is cash only — it does not include open-position market value.personal.evmAddressis 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 underpersonal.solana.- Hyperliquid splits its USDC collateral into two token rows:
USDC(spot, spendable on HL markets) andUSDC_PERP(perps book; Arbitrum→HL bridge deposits land here and must be moved to spot before trading HL markets). tokens[].usdValueis"0.00"when the token is unpriced.contractAddressis omitted for native gas tokens (isNative: true).tradingis 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 aswallet.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.
position:read · Rate limit: queries
Cache: Cached briefly per address.
Input
Example
Response
Field notes
buyingPowersums each chain’s stable + native-token USD. For a Polymarket deposit-wallet user, the Polygon contribution is the proxypolymarketBuyingPower(pUSD), not the EOAtotalUsdc— the EOA stables aren’t spendable on the CLOB. Sponsored POL is subtracted out.polymarketBuyingPowerandproxyUsdcare present only for deposit-wallet (V2) Polymarket users; they’renull/absent otherwise.usdce(bridged USDC.e) is excluded fromtotalUsdcand buying power — it must be wrapped to pUSD/USDC before it’s tradeable.
Errors
Gotchas
balances.getAllBalances
The per-venue spendable-cash view: every chain (via the same fetch asgetWalletBalances) 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.
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
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: falsesilently 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). ItsusdValueis then"0.00"— a partial outage lowers your figure rather than erroring. Check the flag on every row you add. The top-leveldegradedboolean is the one-field version of the same check:truemeans at least one row is unavailable and any sum you compute under-reports. A chain whose read fails is emitted as an explicitavailable: falserow 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 provider403s the whole call. The provider gate checkspolymarket,predictfun, andhyperliquidon every call, pluskalshiwhen a Kalshi credential exists. There’s no filter to narrow it.
There is alsobalances.getBalanceForAddressandbalances.getGasEstimate. Both areposition:read-scope queries on thequeriesbucket, like everything else on this page.getBalanceForAddresstakes{chain, address}and returns{chain, polygon|solana|bsc|arbitrum, lastUpdated}.chainmust be one of"polygon","solana","bsc","arbitrum"—"hyperliquid"is not accepted here and yields400 BAD_REQUEST(chain must be 'polygon', 'solana', 'bsc', or 'arbitrum'), as does an emptyaddress(address is required). Ownership is verified against your account: an address you don’t own returns404 NOT_FOUND(Address not found for user), never another user’s balance. There is noforceRefreshon 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.
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
Field notes
openPositionCountandmarketsTradedare point-in-time / all-time, not bounded bydateRange.volume,fees,realizedPnL,netPnL,winRate,roiPercent, and the fill-size stats respect the window.roiPercentis realized-PnL-over-volume (realizedPnL / volume * 100), not a capital-weighted return. It is0whenvolumeis0.winRateiswinningPositions / (winningPositions + losingPositions) * 100, andtotalPositionsis that same denominator — not a count of all positions.marketValuecounts open positions plus resolved-but-unredeemed winners. Redeemed positions (already cash), dust, and resolved losers contribute0.exposureis 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 marketatRisk = max(0, cost − min(side0Shares, side1Shares)).
Errors
Gotchas
dateRangeis not validated against an enum. Any unrecognised value (including a typo) falls through to theALLbranch, which looks back 5 years, and"custom"without acustomStartdoes the same. Check your spelling — you get a plausible-looking answer for the wrong window rather than aBAD_REQUEST.
marketValue,unrealizedPnL, andexposureignoredateRangeentirely. They are point-in-time over the complete position set. This is deliberate: summing per-rowcurrentValuefrom a paginatedpositions.getPositionssilently 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 — sounrealizedPnLdrifts toward0instead of positions vanishing frommarketValue.
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:openPositionsMarketValue=portfolio.getSummary.marketValue(this is the off-chain / market-priced component). Prefer this over summingcurrentValueacrosspositions.getPositionsyourself: 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 thebalances.getAllBalancesrows you intend to spend from — within onedomain— to get that figure; it is API-key accessible withposition: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.”Other portfolio procedures
The rest of theportfolio.* 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.
