# 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](/authentication#wildcard-scopes).

| 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:

```json
{
  "data": {
    "sync": { "assets": "updated", "transactions": "current" },
    "walletAddress": "0x1234567890abcdef1234567890abcdef12345678",
    "assets": [ ]
  }
}
```

| 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 |

<Callout type="caution" title="`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.
</Callout>

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

```bash
curl "https://stg-s2s.restake.gg/api/v1/s2s/portfolio/wallet/assets?userId=$USER_ID" \
  -H "authorization: Bearer $ACCESS_TOKEN"
```

```json title="Response"
{
  "data": {
    "sync": { "assets": "updated", "transactions": "updated" },
    "walletAddress": "0x1234567890abcdef1234567890abcdef12345678",
    "assets": [
      {
        "assetId": "3f0a…",
        "amount": 1250.42,
        "token": {
          "symbol": "USDC",
          "chain": "BASE",
          "contractAddress": "0x833589fcd6edb6e08f4c7c32d4f71b54bda02913",
          "decimals": 6,
          "verified": true
        }
      }
    ]
  }
}
```

`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

```bash
curl "https://stg-s2s.restake.gg/api/v1/s2s/portfolio/wallet/transactions?userId=$USER_ID&limit=50" \
  -H "authorization: Bearer $ACCESS_TOKEN"
```

```json title="Response"
{
  "data": {
    "sync": { "assets": "current", "transactions": "current" },
    "transactions": [
      {
        "txId": "9c41…",
        "txHash": "0xabc…",
        "blockNumber": 24817392,
        "direction": "in",
        "amount": 250.5,
        "fromAddress": "0x…",
        "toAddress": "0x1234567890abcdef1234567890abcdef12345678",
        "category": "token",
        "token": { "symbol": "USDC", "chain": "BASE", "contractAddress": "0x…", "decimals": 6, "verified": true },
        "createdAt": "2026-08-11T09:14:03.221Z"
      }
    ]
  }
}
```

`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:

```bash
curl "https://stg-s2s.restake.gg/api/v1/s2s/portfolio/wallet/transactions?userId=$USER_ID&dateFrom=2026-08-11T09:14:03.221Z" \
  -H "authorization: Bearer $ACCESS_TOKEN"
```

`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.

<Callout type="info" title="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.
</Callout>

## Value over time

```bash
curl "https://stg-s2s.restake.gg/api/v1/s2s/portfolio/wallet/snapshot/history?userId=$USER_ID&dateFrom=2026-07-01" \
  -H "authorization: Bearer $ACCESS_TOKEN"
```

```json title="Response"
{
  "data": [
    {
      "totalValueUsd": 3241.87,
      "unrealizedPnlUsd": 41.87,
      "createdAt": "2026-08-11T09:00:00.000Z"
    }
  ]
}
```

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

```bash
curl "https://stg-s2s.restake.gg/api/v1/s2s/portfolio/positions/open/staking/list?userId=$USER_ID" \
  -H "authorization: Bearer $ACCESS_TOKEN"
```

```json title="Response"
{
  "data": [
    {
      "positionId": "b41c…",
      "poolSlug": "aave-usdc-base",
      "poolName": "Aave USDC",
      "project": "Aave",
      "chain": "BASE",
      "poolType": "A_TOKEN",
      "status": "open",
      "amount": 1000.0,
      "token": { "symbol": "aBasUSDC", "chain": "BASE", "contractAddress": "0x…", "decimals": 6, "verified": true },
      "openUsdValue": 1000.0,
      "currentUsdValue": 1041.87,
      "unrealizedProfitUsd": 41.87,
      "forecastApyUsd": 52.4,
      "apyPercent": 5.03,
      "openedAt": "2026-06-02T11:20:00.000Z",
      "updatedAt": "2026-08-11T09:00:00.000Z"
    }
  ]
}
```

One entry per pool the user is currently staked into. `poolSlug` is the same
handle the [staking endpoints](/staking) 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`.

<Callout type="caution" title="`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](/staking#unstaking-and-the-units-trap) describes.
</Callout>

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

```bash
curl "https://stg-s2s.restake.gg/api/v1/s2s/portfolio/positions/open/stocks/list?userId=$USER_ID" \
  -H "authorization: Bearer $ACCESS_TOKEN"
```

```json title="Response"
{
  "data": [
    {
      "positionId": "9d3f…",
      "stock": {
        "slug": "apple",
        "symbol": "AAPLon",
        "name": "Apple Inc.",
        "logo": "https://…/aapl.png",
        "chain": "BSC",
        "category": "technology"
      },
      "status": "open",
      "amount": 2.5,
      "openUsdValue": 550.0,
      "avgEntryPriceUsd": 220.0,
      "currentPriceUsd": 227.31,
      "currentUsdValue": 568.28,
      "unrealizedProfitUsd": 18.28,
      "unrealizedProfitPercent": 3.32,
      "marketOpen": false,
      "nextOpenAt": "2026-09-08T00:05:00.000Z",
      "openedAt": "2026-06-02T11:20:00.000Z",
      "updatedAt": "2026-08-11T09:00:00.000Z"
    }
  ]
}
```

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](/stocks) 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.

<Callout type="caution" title="`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](/stocks#the-units-trap).
</Callout>

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.
