> For the complete documentation index, see [llms.txt](https://docs.mysticfinance.xyz/llms.txt). Markdown versions of documentation pages are available by appending `.md` to page URLs; this page is available as [Markdown](https://docs.mysticfinance.xyz/api/mystic-morpho-rest-api.md).

# Mystic Morpho REST API

### Introduction

The Mystic Morpho API lets you read all kinds of Morpho data in real-time on the chains Morpho is on. Track your positions, liquidate, manage risk or build your own Morpho-powered app using Mystic's Morpho API. Note that to use it, you will need your own API Key. Please contact us to get an API Key and get started -> [**Contact us**](mailto:joao.moreira@mysticfinance.xyz)**.**

### Base URL

{% tabs %}
{% tab title="Production" %}

```
https://api.mysticfinance.xyz
```

{% endtab %}

{% tab title="Staging" %}

```
https://staging-api.mysticfinance.xyz
```

{% endtab %}
{% endtabs %}

### OpenAPI Specification

The full machine-readable API spec available for AI agents, code generators, and API clients:

| Resource     | Url                                                                                       |
| ------------ | ----------------------------------------------------------------------------------------- |
| Swagger UI   | <https://api.mysticfinance.xyz/docs>                                                      |
| OpenAPI JSON | [https://api.mysticfinance.xyz/docs-json](<https://api.mysticfinance.xyz/docs-json&#xA;>) |

### Contents

* Why Mystic
* Authentication
* Quickstart
* API reference
  * General
  * Vaults
  * Borrows
  * Portfolio
  * Transactions
  * Historical
  * Realtime
  * Liquidations
* Supported protocols and chains
* Errors

### Why Mystic

Use one API for DeFi earn and borrow data, risk context, eligibility screening, portfolio tracking, position history, and live on-chain state.

Mystic is the API layer for onchain earn and borrow features. Use one integration to discover vaults, compare risk and return, screen out markets that should never reach a user, size a borrow, track positions and returns after a deposit, and see what happened when capital move  across 1.5K+ vaults, 5.5k+ borrow markets, and 6+ chains.

Use Mystic when your product needs to do more than show APYs, to decide which vaults and markets are eligible, explain why one beats another, tell a user what they hold and what it has earned, and report what happened after capital moved, including the liquidations.&#x20;

### Authentication

To call the API, you need to an API Key. Contact the Mystic team to get one and add it to the header of the api call like below. API Key usage is billed monthly and the Key will expire if payment fails. An expired key returns `401`.&#x20;

```
curl "https://api.mysticfinance.xyz/morphoCache/lite?chainId=1" \
  -H "x-api-key: YOUR_API_KEY"
```

\
**Rate limit:** 20 - 100 requests/second on data endpoints depending on your API plan. When you are over the limit, it returns `429`

[**Contact us to get your API Key**](mailto:joao.moreira@mysticfinance.xyz)<br>

### Quickstart

Read a vault and a user's position in it, in 5 steps:

1. Fetch the list of vaults
2. Fetch a vault's details
3. Fetch a vault's allocations
4. Check a user's position in that vault
5. Check the whole portfolio

Read a borrow market and a user's position in it, in 6 steps:

1. Fetch the list of borrow markets
2. Fetch a market's details
3. Fetch a market's allocations
4. Check a user's borrow positions
5. Narrow to one market
6. Check the whole portfolio

**Two conventions before you start:**

**Rates are decimal fractions, not percentages.** `0.0752` means 7.52%. This holds for every APY, LLTV, utilization, fee and liquidation penalty in every request and response, `minApy=0.05` filters at 5%, not 5 basis points.

**Amounts are decimal strings, not numbers.** `"12403118.44"`, already scaled by the token's decimals.&#x20;

All examples use JavaScript with axios for HTTP.

```js
import axios from 'axios';

const mystic = axios.create({
  baseURL: 'https://api.mysticfinance.xyz/v1/yields',
  headers: { 'x-api-key': process.env.MYSTIC_API_KEY },
});

/** "plume:0x0b14…" → ["plume", "0x0b14…"] */
const splitId = (id) => id.split(':');
```

#### Vaults

**1. Fetch the list of vaults**

Filter and rank across every supported network in one call. This is the endpoint a "where should I deposit?" screen is built on:

```js
async function listVaults(params) {
  const { data } = await mystic.get('/vaults', { params });
  return data;
}

const page = await listVaults({
  assetGroup: 'usd',
  minTvl: 1_000_000,
  minApy: 0.05,          // 5%, not 5%%
  sortBy: 'apy',
  perPage: 10,
});
```

Example response (`GET /vaults?assetGroup=usd&minTvl=1000000&minApy=0.05&sortBy=apy&perPage=10`):

```jsonc
{
  "data": [{
    "vaultId": "plume:0x0b14d0bdaf647c541d3887c5b1a4bd64068fcda7",
    "address": "0x0b14d0bdaf647c541d3887c5b1a4bd64068fcda7",
    "name": "Mystic MEV Capital pUSD",
    "network":  { "name": "Plume", "slug": "plume", "chainId": 98866, "networkCaip": "eip155:98866" },
    "protocol": { "name": "morpho", "displayName": "Morpho", "product": "vault", "version": "v1.1" },
    "asset": { "address": "0xdddd…", "symbol": "pUSD", "decimals": 6, "priceUsd": "1.0002", "assetGroup": "usd" },
    "apy": {
      "instantaneous": { "base": 0.0642, "reward": 0.0110, "total": 0.0752 },
      "1day":  { "base": 0.0612, "reward": 0.0140, "total": 0.0752 },
      "7day":  { "base": 0.0591, "reward": 0.0140, "total": 0.0731 },
      "30day": { "base": 0.0575, "reward": 0.0138, "total": 0.0713 }
    },
    "tvl":       { "native": "12397600.0", "usd": "12400079.5" },
    "liquidity": { "native": "3120004.1",  "usd": "3120628.1" },
    "curator": { "name": "MEV", "logoUri": "https://…" },
    "tags": ["stablecoin", "blue-chip", "incentivised"],
    "score": { "vaultScore": 78 },
    "holderCount": 137,
    "lastUpdateTimestamp": 1753747200
  }],
  "pagination": { "page": 1, "perPage": 10, "total": 34, "totalPages": 4, "hasNextPage": true },
  "errors": { "unsupportedNetworks": [], "unsupportedAssets": [], "unsupportedProtocols": [] }
}
```

Read `apy.instantaneous.reward` against `apy["30day"].reward` before you render a headline rate.&#x20;

**2. Fetch a vault's details**

Split the `vaultId` and read the vault in full:

```js
async function getVault(vaultId) {
  const [network, address] = splitId(vaultId);
  const { data } = await mystic.get(`/vaults/${network}/${address}`);
  return data;
}
```

The detail response is the same object as the list plus `allocations`, `governance`, `collaterals`, `capacity`, `protocolSpecific` and the full score breakdown, see the vault object for every field.

```jsonc
{
  "vaultId": "plume:0x0b14…",
  "name": "Mystic MEV Capital pUSD",
  "fees": { "performanceFee": 0.1, "managementFee": null, "withdrawalFee": null, "depositFee": null },
  "capacity": { "maxCapacity": "50000000.0", "remainingCapacity": "37602400.0" },
  "sharePrice": { "value": "1.041233", "asset": "pUSD" },
  "curators": [{ "name": "MEV" }, { "name": "Cicada" }],
  "governance": { "owner": "0x…", "curator": "0x…", "guardian": null, "timelockSeconds": 86400 },
  "score": { "vaultScore": 78, "vaultMaturityScore": 90, "allocationQualityScore": 72, "totalScorePenalty": 0 }
  // … plus everything from the list row
}
```

A missing vault is a `404`, never a `200` with a null body, so a failed lookup throws `404` in axios.&#x20;

**3. Fetch a vault's allocations**

Where the deposits actually go, the answer to "what risk am I taking?":

```js
async function getAllocations(vaultId) {
  const [network, address] = splitId(vaultId);
  const { data } = await mystic.get(`/vaults/${network}/${address}/allocations`);
  return data;
}
```

Example response, largest share first:

```jsonc
{
  "vaultId": "plume:0x0b14…",
  "asset": { "symbol": "pUSD", "address": "0xdddd…", "priceUsd": "1.0002", "assetGroup": "usd" },
  "data": [
    { "kind": "market", "share": 0.62, "amount": { "native": "7686512.0", "usd": "7688049.3" },
      "market": { "marketId": "plume:0x7a96549c…", "collateralAsset": { "symbol": "nALPHA" }, "lltv": 0.86,
                  "utilization": 0.8794, "apy": { "supply": { "total": 0.0373 }, "borrow": { "total": 0.0426 } } },
      "position": { "supplyShares": "7301220.4", "borrowShares": null, "collateral": null }, "vault": null },
    { "kind": "vault",  "share": 0.28, "amount": { "native": "3471328.0", "usd": "3472022.2" },
      "vault": { "vaultId": "plume:0xc0df…", "name": "Re7 RWA Yield", "version": "v1.1", "asset": "pUSD" } },
    { "kind": "idle",   "share": 0.10, "amount": { "native": "1239760.0", "usd": "1240008.0" } }
  ],
  "lastUpdateTimestamp": 1753747200
}
```

**4. Check a user's position in that vault**

```js
async function getPosition(address, vaultId) {
  const [network, vault] = splitId(vaultId);
  const { data } = await mystic.get(`/portfolio/positions/${address}/${network}/${vault}`);
  return data;
}
```

Example response:

```jsonc
{
  "vaultId": "plume:0x0b14…",
  "name": "Mystic MEV Capital pUSD",
  "network": { "slug": "plume", "chainId": 98866 },
  "asset": { "symbol": "pUSD", "decimals": 6, "priceUsd": "1.0002" },
  "balance": { "shares": "119843.22", "native": "124776.5", "usd": "124801.4" },
  "apy": { "base": 0.0612, "reward": 0.0140, "total": 0.0752 },
  "projectedUsdAnnualEarnings": "9384.9"
}
```

**5. Check the whole portfolio**

One call for every total across every indexed network:

```js
async function getSummary(address) {
  const { data } = await mystic.get(`/portfolio/summary/${address}`);
  return data;
}

async function getPositions(address) {
  const { data } = await mystic.get(`/portfolio/positions/${address}`);
  return data;   // list envelope of the position objects from step 4
}
```

Example response (`GET /portfolio/summary/0xd8dA…6045`):

```jsonc
{
  "address": "0xd8da6bf26964af9d7eed9e03e53415d37aa96045",
  "totals": { "suppliedUsd": "124000.5", "collateralUsd": "50000", "debtUsd": "20000", "netWorthUsd": "154000.5" },
  "weightedApy": 0.0641,
  "projectedUsdAnnualEarnings": "7948.4",
  "positionCount": 4,
  "borrowPositionCount": 1,
  "lowestHealthFactor": 2.15,
  "networks": ["plume", "flare"],
  "errors": { "unsupportedNetworks": [], "unsupportedAssets": [], "unsupportedProtocols": [] }
}
```

**Putting the vault flow together**

```js
async function vaultFlow(user) {
  // 1. discover
  const { data: vaults } = await mystic.get('/vaults', {
    params: { assetGroup: 'usd', minTvl: 1_000_000, sortBy: 'apy', perPage: 10 },
  });
  const best = vaults[0];                       // already sorted; [] means nothing matched

  // 2 + 3. detail and where it deploys
  const [network, address] = splitId(best.vaultId);
  const [vault, allocations] = await Promise.all([
    mystic.get(`/vaults/${network}/${address}`).then((r) => r.data),
    mystic.get(`/vaults/${network}/${address}/allocations`).then((r) => r.data),
  ]);
  const idle = allocations.data.find((a) => a.kind === 'idle')?.share ?? 0;

  // 4 + 5. the user's side
  const [position, summary] = await Promise.all([
    mystic.get(`/portfolio/positions/${user}/${network}/${address}`)
      .then((r) => r.data)
      .catch((e) => (e.response?.status === 404 ? null : Promise.reject(e))),
    mystic.get(`/portfolio/summary/${user}`).then((r) => r.data),
  ]);

  return { vault, idle, position, summary };
}
```

#### Borrows

**1. Fetch the list of borrow markets**

```js
async function listMarkets(params) {
  const { data } = await mystic.get('/borrow/markets', { params });
  return data;
}

const page = await listMarkets({
  allowedBorrowAssets: 'pUSD',   // what the user wants to borrow
  hasLiquidity: true,
  sortBy: 'lltv',
  sortOrder: 'asc',              // safest first
});
```

Example response:

```jsonc
{
  "data": [{
    "marketId": "plume:0x7a96549cae736c913d12c78ee4c155c2d2f874031fce5acdd07bdbf23d7644c7",
    "marketKey": "0x7a96549cae736c913d12c78ee4c155c2d2f874031fce5acdd07bdbf23d7644c7",
    "network": { "name": "Plume", "slug": "plume", "chainId": 98866, "networkCaip": "eip155:98866" },
    "loanAsset":       { "symbol": "pUSD",   "address": "0xdddd…", "priceUsd": "1.0002", "assetGroup": "usd" },
    "collateralAsset": { "symbol": "nALPHA", "address": "0x593c…", "priceUsd": "1.1059", "assetGroup": "rwa" },
    "lltv": 0.86,
    "liquidationThreshold": 0.86,
    "liquidationPenalty": 0.0438,
    "apy": {
      "supply": { "base": 0.0373, "reward": 0, "total": 0.0373 },
      "borrow": { "base": 0.0426, "reward": 0, "total": 0.0426 }
    },
    "supply": { "assets": "4261152.37", "shares": "4048331.17", "usd": "4262004.6" },
    "borrow": { "assets": "3747270.67", "shares": "3535370.85", "usd": "3748020.1" },
    "liquidity": { "native": "513881.7", "usd": "513984.5" },
    "utilization": 0.8794,
    "isIdle": false,
    "score": { "marketScore": 62 }
  }],
  "pagination": { "page": 1, "perPage": 50, "total": 12, "totalPages": 1, "hasNextPage": false },
  "errors": { "unsupportedNetworks": [], "unsupportedAssets": [], "unsupportedProtocols": [] }
}
```

Bad-debt and unverified markets are excluded by default.  Pass `includeBadDebt=true` / `includeUnverified=true` for the unfiltered set; both are also reported in `tags` and `flags` when present.

`supplyingVaults` is omitted from list rows to keep them small. Add `?expand=supplyingVaults` when you need it in a list, an unrecognised `expand` value is a `400`, not a silently slim response.

**2. Fetch a market's details**

```js
async function getMarket(marketId) {
  const [network, key] = splitId(marketId);
  const { data } = await mystic.get(`/borrow/markets/${network}/${key}`);
  return data;
}
```

The detail response adds `reallocatableLiquidity`, `capacity`, `oracle`, `irm`, `fee`, `performanceFee`, `vaultCaps` and the fully-expanded `supplyingVaults,` see the market object.

```jsonc
{
  "marketId": "plume:0x7a96549c…",
  "liquidity":              { "native": "513881.7", "usd": "513984.5" },
  "reallocatableLiquidity": { "native": "109354.5", "usd": "109376.3" },
  "capacity": { "supplyCap": "10000000.0", "totalSupplyCaps": "11000000.0" },
  "oracle": { "address": "0x7824…", "baseFeed": null },
  "irm":    { "address": "0x7420…" },
  "fee": 0,
  "performanceFee": 0.1,
  "lastAccrualTimestamp": 1785516494
}
```

`liquidity` is everything reachable; `reallocatableLiquidity` is the slice that only arrives if a public allocator acts. Borrowing against the first number and assuming the second is instant is how a large draw fails at signing time.

**3. Fetch a market's allocations**

Which vaults back the market, and how much each has in it, depth is only as real as the vaults supplying it:

```js
async function getMarketAllocations(marketId) {
  const [network, key] = splitId(marketId);
  const { data } = await mystic.get(`/borrow/markets/${network}/${key}/allocations`);
  return data;
}
```

```jsonc
{
  "marketId": "plume:0x7a96549c…",
  "data": [{
    "vaultId": "plume:0xc0df…",
    "name": "Re7 RWA Yield",
    "symbol": "Re7pUSD",
    "curators": [{ "name": "Re7", "address": "0x…" }],
    "supplyShare": 0.9999,
    "supplied": { "native": "4048324.07", "usd": "4049133.7" },
    "cap": "10000000.0",
    "allocated": "3906454.7",
    "isActive": true,
    "allocatorFee": 0
  }],
  "lastUpdateTimestamp": 1785516534
}
```

**4. Check a user's borrow positions**

```js
async function getBorrowPositions(address, params) {
  const { data } = await mystic.get(`/portfolio/borrow-positions/${address}`, { params });
  return data;
}
```

Example response:

```jsonc
{
  "data": [{
    "marketId": "plume:0x7a96549c…",
    "network": { "slug": "plume", "chainId": 98866 },
    "collateralAsset": { "symbol": "nALPHA", "address": "0x593c…", "decimals": 6, "priceUsd": "1.1059" },
    "debtAsset":       { "symbol": "pUSD",   "address": "0xdddd…", "decimals": 6, "priceUsd": "1.0002" },
    "collateral": { "native": "10000.0", "usd": "11059.9" },
    "debt":       { "native": "6000.0",  "usd": "6001.2" },
    "lltv": 0.86,
    "ltv": 0.5426,
    "healthFactor": 1.585
  }],
  "pagination": { "page": 1, "perPage": 50, "total": 1, "totalPages": 1, "hasNextPage": false },
  "errors": { "unsupportedNetworks": [], "unsupportedAssets": [], "unsupportedProtocols": [] }
}
```

`healthFactor` is collateral × liquidation threshold ÷ debt, and is `null` when there is no debt, a position that cannot be liquidated has no health factor. Do not treat `null` as zero: it is the safest possible state, not the most dangerous one.

**5. Narrow to one market**

There is no per-market position route, borrow positions come back for the whole wallet, so filter the response by `marketId`:

```js
async function getMarketPosition(address, marketId) {
  const page = await getBorrowPositions(address);
  return page.data.find((p) => p.marketId === marketId) ?? null;
}
```

Scope the fan-out with `allowedNetworks` when you already know the chain — it is one indexer round-trip per network, so naming the one you need is faster and no more expensive:

```js
const page = await getBorrowPositions(user, { allowedNetworks: 'plume' });
```

To see how a position got where it is, every borrow, repayment and liquidation against it — read `/portfolio/events/{address}` with `eventType=borrow,repay,liquidation`.

**6. Check the whole portfolio**

The same two calls as the vault flow. For a borrower, three fields carry the answer:

```js
const summary = await getSummary(user);

summary.totals.debtUsd;        // "20000"
summary.lowestHealthFactor;    // 2.15 — the riskiest position across every market
summary.borrowPositionCount;   // 1
```

`lowestHealthFactor` is what an alerting system should watch. It is `null` only when the wallet has no debt anywhere.

**Putting the borrow flow together**

```js
async function borrowFlow(user, wants = 'pUSD') {
  // 1. find markets that will lend the asset, safest first
  const { data: markets } = await mystic.get('/borrow/markets', {
    params: { allowedBorrowAssets: wants, hasLiquidity: true, sortBy: 'lltv', sortOrder: 'asc' },
  });

  // 2 + 3. detail, and who is actually backing it
  const [network, key] = splitId(markets[0].marketId);
  const [market, allocations] = await Promise.all([
    mystic.get(`/borrow/markets/${network}/${key}`).then((r) => r.data),
    mystic.get(`/borrow/markets/${network}/${key}/allocations`).then((r) => r.data),
  ]);

  // what can actually be drawn right now, vs. what needs an allocator to act
  const now = Number(market.liquidity.native) - Number(market.reallocatableLiquidity?.native ?? 0);
  const concentrated = allocations.data.some((v) => (v.supplyShare ?? 0) > 0.9);

  // 4 + 5 + 6. the user's side
  const positions = await mystic
    .get(`/portfolio/borrow-positions/${user}`, { params: { allowedNetworks: network } })
    .then((r) => r.data);
  const here = positions.data.find((p) => p.marketId === market.marketId) ?? null;
  const summary = await mystic.get(`/portfolio/summary/${user}`).then((r) => r.data);

  return { market, availableNow: now, concentrated, position: here, summary };
}
```

### API reference

Every route below is prefixed with `https://api.mysticfinance.xyz/v1/yields` and all are `GET` endpoints.

#### The list envelope

Every list response, without exception:

```jsonc
{
  "data": [ /* … */ ],
  "pagination": { "page": 1, "perPage": 50, "total": 412, "totalPages": 9, "hasNextPage": true },
  "errors": { "unsupportedNetworks": [], "unsupportedAssets": [], "unsupportedProtocols": [] }
}
```

`errors` is a **degradation, not a failure**. Ask for `allowedNetworks=plume,atlantis` and you get every Plume vault plus `unsupportedNetworks: ["atlantis"]` , rather than a `400` that discards the results you could have had.

#### Shared query parameters

Every list endpoint accepts these. Endpoint sections document only what they add.

| Field                | Type    | Required | Description                                                                                             |
| -------------------- | ------- | -------- | ------------------------------------------------------------------------------------------------------- |
| `page`               | integer | –        | Default `1`.                                                                                            |
| `perPage`            | integer | –        | Default `50`, max `500`.                                                                                |
| `allowedNetworks`    | string  | –        | Slugs or chain ids: `plume,base` or `98866,8453`. Unknown entries land in `errors.unsupportedNetworks`. |
| `disallowedNetworks` | string  | –        | Same format, excluded instead.                                                                          |
| `chainId`            | integer | –        | Single-network alias for callers migrating from `/morphoCache`.                                         |
| `search`             | string  | –        | Free-text match on name, symbol or address.                                                             |
| `sortOrder`          | enum    | –        | `asc` \| `desc`. Default `desc`.                                                                        |

`/v1` is required on every route below.

### General

| Route                         | Credits | Description                                 |
| ----------------------------- | ------- | ------------------------------------------- |
| `/health`                     | 0       | Per-network cache age                       |
| `/networks`                   | 0       | Supported networks with vault/market counts |
| `/protocols`                  | 0       | Protocols and their declared capabilities   |
| `/tags`                       | 0       | Tag vocabulary with live counts             |
| `/curators`                   | 2       | Curators with vault counts and TVL          |
| `/assets`                     | 2       | Every asset used by a vault or market       |
| `/assets/{network}/{address}` | 2       | One asset, with where it is used            |

#### `GET /v1/yields/health`

Freshness per chain, so you can decide what staleness you tolerate rather than trusting a bare `200`. Exempt from rate limiting.

**Request** — no parameters.

**Response**

```jsonc
{
  "status": "ok",
  "networks": [
    { "slug": "ethereum", "chainId": 1, "vaultsAgeSeconds": 84, "marketsAgeSeconds": 120 },
    { "slug": "plume", "chainId": 98866, "vaultsAgeSeconds": 41, "marketsAgeSeconds": 41 }
  ],
  "protocols": ["morpho"]
}
```

`null` for an age means that cache has never been written for that chain.

#### `GET /v1/yields/networks`

**Request** — no parameters.

**Response**

```jsonc
{ "data": [{
  "name": "Plume", "slug": "plume", "chainId": 98866,
  "networkCaip": "eip155:98866",
  "explorerUrl": "https://explorer.plume.org",
  "vaultCount": 13, "marketCount": 43,
  "isSupported": true
}] }
```

A supported network with zero counts is a real state (a chain whose cron has not populated yet), reported rather than hidden.

#### `GET /v1/yields/protocols`

Capabilities are **advertised, not implied** — you can see that a protocol cannot answer share-price history instead of discovering it as an empty series.

**Request:** no parameters.

**Response**

```jsonc
{ "data": [{
  "name": "morpho", "displayName": "Morpho", "product": "vault", "version": "v1.1",
  "protocolUrl": "https://morpho.org", "logoUri": "https://…",
  "capabilities": {
    "vaults": true, "borrowMarkets": true, "events": true,
    "sharePriceHistory": true, "holders": true, "positions": true
  },
  "supportedNetworks": ["ethereum", "base", "plume", "flare", "citrea", "berachain"]
}] }
```

#### `GET /v1/yields/tags`

Counts come from the same derivation the vault list uses, so `?tags=stablecoin` returns the number shown here.

**Response**

```jsonc
{ "data": [
  { "tag": "stablecoin", "description": "Denominated in a US-dollar stablecoin.", "count": 61 },
  { "tag": "blue-chip", "description": "Over $10M TVL.", "count": 12 },
  { "tag": "incentivised", "description": "Carries an active reward campaign on top of the base rate.", "count": 27 }
] }
```

Full vocabulary: `stablecoin`, `eth`, `btc`, `rwa`, `v2`, `incentivised`, `blue-chip`, `bad-debt`, `unverified`.

#### `GET /v1/yields/curators`

**Request** — shared parameters only.

**Response** — list envelope, highest TVL first.

```jsonc
{ "data": [{
  "name": "Re7", "vaultCount": 4, "tvlUsd": "48210553.2", "networks": ["plume", "base"]
}] }
```

Curators named `Unknown` are omitted rather than aggregated into a fake entity.

#### `GET /v1/yields/assets`

Every asset that appears in a vault or market we index — derived, not curated. An asset outside that set has nowhere to be deposited, so listing it would be noise.

**Request**

| Field        | Type | Required | Description                                 |
| ------------ | ---- | -------- | ------------------------------------------- |
| `assetGroup` | csv  | –        | `usd` \| `eth` \| `btc` \| `rwa` \| `other` |
|              |      |          | Plus the shared parameters.                 |

**Response** — list envelope, most-used first.

```jsonc
{ "data": [{
  "address": "0xdddd73f5df1f0dc31373357beac77545dc5a6f3f",
  "symbol": "pUSD", "priceUsd": "1.0002", "assetGroup": "usd",
  "assetCaip": "eip155:98866/erc20:0xdddd…",
  "network": { "name": "Plume", "slug": "plume", "chainId": 98866, "networkCaip": "eip155:98866" },
  "vaultCount": 6, "marketCount": 21
}] }
```

The same symbol on two chains is two entries, different tokens, different prices.

#### `GET /v1/yields/assets/{network}/{address}`

**Request**

| Field     | Type | Required | Description             |
| --------- | ---- | -------- | ----------------------- |
| `network` | path | ✅        | Network slug.           |
| `address` | path | ✅        | Asset contract address. |

**Response:** one asset object as above. `404` when the asset is not used by anything we index.

***

### Vaults

| Route                                     | Credits | Description                               |
| ----------------------------------------- | ------- | ----------------------------------------- |
| `/vaults`                                 | 1       | List vaults — the main discovery endpoint |
| `/vaults/{network}/{vaultId}`             | 1       | One vault, in full                        |
| `/vaults/{network}/{vaultId}/apy`         | 1       | APY breakdown alone — a cheap poll        |
| `/vaults/{network}/{vaultId}/tvl`         | 1       | TVL and liquidity alone                   |
| `/vaults/{network}/{vaultId}/allocations` | 1       | Where the vault deploys its assets        |
| `/vaults/{network}/{vaultId}/campaigns`   | 2       | Reward campaigns: token, budget, end date |
| `/vaults/{network}/{vaultId}/holders`     | 2       | Holder count and largest positions        |

#### `GET /v1/yields/vaults`

Defaults to **all** networks, an omitted network filter is not silently treated as Plume.

**Request**

| Field                                      | Type   | Required | Description                                                                                                    |
| ------------------------------------------ | ------ | -------- | -------------------------------------------------------------------------------------------------------------- |
| `sortBy`                                   | enum   | –        | `tvl` (default), `apy`, `liquidity`, `name`, `score`, `createdAt`, `holders`                                   |
| `view`                                     | enum   | –        | `full` (default) \| `slim` — see below                                                                         |
| `expand`                                   | string | –        | `score`. Adds the component breakdown a list otherwise omits. An unrecognised value is a `400`.                |
| `allowedAssets` / `disallowedAssets`       | string | –        | Vault assets, by symbol or address                                                                             |
| `assetGroup`                               | string | –        | `usd` \| `eth` \| `btc` \| `rwa` \| `other`                                                                    |
| `allowedProtocols` / `disallowedProtocols` | string | –        | See `/protocols`                                                                                               |
| `curators`                                 | string | –        | Names or addresses                                                                                             |
| `tags`                                     | string | –        | See `/tags`                                                                                                    |
| `minTvl` / `maxTvl`                        | number | –        | USD                                                                                                            |
| `minApy` / `maxApy`                        | number | –        | **Fractions.** `0.05` is 5%.                                                                                   |
| `apyInterval`                              | enum   | –        | `instantaneous`, `1hour`, `1day` (default), `7day`, `30day` — which window `minApy`/`maxApy`/`sortBy=apy` read |
| `minLiquidity`                             | number | –        | USD                                                                                                            |
| `minVaultScore`                            | number | –        | 0–100. Vaults with no computed score are excluded — an unscored vault cannot satisfy a floor.                  |
| `version`                                  | enum   | –        | `v1` \| `v2` \| `all` (default)                                                                                |
|                                            |        |          | Plus the shared parameters.                                                                                    |

**Response:** list envelope of vault objects.

`view=slim` returns identity, asset, APY, TVL and tags only — same vaults, same order, same filters, smaller payload:

```jsonc
{
  "vaultId": "plume:0x0b14…", "address": "0x0b14…", "name": "Mystic MEV Capital pUSD",
  "network": { "slug": "plume", "chainId": 98866 },
  "protocol": { "name": "morpho", "version": "v1.1" },
  "asset": { "address": "0xdddd…", "symbol": "pUSD", "assetGroup": "usd" },
  "apy": { /* … */ }, "tvl": { "native": "…", "usd": "…" }, "tags": ["stablecoin"]
}
```

It is a parameter rather than a second route so filtering and sorting cannot diverge between the two.

#### `GET /v1/yields/vaults/{network}/{vaultId}`

**Request**

| Field     | Type | Required | Description                 |
| --------- | ---- | -------- | --------------------------- |
| `network` | path | ✅        | Network slug, e.g. `plume`. |
| `vaultId` | path | ✅        | Vault contract address.     |

**Response** — one vault object, including the detail-only fields. `404` when it does not exist.

**The vault object**

```jsonc
{
  "vaultId": "plume:0x0b14…",          // {networkSlug}:{address} — globally unique
  "address": "0x0b14…",
  "name": "Mystic pUSD Core",
  "network":  { "name": "Plume", "slug": "plume", "chainId": 98866, "networkCaip": "eip155:98866" },
  "protocol": { "name": "morpho", "displayName": "Morpho", "product": "vault", "version": "v1.1" },
  "asset": {
    "address": "0xdddd…", "symbol": "pUSD", "decimals": 6,
    "priceUsd": "1.0002",              // null when unpriceable — never 1
    "assetGroup": "usd",
    "assetCaip": "eip155:98866/erc20:0xdddd…",
    "logoUri": "https://…",            // mirrored from Morpho's registry; null if they have none
    "intrinsicApy": null               // the asset's OWN yield (LST, tokenised T-bill); additive
  },
  "lpToken": { "address": "0x0b14…", "symbol": "MEVPUSD", "decimals": 18 },
  "apy": {
    "instantaneous": { "base": 0.0642, "reward": 0.0110, "total": 0.0752 },
    "1hour": { "base": 0.0636, "reward": 0.0140, "total": 0.0776 },
    "1day":  { "base": 0.0612, "reward": 0.0140, "total": 0.0752 },
    "7day":  { "base": 0.0591, "reward": 0.0140, "total": 0.0731 },
    "30day": { "base": 0.0575, "reward": 0.0138, "total": 0.0713 }
  },
  "tvl":       { "native": "12397600.0", "usd": "12400079.5" },
  "liquidity": { "native": "3120004.1",  "usd": "3120628.1" },   // native is in ASSET units
  "sharePrice": { "value": "1.041233", "asset": "pUSD" },
  "fees": {
    "performanceFee": 0.1,             // fraction, so 0.1 = 10%
    "managementFee": null,             // V2 only — V1 has no such fee
    "withdrawalFee": null,             // Morpho has none, in either version
    "depositFee": null
  },
  "capacity": { "maxCapacity": "50000000.0", "remainingCapacity": "37602400.0" },
  "curator":  { "name": "Mystic", "logoUri": "https://…" },
  "curators": [{ "name": "Mystic" }, { "name": "Cicada" }],       // incl. co-curators
  "rewards":  [{ "asset": null, "apy": 0.014, "source": "merkl" }],
  "holderCount": 137,                  // at the last daily snapshot; null where we run no indexer
  "score": { "vaultScore": 78 },       // headline only in lists; breakdown on detail / ?expand=score
  "tags": ["stablecoin", "blue-chip", "incentivised"],
  "collaterals": [{ "symbol": "pETH", "address": "0x…", "lltv": 0.86, "oracle": "0x…" }],
  "flags": [], "warnings": [],
  "creationData": { "deploymentTimestamp": 1719878400 },
  "lastUpdateTimestamp": 1753747200,

  // ---- detail responses only ----
  "allocations": [
    { "kind": "market", "share": 0.62, "amount": { "native": "…", "usd": "…" },
      "market": { /* the COMPLETE market object — see below */ },
      "position": { "supplyShares": "…", "borrowShares": null, "collateral": null }, "vault": null },
    { "kind": "vault",  "share": 0.28, "amount": { /* … */ },
      "vault": { "vaultId": "plume:0x…", "address": "0x…", "name": "…", "version": "v1.1", "asset": "pUSD" } },
    { "kind": "idle",   "share": 0.10, "amount": { /* … */ } }
  ],
  "governance": {
    "owner": "0x…", "curator": "0x…", "guardian": null,
    "feeRecipient": "0x…", "skimRecipient": null, "timelockSeconds": 86400
  },
  "protocolSpecific": { "feeWrapper": "0x…", "vaultV2AdapterAddress": null }
}
```

`instantaneous` is the spot rate,a null APY window is meaningful. It means no data covers that period, a two-day-old vault genuinely has no 30-day APY. The spot rate is never copied in.

Three allocation kinds exists, the first is the idle for idle liquidity, the second is market for market allocation and the third is vault for vault allocation. The vault allocation is only available for v2 vaults.

#### `GET /v1/yields/vaults/{network}/{vaultId}/apy`

```jsonc
{
  "vaultId": "plume:0x0b14…",
  "apy": { /* the full interval block */ },
  "rewards": [{ "asset": null, "apy": 0.014, "source": "merkl" }],
  "lastUpdateTimestamp": 1753747200
}
```

#### `GET /v1/yields/vaults/{network}/{vaultId}/tvl`

```jsonc
{
  "vaultId": "plume:0x0b14…",
  "tvl": { "native": "12397600.0", "usd": "12400079.5" },
  "liquidity": { "native": "3120004.1", "usd": "3120628.1" },
  "lastUpdateTimestamp": 1753747200
}
```

#### `GET /v1/yields/vaults/{network}/{vaultId}/allocations`

Largest share first. The same array the detail response carries inline.

```jsonc
{
  "vaultId": "plume:0x0b14…",
  "asset": { "symbol": "pUSD", "address": "0xdddd…", "priceUsd": "1.0002", "assetGroup": "usd" },
  "data": [ /* allocation entries — see the vault object */ ],
  "lastUpdateTimestamp": 1753747200
}
```

#### `GET /v1/yields/vaults/{network}/{vaultId}/campaigns`

**Response**

```jsonc
{
  "target": "plume:0x0b14…",
  "source": "merkl",
  "available": true,               // false when Merkl returned nothing for the chain at all
  "data": [{
    "id": "0xabc…", "name": "pUSD Boost", "type": "ERC20", "status": "LIVE",
    "apy": 0.014,                  // Merkl publishes a percentage; normalised to a fraction here
    "dailyRewardsUsd": "412.55",
    "tvlUsd": "12400079.5",
    "rewardTokens": [{ "symbol": "PLUME", "address": "0xea23…", "endTimestamp": 1756339200 }],
    "startTimestamp": 1753747200,
    "endTimestamp": 1756339200
  }],
  "pagination": { /* … */ },
  "errors": { /* … */ }
}
```

#### `GET /v1/yields/vaults/{network}/{vaultId}/holders`

Requires an indexer. See coverage.

**Request** — `page`, `perPage` (default 50, max 500).

**Response**

```jsonc
{
  "data": [{ "address": "0x…", "shares": "412000.0", "assets": "429142.7" }],
  "pagination": { /* … */ },
  "totalCount": 137,
  "unavailableReason": null
}
```

On an unindexed network the list is empty, `totalCount` is `null`, the network is in `errors.unsupportedNetworks`, and `unavailableReason` says why — rather than an empty array that reads as "this vault has no holders".

***

### Borrows

| Route                                              | Credits | Description                          |
| -------------------------------------------------- | ------- | ------------------------------------ |
| `/borrow/markets`                                  | 1       | List markets                         |
| `/borrow/markets/{network}/{marketId}`             | 1       | One market                           |
| `/borrow/markets/{network}/{marketId}/allocations` | 1       | Which vaults supply it, and how much |

#### `GET /v1/yields/borrow/markets`

**Request**

| Field                                                    | Type    | Required | Description                                                                            |
| -------------------------------------------------------- | ------- | -------- | -------------------------------------------------------------------------------------- |
| `sortBy`                                                 | enum    | –        | `tvl` (default), `supplyApy`, `borrowApy`, `utilization`, `liquidity`, `lltv`, `score` |
| `expand`                                                 | string  | –        | `supplyingVaults`, `score`. Unrecognised values are a `400`.                           |
| `allowedCollateralAssets` / `disallowedCollateralAssets` | string  | –        | Symbol or address                                                                      |
| `allowedBorrowAssets` / `disallowedBorrowAssets`         | string  | –        | The loan asset, by symbol or address                                                   |
| `allowedProtocols` / `disallowedProtocols`               | string  | –        | See `/protocols`                                                                       |
| `tags`                                                   | string  | –        | See `/tags`                                                                            |
| `minTvl` / `maxTvl`                                      | number  | –        | Total supplied, USD                                                                    |
| `minLltv` / `maxLltv`                                    | number  | –        | **Fractions.** `0.86` is 86%.                                                          |
| `minUtilization` / `maxUtilization`                      | number  | –        | Fractions                                                                              |
| `minBorrowApy` / `maxBorrowApy`                          | number  | –        | Fractions                                                                              |
| `minMarketScore`                                         | number  | –        | 0–100                                                                                  |
| `hasLiquidity`                                           | boolean | –        | Only markets with borrowable liquidity                                                 |
| `includeBadDebt`                                         | boolean | –        | Default `false`                                                                        |
| `includeUnverified`                                      | boolean | –        | Default `false`                                                                        |
|                                                          |         |          | Plus the shared parameters.                                                            |

**Response:** list envelope of market objects.

#### `GET /v1/yields/borrow/markets/{network}/{marketId}`

**Request**

| Field      | Type | Required | Description                                         |
| ---------- | ---- | -------- | --------------------------------------------------- |
| `network`  | path | ✅        | Network slug.                                       |
| `marketId` | path | ✅        | The 32-byte market id (without the network prefix). |

**The market object**

```jsonc
{
  "marketId": "plume:0x7a96549c…",     // {networkSlug}:{marketId}
  "marketKey": "0x7a96549c…",          // the raw 32-byte id
  "network":  { "name": "Plume", "slug": "plume", "chainId": 98866, "networkCaip": "eip155:98866" },
  "protocol": { "name": "morpho", "displayName": "Morpho", "product": "lending-market", "version": "v1.1" },
  "loanAsset":       { "address": "0xdddd…", "symbol": "pUSD", "priceUsd": "1.0002", "assetGroup": "usd" },
  "collateralAsset": { "address": "0x593c…", "symbol": "nALPHA", "priceUsd": "1.1059", "assetGroup": "rwa" },
  "lltv": 0.86,
  "liquidationThreshold": 0.86,
  "liquidationPenalty": 0.0438,
  "apy": {
    "supply": { "base": 0.0373, "reward": 0, "total": 0.0373 },
    "borrow": { "base": 0.0426, "reward": 0, "total": 0.0426 }
  },
  "supply": { "assets": "4261152.37", "shares": "4048331.17", "usd": "4262004.6" },
  "borrow": { "assets": "3747270.67", "shares": "3535370.85", "usd": "3748020.1" },
  "liquidity":              { "native": "513881.7", "usd": "513984.5" },
  "reallocatableLiquidity": { "native": "109354.5", "usd": "109376.3" },
  "utilization": 0.8794,
  "capacity": { "supplyCap": "10000000.0", "totalSupplyCaps": "11000000.0" },
  "fee": 0,
  "performanceFee": 0.1,               // a DIFFERENT number from `fee`
  "isIdle": false,                     // Morpho's placeholder market, flagged not inferred
  "oracle": { "address": "0x7824…", "baseFeed": null },
  "irm":    { "address": "0x7420…" },
  "score": { "marketScore": 62 },      // identical on list and detail by construction
  "supplyingVaults": [{
    "vaultId": "plume:0xc0df…", "address": "0xc0df…", "name": "Re7 RWA Yield", "symbol": "Re7pUSD",
    "curators": [{ "name": "Re7", "address": "0x…" }],
    "supplyShare": 0.9999,             // fraction of the market's total supply
    "supplied": { "native": "4048324.07", "usd": "4049133.7" },
    "vaultAsset": "pUSD"
    // ?expand=supplyingVaults adds: asset, curator, marketLabel, isActive, feeWrapper,
    // accessType, lastUpdateTimestamp, borrowShare, suppliedAssets, borrowedAssets,
    // cap, allocated, allocatorFee
  }],
  "vaultCaps": [{
    "vaultAddress": "0xc0df…", "vaultId": "plume:0xc0df…", "marketKey": "0x7a96549c…",
    "supplyCap": "10000000.0", "allocationLiquidity": "1093545.29", "allocationFee": 0
  }],
  "tags": [], "flags": [],
  "lastAccrualTimestamp": 1785516494,
  "lastUpdateTimestamp": 1785516534
}
```

**`liquidity` vs `reallocatableLiquidity`.** The first is total reachable liquidity: the market's own free liquidity plus whatever a public allocator can move in. The second is that reallocatable component alone. They behave differently, free liquidity is available now, reallocatable liquidity depends on an allocator acting, and a borrower sizing a draw needs to know which is which.

**`supplyingVaults` is tiered.** Lists carry the identity fields (everything `/morphoCache/lite` published, plus ids); the detail endpoint and `?expand=supplyingVaults` carry every field. Expanded fields are **omitted** rather than nulled, so `'cap' in vault` distinguishes "not asked for" from "no cap set".

**`vaultCaps` is not `supplyingVaults`.** It is what a public allocator *could* move, which is the number that decides whether a large borrow can be filled.

#### `GET /v1/yields/borrow/markets/{network}/{marketId}/allocations`

```jsonc
{
  "marketId": "plume:0x7a96549c…",
  "data": [ /* supplyingVaults, fully expanded */ ],
  "lastUpdateTimestamp": 1785516534
}
```

***

### Portfolio

Wallet-scoped reads. Every response carries `errors.unsupportedNetworks` naming any chain we could not see, for a balance figure that matters more than anywhere else in the API.

| Route                                                      | Credits | Description                             |
| ---------------------------------------------------------- | ------- | --------------------------------------- |
| `/portfolio/summary/{address}`                             | 3       | Everything below, in one call           |
| `/portfolio/positions/{address}`                           | 2       | Vault positions across networks         |
| `/portfolio/positions/{address}/{network}/{vaultId}`       | 2       | One position                            |
| `/portfolio/borrow-positions/{address}`                    | 2       | Collateral, debt, LTV, health factor    |
| `/portfolio/total-returns/{address}/{network}/{vaultId}`   | 2       | Lifetime realised + unrealised return   |
| `/portfolio/partial-returns/{address}/{network}/{vaultId}` | 2       | Return between two timestamps           |
| `/portfolio/historical-positions/{address}`                | 2       | Every vault ever held, including exited |
| `/portfolio/historical-balances/{address}/{timestamp}`     | 2       | Balances reconstructed at a past date   |
| `/portfolio/idle-assets/{address}`                         | 2       | Holdings not deployed anywhere we index |
| `/portfolio/best-vault/{address}`                          | 3       | Best vault per idle asset               |
| `/portfolio/best-deposit-options/{address}`                | 3       | Top N vaults per idle asset             |

**Shared portfolio parameters**

| Field                  | Type    | Required | Description                                                                                                  |
| ---------------------- | ------- | -------- | ------------------------------------------------------------------------------------------------------------ |
| `address`              | path    | ✅        | Wallet address.                                                                                              |
| `page`, `perPage`      | integer | –        | Default 1 / 50, max 500.                                                                                     |
| `allowedNetworks`      | csv     | –        | Slugs or chain ids.                                                                                          |
| `allowedAssets`        | csv     | –        | Symbols or addresses.                                                                                        |
| `minUsdValueThreshold` | number  | –        | Default `0.01`. Suppresses dust — a wallet that ever touched a vault keeps a rounding-dust position forever. |

#### `GET /v1/yields/portfolio/summary/{address}`

Replaces roughly five separate requests, and is priced accordingly.

```jsonc
{
  "address": "0x…",
  "totals": {
    "suppliedUsd": "124000.5", "collateralUsd": "50000",
    "debtUsd": "20000", "netWorthUsd": "154000.5"
  },
  "weightedApy": 0.0641,                   // TVL-weighted, not a mean of rates
  "projectedUsdAnnualEarnings": "7948.4",
  "positionCount": 4,
  "borrowPositionCount": 1,
  "lowestHealthFactor": 2.15,              // the riskiest position governs
  "networks": ["plume", "flare"],
  "errors": { /* … */ }
}
```

An average health factor would hide one position about to be liquidated behind several safe ones, so the lowest is reported instead.

#### `GET /v1/yields/portfolio/positions/{address}`

**Response** — list envelope of positions.

```jsonc
{ "data": [{
  "vaultId": "plume:0x0b14…", "address": "0x0b14…", "name": "Mystic MEV Capital pUSD",
  "network":  { "slug": "plume", "chainId": 98866, "name": "Plume", "networkCaip": "eip155:98866" },
  "protocol": { "name": "morpho", "displayName": "Morpho", "product": "vault", "version": "v1.1" },
  "asset": { "address": "0xdddd…", "symbol": "pUSD", "decimals": 6, "priceUsd": "1.0002", "assetGroup": "usd" },
  "balance": { "shares": "119843.22", "native": "124776.5", "usd": "124801.4" },
  "apy": { "base": 0.0612, "reward": 0.0140, "total": 0.0752 },
  "projectedUsdAnnualEarnings": "9384.9"
}] }
```

#### `GET /v1/yields/portfolio/positions/{address}/{network}/{vaultId}`

One position object as above. `404` when the wallet holds nothing in that vault.

#### `GET /v1/yields/portfolio/borrow-positions/{address}`

The indexer models collateral and debt as separate rows; they are re-joined here so a position is one object, not two halves.

```jsonc
{ "data": [{
  "marketId": "plume:0x7a96549c…",
  "network": { "slug": "plume", "chainId": 98866 },
  "protocol": { "name": "morpho", "displayName": "Morpho", "product": "lending-market" },
  "collateralAsset": { "symbol": "nALPHA", "address": "0x593c…", "decimals": 6, "priceUsd": "1.1059" },
  "debtAsset":       { "symbol": "pUSD",   "address": "0xdddd…", "decimals": 6, "priceUsd": "1.0002" },
  "collateral": { "native": "10000.0", "usd": "11059.9" },
  "debt":       { "native": "6000.0",  "usd": "6001.2" },
  "lltv": 0.86,
  "ltv": 0.5426,
  "healthFactor": 1.585                    // null when there is no debt
}] }
```

`healthFactor` is collateral × liquidation threshold ÷ debt. It is **`null` with no debt** — a position that cannot be liquidated has no health factor, and returning `Infinity` would force every caller to special-case it.

#### `GET /v1/yields/portfolio/total-returns/{address}/{network}/{vaultId}`

Current value plus everything withdrawn minus everything deposited, denominated in the **vault asset** — a USD figure would conflate yield with the asset's own price movement.

```jsonc
{
  "vaultId": "plume:0x0b14…",
  "asset": { "symbol": "pUSD", "address": "0xdddd…", "priceUsd": "1.0002" },
  "deposited":    { "native": "120000.0" },
  "withdrawn":    { "native": "10000.0" },
  "currentValue": { "native": "124776.5" },
  "returnsNative": "14776.5",
  "returnsUsd": "14779.4",
  "basedOnEvents": 42,
  "isComplete": true                        // false once the 500-event cap truncates the history
}
```

#### `GET /v1/yields/portfolio/partial-returns/{address}/{network}/{vaultId}`

Period performance rather than lifetime. Contributions inside the window are subtracted, so a mid-window deposit does not read as a gain.

**Request** — `fromTimestamp` **and** `toTimestamp` are both **required** (a period return needs a period); `toTimestamp` must be after `fromTimestamp`. Either omitted, or reversed, is a `400`.

```jsonc
{
  "vaultId": "plume:0x0b14…",
  "asset": { "symbol": "pUSD" },
  "fromTimestamp": 1735689600, "toTimestamp": 1767225600,
  "openingContributed":  { "native": "100000.0" },
  "depositedInWindow":   { "native": "20000.0" },
  "withdrawnInWindow":   { "native": "10000.0" },
  "closingValue":        { "native": "124776.5" },
  "returnsNative": "14776.5", "returnsUsd": "14779.4",
  "eventsInWindow": 12,
  "closingValueIsCurrent": true,            // see below
  "isComplete": true
}
```

`closingValueIsCurrent` is stated rather than implied: a `toTimestamp` in the past cannot be honoured exactly, because we hold no historical share price for every vault. The closing value is the **current** balance.

#### `GET /v1/yields/portfolio/historical-positions/{address}`

Every vault the wallet has ever held, including fully-exited ones, with when each opened and last saw activity. `positions` only shows current holdings, so a wallet that exited last month vanishes from it entirely.

**Request** — the shared portfolio parameters plus `fromTimestamp`, `toTimestamp`, `eventType`, and `includeClosed` (default `true`).

#### `GET /v1/yields/portfolio/historical-balances/{address}/{timestamp}`

Replayed from the transfer stream: Σdeposits − Σwithdrawals up to that instant.

**Request:** `timestamp` is a path parameter in unix seconds; non-numeric or non-positive is a `400`.

```jsonc
{
  "address": "0x…",
  "asOf": 1735689600,
  "basis": "net-contributed",
  "caveats": [
    "Reconstructed from transfer events, so yield accrued between events is not included.",
    "Valued at the current asset price, not the price on the requested date."
  ],
  "data": [{
    "vaultId": "plume:0x0b14…",
    "network": { "slug": "plume", "chainId": 98866 },
    "asset": { "symbol": "pUSD" },
    "netContributed": { "native": "110000.0" },
    "valueUsdAtCurrentPrice": "110022.0",
    "eventsReplayed": 31
  }],
  "pagination": { /* … */ }, "errors": { /* … */ }
}
```

It reports **net contributed, not position value** — yield accrued between events is not recoverable from transfers alone. Both limits are on the response rather than in a footnote.

#### `GET /v1/yields/portfolio/idle-assets/{address}`

Wallet balances not deployed into anything we index.

**Request**

| Field                                | Type    | Required | Description                                      |
| ------------------------------------ | ------- | -------- | ------------------------------------------------ |
| `address`                            | path    | ✅        | Wallet address.                                  |
| `page`, `perPage`                    | integer | –        | Default 1 / 50, max 200.                         |
| `allowedNetworks`                    | csv     | –        | Slugs or chain ids.                              |
| `allowedAssets` / `disallowedAssets` | csv     | –        | Symbols or addresses.                            |
| `minUsdValueThreshold`               | number  | –        | Default `1` — below that, gas exceeds the yield. |

```jsonc
{ "data": [{
  "address": "0xdddd…", "symbol": "pUSD", "decimals": 6,
  "priceUsd": "1.0002", "assetGroup": "usd",
  "assetCaip": "eip155:98866/erc20:0xdddd…",
  "network": { "slug": "plume", "chainId": 98866 },
  "balanceNative": "5000.0", "balanceUsd": "5001.0"
}] }
```

**What the scope actually is.** Balances are read against a **curated asset universe** — every asset our vaults and markets use, plus the chain's native token and a short extras list. It is not every ERC-20 in the wallet.

That is a deliberate trade: an open scan needs a token indexer we do not run on Ethereum or Base, and it surfaces airdropped spam with fabricated prices, which in a "here is what you could be earning" endpoint is actively harmful. The consequence is that a holding outside the universe will not appear. "Idle" is also relative to what we index — a token deployed in a protocol we do not cover reads as idle here.

#### `GET /v1/yields/portfolio/best-vault/{address}`

Highest-yielding vault for each idle asset, with projected annual earnings on that balance. Restricted to vaults denominated in the **same asset** — a recommendation that silently requires a swap would be misleading.

**Request:** the idle-asset parameters plus:

| Field                | Type    | Required | Description                                                                   |
| -------------------- | ------- | -------- | ----------------------------------------------------------------------------- |
| `apyInterval`        | enum    | –        | Window to rank on. Default `1day`.                                            |
| `minTvl`             | number  | –        | Only consider vaults above this TVL, USD.                                     |
| `maxVaultsPerAsset`  | integer | –        | Default `3`, max `10`. Used by `best-deposit-options`.                        |
| `alwaysReturnAssets` | boolean | –        | Default `false`. Keep assets with no matching vault instead of dropping them. |

```jsonc
{
  "requestedAddress": "0x…",
  "data": [{
    "asset": { "symbol": "pUSD", "balanceNative": "5000.0", "balanceUsd": "5001.0", "network": { "slug": "plume" } },
    "vault": {
      "vaultId": "plume:0x0b14…", "name": "Mystic MEV Capital pUSD",
      "network": { /* … */ }, "protocol": { /* … */ },
      "tvl": { "native": "…", "usd": "…" },
      "apy": { "base": 0.0612, "reward": 0.0140, "total": 0.0752 },
      "tags": ["stablecoin"], "curator": { "name": "MEV" },
      "projectedUsdAnnualEarnings": "376.1"
    }
  }],
  "errors": { /* … */ }
}
```

#### `GET /v1/yields/portfolio/best-deposit-options/{address}`

Same request and shape, but each entry carries a `vaults` array of up to `maxVaultsPerAsset` options instead of a single `vault`.

`projectedUsdAnnualEarnings` is the point: an APY alone does not tell a user whether moving $40 is worth the gas.

***

### Transactions

A wallet's on-chain activity against everything we index — the feed a portfolio UI renders as a history tab. The separate `/liquidations` API is market-scoped: it answers "what was liquidated in this market", not "what happened to me".

| Route                                             | Credits | Description                     |
| ------------------------------------------------- | ------- | ------------------------------- |
| `/portfolio/events/{address}`                     | 3       | Every event against this wallet |
| `/portfolio/events/{address}/{network}/{vaultId}` | 2       | Events for one vault            |

#### `GET /v1/yields/portfolio/events/{address}`

Deposits, withdrawals, borrows, repayments and **liquidations**, newest first.

**Request**

| Field                           | Type    | Required | Description                                             |
| ------------------------------- | ------- | -------- | ------------------------------------------------------- |
| `address`                       | path    | ✅        | Wallet address.                                         |
| `eventType`                     | string  | –        | `deposit`, `withdraw`, `borrow`, `repay`, `liquidation` |
| `fromTimestamp` / `toTimestamp` | integer | –        | Unix seconds.                                           |
| `includeClosed`                 | boolean | –        | Default `true`.                                         |
|                                 |         |          | Plus the shared portfolio parameters.                   |

**Response**

```jsonc
{ "data": [{
  "timestamp": 1753747200,
  "blockNumber": "18402113",
  "eventType": "deposit",
  "network": { "slug": "plume", "chainId": 98866, "name": "Plume", "networkCaip": "eip155:98866" },
  "vaultId": "plume:0x0b14…",              // null on market events
  "marketId": null,                        // set on borrow/repay/liquidation
  "asset": { "address": "0xdddd…", "symbol": "pUSD", "decimals": 6, "priceUsd": "1.0002" },
  "assetAmount": { "native": "20000.0", "usd": "20004.0" },
  "lpTokenAmount": "19207.4",              // shares moved; null on market events
  "transactionHash": "0x…",
  "logIndex": null                         // set on market events
}] }
```

A liquidation carries an extra block:

```jsonc
{
  "eventType": "liquidation",
  "marketId": "plume:0x7a96549c…",
  "assetAmount": { "native": "6000.0", "usd": "6001.2" },
  "liquidation": {
    "liquidator": "0x…",
    "seizedAsset": { "symbol": "nALPHA", "address": "0x593c…", "decimals": 6 },
    "seizedAmount": "5660.4"
  }
}
```

#### `GET /v1/yields/portfolio/events/{address}/{network}/{vaultId}`

The same feed, scoped to one vault. Same parameters, same event shape.

***

### Historical

Every route takes an explicit window and an optional granularity. This replaces the legacy `timeRange=1M` enum, which cannot express "since I deposited" — the range a portfolio UI actually needs.

| Route                                          | Credits | Description                                               |
| ---------------------------------------------- | ------- | --------------------------------------------------------- |
| `/historical/{network}/{vaultId}`              | 2       | TVL, APY, liquidity and implied asset price in one series |
| `/historical/{network}/{vaultId}/apy`          | 2       | APY only                                                  |
| `/historical/{network}/{vaultId}/tvl`          | 2       | TVL only                                                  |
| `/historical/{network}/{vaultId}/share-price`  | 3       | Assets per share over time                                |
| `/historical/{network}/{vaultId}/daily-flows`  | 3       | Net deposits and withdrawals per day                      |
| `/historical/asset-prices/{network}/{address}` | 3       | Asset price over time                                     |
| `/historical/borrow/{network}/{marketId}`      | 2       | Market supply, borrow, rates, utilization                 |

**Shared parameters**

| Field           | Type    | Required | Description                                            |
| --------------- | ------- | -------- | ------------------------------------------------------ |
| `fromTimestamp` | integer | –        | Start of the window, unix seconds, inclusive.          |
| `toTimestamp`   | integer | –        | End of the window, unix seconds, inclusive.            |
| `granularity`   | enum    | –        | `1hour` \| `1day` \| `1week`. Omit for the raw series. |
| `page`          | integer | –        | Default `1`.                                           |
| `perPage`       | integer | –        | Default `500`, max `1000`.                             |

Buckets are aligned to the epoch and keep their **closing** value. These are point-in-time readings, so averaging within a bucket would invent a number that never existed at any instant.

#### `GET /v1/yields/historical/{network}/{vaultId}`

```jsonc
{
  "vaultId": "plume:0x0b14…",
  "data": [{
    "timestamp": 1753747200, "date": "2025-07-29",
    "tvl": { "native": "12397600.0", "usd": "12400079.5" },
    "apy": 0.0612,
    "liquidity": "3120004.1",
    "assetPriceUsd": "1.0002"
  }],
  "pagination": { /* … */ }, "errors": { /* … */ }
}
```

`404` when we hold no history for that vault.

#### `GET /v1/yields/historical/{network}/{vaultId}/apy`

```jsonc
{ "data": [{ "timestamp": 1753747200, "apy": 0.0612 }], "pagination": { /* … */ } }
```

#### `GET /v1/yields/historical/{network}/{vaultId}/tvl`

```jsonc
{ "data": [{ "timestamp": 1753747200, "tvl": { "native": "12397600.0", "usd": "12400079.5" } }] }
```

#### `GET /v1/yields/historical/{network}/{vaultId}/share-price`

Requires indexed share supply.

```jsonc
{
  "data": [{ "timestamp": 1753747200, "sharePrice": "1.041233" }],
  "pagination": { /* … */ },
  "unavailableReason": null
}
```

On Ethereum and Base it returns an **empty series with an explicit `unavailableReason`** and the network in `errors.unsupportedNetworks`, rather than an empty array that reads like "this vault has no history".

#### `GET /v1/yields/historical/{network}/{vaultId}/daily-flows`

Net flow per day, counted from actual transfers.

```jsonc
{
  "vaultId": "plume:0x0b14…",
  "data": [{
    "timestamp": 1753747200, "date": "2025-07-29",
    "deposits": "42000.0", "withdrawals": "18000.0",
    "netFlow": "24000.0",                 // signed — negative days are outflows
    "depositCount": 7, "withdrawalCount": 3
  }],
  "pagination": { /* … */ },
  "unavailableReason": null
}
```

Counted from transfers rather than differenced from TVL: a TVL delta conflates flows with yield accrual and price movement, so a vault that took no deposits but whose asset appreciated would show a fabricated inflow. Requires an indexer.

#### `GET /v1/yields/historical/asset-prices/{network}/{address}`

```jsonc
{
  "asset": { "address": "0xdddd…", "network": "plume" },
  "source": "recorded",                     // or "implied"
  "sourceVault": null,                      // set when source is "implied"
  "data": [{ "timestamp": 1753747200, "date": "2025-07-29", "priceUsd": "1.0002" }],
  "pagination": { /* … */ }
}
```

Two sources, named so you can tell them apart — they have different accuracy:

* **`recorded`** — read from the daily price snapshots. Authoritative, and covers every asset we index including collateral assets no vault is denominated in.
* **`implied`** — derived from a vault's own history, where each point's USD and native totals give the price the pipeline observed. Used for the period before daily recording began, with `sourceVault` naming the vault it came from.

`404` when the asset is neither recorded nor held by any indexed vault.

#### `GET /v1/yields/historical/borrow/{network}/{marketId}`

```jsonc
{
  "marketId": "plume:0x7a96549c…",
  "data": [{
    "timestamp": 1753747200, "date": "2025-07-29",
    "supply": { "native": "4261152.37", "usd": "4262004.6" },
    "borrow": { "native": "3747270.67", "usd": "3748020.1" },
    "liquidity": "513881.7",
    "apy": { "supply": 0.0373, "borrow": 0.0426 },
    "utilization": 0.8794
  }],
  "pagination": { /* … */ }
}
```

***

### Realtime

Uncached on-chain reads, for when a cache is not good enough — sizing a liquidation, or reconciling against chain state.

| Route                                                  | Credits | Description                                               |
| ------------------------------------------------------ | ------- | --------------------------------------------------------- |
| `/realtime/{network}/{vaultId}`                        | 3       | Total assets, total supply and share price from ONE block |
| `/realtime/{network}/{vaultId}/share-price`            | 3       | Share price alone                                         |
| `/realtime/{network}/{vaultId}/total-assets`           | 3       | Total assets alone                                        |
| `/realtime/{network}/{vaultId}/total-supply`           | 3       | Total share supply alone                                  |
| `/realtime/{network}/{vaultId}/underlying-asset-price` | 3       | Asset identity live; price from the cache                 |

#### `GET /v1/yields/realtime/{network}/{vaultId}`

**Request:** `network` and `vaultId` path parameters. No query parameters.

```jsonc
{
  "vaultId": "plume:0x0b14…",
  "network": { "name": "Plume", "slug": "plume", "chainId": 98866, "networkCaip": "eip155:98866" },
  "readAt": 1785516534,
  "asset": { "address": "0xdddd…", "symbol": "pUSD", "decimals": 6, "priceUsd": "1.0002" },
  "totalAssets": { "native": "12397600.0", "usd": "12400079.5" },
  "totalSupply": "11907214.2",
  "sharePrice": "1.041233"                  // null for a vault with no shares outstanding
}
```

**Prefer this route** if you need more than one value. It is one RPC round-trip instead of several and more importantly the values come from the **same block**. Four separate calls can straddle a block and produce a share price that never existed. The combined route costs the same as a single sub-resource, so taking the consistent path is never penalised.

`sharePrice` is `null`, never `1.0`, for a vault with no shares outstanding: 1.0 is a plausible-looking reading and would be indistinguishable from a real one.

#### The single-value routes

Each reads the same block and returns one slice of the snapshot above.

| Route                                                      | Response                                                                              |
| ---------------------------------------------------------- | ------------------------------------------------------------------------------------- |
| `GET /v1/yields/realtime/{network}/{vaultId}/share-price`  | `{ vaultId, readAt, sharePrice, asset }` — `asset` is the symbol here, not the object |
| `GET /v1/yields/realtime/{network}/{vaultId}/total-assets` | `{ vaultId, readAt, totalAssets: { native, usd } }`                                   |
| `GET /v1/yields/realtime/{network}/{vaultId}/total-supply` | `{ vaultId, readAt, totalSupply }`                                                    |

They cost the same as the combined route, so there is no saving in asking for one value at a time — only the risk of straddling a block.

#### `GET /v1/yields/realtime/{network}/{vaultId}/underlying-asset-price`

```jsonc
{
  "vaultId": "plume:0x0b14…",
  "readAt": 1785516534,
  "asset": { "address": "0xdddd…", "symbol": "pUSD", "decimals": 6, "priceUsd": "1.0002" },
  "priceSource": "cache"
}
```

Only the asset **identity** is read on-chain. The price comes from the same pricing layer the cached endpoints use, and `priceSource` says so — a caller reconciling against chain data needs to know which half is live.

#### Liquidations

| Endpoint                                                                       | Description                                                                                 |
| ------------------------------------------------------------------------------ | ------------------------------------------------------------------------------------------- |
| `GET /liquidations/overview-data?chainId={chainId}&marketId={marketId}`        | Aggregate summary across all matching positions (counts + USD totals).                      |
| `GET /liquidations/positions?chainId={chainId}&marketId={marketId}`            | Paginated list of open borrow positions and their risk of liquidation (LTVs, HFs and more). |
| `GET /liquidations/liquidated-positions?chainId={chainId}&marketId={marketId}` | Paginated history of positions that have already been liquidated (most recent first).       |

### Supported protocols, and chains

#### Protocols

| Protocol | Product                  | Versions | Vaults | Borrow markets | Events | Share-price history | Holders | Positions |
| -------- | ------------------------ | -------- | ------ | -------------- | ------ | ------------------- | ------- | --------- |
| Morpho   | Vaults + lending markets | V1.1, V2 | ✅      | ✅              | ✅      | ✅                   | ✅       | ✅         |

#### Chains

Every endpoint takes a `chainId` query param. Omit it and it defaults to **Plume (98866)**. Unsupported chains return an empty result, not an error.\
\
This is a list of Chains and their respective chainId below.

| Chain    | ChainId |
| -------- | ------- |
| Plume    | 98866   |
| Flare    | 14      |
| Citrea   | 4114    |
| Ethereum | 1       |
| Base     | 8453    |

### Errors

| Status | Meaning                                                                                                         | What to do                                                                                                                                                  |
| ------ | --------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `400`  | Unknown query parameter, wrong type, or a missing required one (e.g. `partial-returns` without both timestamps) | Fix the request. The message names the offending property.                                                                                                  |
| `401`  | Missing, invalid, disabled or expired API key                                                                   | Check the `x-api-key` header.                                                                                                                               |
| `402`  | Monthly credit allowance exhausted, on a key with a hard cap                                                    | Upgrade the plan or ask support to raise the ceiling. Only keys explicitly opted into a spend ceiling can hit this — the default is that overage is billed. |
| `404`  | Resource does not exist, or no history for it                                                                   | Do not retry. Never returned as `200 null`.                                                                                                                 |
| `429`  | Over your plan's requests/second                                                                                | Back off for `Retry-After` seconds.                                                                                                                         |
| `5xx`  | Our problem                                                                                                     | Retry with backoff; contact support if it persists.                                                                                                         |

#### What is not an error

Three things that look like failures and are not:

**Unresolvable filter values.** `?allowedNetworks=plume,atlantis` returns `200` with every Plume result and `errors.unsupportedNetworks: ["atlantis"]`. Losing nine good networks to one typo would be worse than the warning.

**Null values.** `priceUsd: null`, `apy["30day"]: null`, `healthFactor: null` and `sharePrice: null` are all deliberate. They mean "we do not know" or "not applicable".
