Portfolio
Five read endpoints answer "what does this user hold, and what has it done".
They all name the user in a userId query parameter - a partner token
identifies your organisation, not a person - and they all refuse a user you do
not own with a 404.
Requires portfolio:read, and transactions:read for the transfer feed -
granted by name, or through the
all:read wildcard.
| Endpoint | Scope | Returns |
|---|---|---|
GET …/portfolio/wallet/assets | portfolio:read | Token balances |
GET …/portfolio/wallet/transactions | transactions:read | Indexed transfers |
GET …/portfolio/wallet/snapshot/history | portfolio:read | Portfolio value over time |
GET …/portfolio/positions/open/staking/list | portfolio:read | Open staking positions |
GET …/portfolio/positions/open/stocks/list | portfolio:read | Open stock positions |
The wallet reads sync themselves
The Restake app bootstraps a wallet with two deliberate gestures - pull the current balances from the on-chain indexer, then register the address for transaction webhooks so they keep moving. You have nowhere to make those calls from: there is no onboarding screen in your integration, and a user you created through the API may well deposit before you ever read their portfolio.
So the two wallet endpoints do both on the way through. Read the balances and they are already refreshed. Whichever of the two you call first is what bootstraps the account, and every response says what actually happened:
Code
| Value | Meaning |
|---|---|
updated | Ran on this request and succeeded |
current | Nothing to do - inside the refresh window, or already registered |
skipped | The user has no linked wallet, so there was nothing to read |
failed | The upstream indexer did not answer |
`failed` means the numbers are stale
A failed sync never fails the request. You still get a 200 carrying the last
data Restake stored, which may be minutes or days old. Branch on
sync.assets before you show a balance as current - that field is the only
thing that distinguishes the two.
The refresh is rate-limited per user, not per call. Reading the same user
twice in quick succession refreshes once and reports current the second time.
Polling harder does not produce fresher data; it just gets you the same answer
sooner. Between refreshes the webhook subscription is what keeps balances
moving, which is why registering it matters more than the polling interval.
Balances
Code
Code
amount is already scaled by token.decimals - a human-readable number, the
same convention the staking endpoints take.
verified: false marks a token discovered on-chain that Restake has not vetted.
Those are excluded from the valuations below, and anyone can mint one into a
wallet, so do not price them or surface them as holdings without saying where
they came from.
A user with no linked wallet is not an error: you get walletAddress: null, an
empty list and sync: { "assets": "skipped", … }.
Transfers
Code
Code
amount is always positive; direction carries the sign, relative to this
user's own wallet. limit defaults to 20 and is capped at 100.
Paging
The feed is a backwards cursor, not a page number. Pass the createdAt of
the last row you received as dateFrom to get the next page:
Code
dateFrom is exclusive and means indexed before this instant, so you cannot
skip or duplicate a row by paging while new ones arrive. An empty array is the
end of the history.
Not a chain backfill
This feed contains transfers Restake indexed, which starts when the wallet was registered - on the first read of either wallet endpoint. Activity from before that is not backfilled here. What it is reflected in is the balances, which are read from the chain in full on every refresh.
Value over time
Code
Code
Newest first, defaulting to the last 7 days; limit defaults to 100 and is
capped at 1000.
These points are written by the valuation job, not by your request - which is
why this endpoint does not sync anything, and why the series is sparse. Treat
the cadence as an implementation detail: plot the points you get by their
createdAt rather than assuming a fixed interval between them.
Open staking positions
Code
Code
One entry per pool the user is currently staked into. poolSlug is the same
handle the staking endpoints take, so a position joins straight back
onto the pool it came from.
openUsdValue is the cost basis - what the deposits were worth when they were
made, time-weighted across top-ups, which is also why openedAt moves forward
when a user adds to a position. currentUsdValue is the value at the last
valuation, and the difference between them is unrealizedProfitUsd.
`amount` is in share units
A position is denominated in the pool's wrap token - the shares the deposit
minted - not in the asset the user deposited. That is exactly the unit
…/construct/unstake/transaction/batch takes, so unstaking a whole position is
passing this amount straight through. Converting it back to the base token
yourself is the mistake the
units trap describes.
Everything here is priced as of updatedAt, not as of your request. A stake
that landed a minute ago has a position row, but its valuation catches up on the
next run of the valuation job.
Stock positions
Code
Code
A separate endpoint with a different shape, not a filter on the one above.
An equity has no APY forecast and no pool behind it; what it has is a share
count, an average entry price and a market that is open or shut. stock.slug is
the handle the stocks endpoints take.
Unlike staking positions, these are priced when you ask rather than at the
last valuation, so currentPriceUsd and the PnL beside it are current to within
a minute.
openUsdValue is average-cost: a partial sell reduces it proportionally, so
selling half a holding leaves avgEntryPriceUsd exactly where it was. A buy
moves openedAt forward, weighted by USD, so "held for" and the cost basis
describe the same money. unrealizedProfitPercent is a simple return, never
annualized.
`amount` is in shares
It is the unit …/stocks/construct/sell/transaction/batch takes, so selling a
whole holding is passing this amount straight through - see the
units trap.
Two fields are the difference between a stale screen and a shut market.
marketOpen: false means the PnL cannot have moved since nextOpenAt's previous
session, and currentPriceUsd falls back to the last price the position was
valued at if the price feed cannot be reached - so a number here is never
missing, but it is not always live.
Failure modes
| Status | Cause |
|---|---|
400 | userId missing or not a uuid, or limit above its cap |
403 | Your token lacks portfolio:read (or transactions:read for the transfer feed) |
404 | No such user, or the user belongs to another partner |
A 200 with sync.assets: "failed" is not in this table on purpose - it is a
successful read of stale data, not an error, and it is the one case where the
status code alone does not tell you what you got.
