API
Base URL, endpoints, address-casing and error-format details for calling the public Fusion API directly.
IPOR Static API — Developer Guide
Public, read-only JSON API serving IPOR Fusion vault data, including the historical time series behind the charts on app.ipor.io (opens in new tab).
Base URL: https://api.ipor.io
No API key. No authentication. No rate limit. No query parameters — every input is encoded in the URL path.
OpenAPI description: https://ipor.io/openapi.json (also https://ipor.io/openapi.yaml) describes every endpoint and field, with units and nullability. Give an AI agent that URL and it can use the API without further explanation.
Important: the long-range history buckets are intentionally stale:
1yis regenerated every 7 days andallevery 30 days. A request for-allalone returns a series whose most recent point may be up to about 30 days old, and a vault created after the last regeneration gets an emptyall, with no error to indicate this. See Period buckets and freshness. This behaviour is by design and will not change; clients are expected to merge buckets.
Quick start
Chart a vault's APY over the last 30 days:
# Note the lowercase address. Anything else 404s.
curl --compressed \
'https://api.ipor.io/v2/fusion/vault-history/1/0x43ee0243ea8cf02f7087d8b16c8d2007cc9c7ca2-30d' \
| jq '.history[-1] | {blockTimestamp, apy, tvl}'
{
"blockTimestamp": "2026-07-08T13:14:47Z",
"apy": "3.2864762350246543",
"tvl": "490462.27822644341523"
}
Note that the most recent point is already approximately 21 hours old. Obtaining a series that extends to the present requires additionally fetching -7d and merging the two. The reference client performs this automatically.
Discover vault addresses first:
curl --compressed 'https://api.ipor.io/v2/fusion/vaults' \
| jq '.vaults[] | {chainId, address, name, netApy, tvl}'
Conventions
Addresses must be lowercase. The API is static JSON objects on S3 behind CloudFront, so paths are object keys and are case-sensitive. A checksummed (mixed-case) address returns 404.
200 /v2/fusion/vault-history/1/0x43ee0243ea8cf02f7087d8b16c8d2007cc9c7ca2-7d
404 /v2/fusion/vault-history/1/0x43Ee0243eA8CF02f7087d8B16C8D2007cC9c7cA2-7d
/v2/fusion/vaults returns lowercase addresses. Take care with the other lists: /dapp/plasma-vaults-list and /fusion/vaults-customization-list return most addresses checksummed (mixed-case). An address taken from the vault list and used unmodified in a history path will return 404. Apply .toLowerCase() before constructing any path.
Numbers are decimal strings, not JSON numbers. "tvl": "490462.27822644341523". The precision exceeds that of an IEEE-754 double. Parse them with a decimal library (decimal.js, big.js, bignumber.js). Passing them through Number() will discard low-order digits. Token amounts in /v2/fusion/vaults and the history endpoints (totalAssets, totalBalance, balance) are whole-token units. Only /dapp/plasma-vaults-list uses raw base units (totalAssets, scale by assetDecimals) and 18-decimal fixed point (fields ending in _18). One exception to the string rule: supplyApy in /v2/fusion/markets is a JSON number.
Percentages are in percent: "4.65" means 4.65 %, not 465 %.
Timestamps are ISO-8601 UTC, e.g. "2026-07-09T09:14:47Z".
Every history point carries a blockNumber, which is the correct key for joining, deduplicating, and ordering series. Prefer it over the timestamp.
Errors are XML, not JSON. A miss — unknown vault, unsupported period, or a non-lowercase address — returns the raw S3 error document with content-type: application/xml:
<?xml version="1.0" encoding="UTF-8"?>
<Error><Code>NoSuchKey</Code><Message>The specified key does not exist.</Message>...</Error>
A client that calls .json() on any non-2xx response will throw a parse error rather than surface a clean 404. Branch on response.status before parsing.
Responses are gzipped (content-encoding: gzip). Send Accept-Encoding: gzip (curl --compressed); browsers and fetch do this automatically. The uncompressed payloads are much larger — see Payload sizes.
CORS is open (access-control-allow-origin: *, GET only). The header is returned when an Origin request header is present, so browser calls work directly with no proxy.
Period buckets and freshness
Both history endpoints take a period suffix on the last path segment:
| Period | Coverage | Regenerated |
|---|---|---|
7d | Trailing 7 days | Every 5 minutes |
30d | Trailing 30 days | Once a day |
1y | Trailing 365 days | Every 7 days |
all | Full vault lifetime | Every 30 days |
Points are spaced roughly hourly.
Why the data is spliced this way
The API is not a database query layer. Every response is a pre-rendered file written to S3 and served through a CDN. This is a deliberate design decision made for security and performance: there is no query surface to exploit or overload, and reads remain fast and inexpensive at any traffic level.
The trade-off is that a file's contents are only as current as its last write, and rewriting a multi-megabyte object is costly. Consequently, the older a file's data, the less frequently it is regenerated. The 7d file is small and is rewritten frequently. The all file is large and consists mostly of immutable history, so it is rewritten rarely.
This is the reason the most recent point in a bucket lags real time by an amount that grows with the size of the bucket. Measured against vault 0x43ee0243ea8cf02f7087d8b16c8d2007cc9c7ca2 on Ethereum at 2026-07-09T10:00Z:
| Period | Points | Newest point | Trails real time by |
|---|---|---|---|
7d | 168 | 2026-07-09T09:14Z | ~45 minutes |
30d | 720 | 2026-07-08T13:14Z | ~21 hours |
1y | 8,714 | 2026-07-06T13:14Z | ~2.9 days |
all | 14,726 | 2026-06-17T13:14Z | ~22 days |
The exact lag varies by vault and by regeneration cycle. Do not depend on the specific values above. Depend instead on the ordering, which is invariant: 7d is always the most current bucket and all is always the least current.
This is intended behaviour and will not change. Rewriting the multi-megabyte all object every hour would provide no benefit, because its stale tail can be reconstructed inexpensively from the smaller, more current buckets. Clients are expected to perform this reconstruction.
The merge rule
To render a period accurately, fetch the requested bucket plus every more current bucket that covers its stale tail, then merge the point sets:
| Requested | Fetch |
|---|---|
7d | 7d |
30d | 30d, 7d |
1y | 1y, 30d, 7d |
all | all, 1y, 30d, 7d |
Merge by blockNumber, sort ascending, and on a duplicate blockNumber retain the point from the more current bucket. The buckets overlap, so duplicates are expected and must be collapsed. Otherwise any aggregate computed over the series (averages, integrals, point counts) will double-count the overlap region.
Applying this, all four periods become current to within ~1 hour:
7d pts= 168 first=2026-07-02T11:14:47Z last=2026-07-09T10:14:47Z trails=0.2h
30d pts= 741 first=2026-06-08T14:14:47Z last=2026-07-09T10:14:47Z trails=0.2h
1y pts= 8783 first=2025-07-06T14:14:47Z last=2026-07-09T10:14:47Z trails=0.2h
all pts= 15249 first=2024-09-30T21:15:11Z last=2026-07-09T10:14:47Z trails=0.2h
all needs 1y as well: all can be up to 30 days old and 30d up to a day old, so 30d alone may not reach back to the end of all. 1y (at most 7 days old) closes that gap.
Tolerate a 404 on an individual bucket. A vault younger than a year may have no 1y object, but its all and 30d objects exist. A vault created after the last all regeneration gets an all file with an empty history array, not a 404; the merge covers it. Treat the merge as successful if at least one bucket resolves. Propagate any non-404 failure rather than rendering a partial series without indication.
Which APY to use
All APY fields are spot rates: forward-looking annualised rates as of the statistics point, not realised past performance. Each statistics point runs a 1-hour forward simulation of the vault, and the rates are annualised from it with compounding (only underlyingAssetApy is linear).
| Field | Meaning |
|---|---|
netApy | Headline yield for a depositor. Derived from the expected change of the vault share price going forward, after the vault's management and performance fees. |
grossApy | The same share-price-based spot rate before the management and performance fees. grossApy − netApy is the fee drag. |
apy | Spot APY of the vault's market positions: each market's expected growth over the simulation, weighted by the vault's balance in it. Before vault fees and without rewards (rewards are in rewardsApy). Built bottom-up from the markets, so it can differ from grossApy, which comes from the share price. Not an after-fee figure; use netApy for that. |
underlyingAssetApy | Yield of the underlying asset itself, e.g. the staking yield of cbETH. Annualised linearly from the price change of the underlying. Informational only: not a component of apy and can be higher than it. |
rewardsApy | Expected yield from rewards: rewards the vault can claim through its reward fuses, plus off-chain programs (e.g. Merkl) that IPOR has configured for the markets the vault holds. Not included in apy. Campaigns that are not configured show as 0 here, including Merkl campaigns that pay holders of another Fusion vault this vault deposits into; once claimed, those rewards still reach the share price. |
vestingApy | Yield from rewards the curator adds to the vault, vested into the share price over time. |
rewardsVestingApy | Yield coming only from external reward programs the vault takes part in, vested into the share price over time. |
Because they are spot rates, netApy and grossApy can move noticeably from one hourly point to the next. For a smoother figure, average them over history points. Both are null for vaults that are too young; fall back to apy (before fees) in that case.
Two share-price fields appear in every vault history point: assetsToSharesRatio is the share price as read from the vault contract, and updatedAssetsToSharesRatio is the share price that would hold if all cached market balances were refreshed.
Endpoints
GET /v2/fusion/vaults
Start here. Every Fusion vault on every chain with its current statistics, in one small file (tens of KB gzipped). Use it to find a vault by chain, name or asset and to read its current netApy, grossApy, apy and tvl.
{
"creationDate": "2026-10-21T10:01:21.638200581Z",
"vaults": [
{
"chainId": 1,
"address": "0x43ee0243ea8cf02f7087d8b16c8d2007cc9c7ca2", // lowercase
"name": "IPOR USDC Lending Optimizer Ethereum",
"asset": "USDC",
"assetAddress": "0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48",
"assetUsdPrice": "0.99981255",
"apy": "4.30352320301165337",
"netApy": "3.178648278077907366",
"grossApy": "4.653496179332899859",
"rewardsApy": "0",
"vestingApy": "0",
"rewardsVestingApy": "0",
"underlyingAssetApy": "0",
"totalAssets": "260074.944903", // whole USDC
"tvl": "260030.30043795795102", // USD
"publicDepositOpened": true,
"managedByIporAlpha": true,
"minVaultTvlUsd": "10000",
"alphaOperational": true, // null unless managedByIporAlpha
"alphaNotOperationalReasons": [], // unmet conditions when false
"lastReadBlockNumber": 26240121,
"lastReadBlockTimestamp": "2026-10-21T09:59:47Z"
}
]
}
- Names are free text and often end with the chain; several vaults can share a base name across chains. Filter by
chainIdfirst; the address is the only unique key. publicDepositOpened: true when anyone can deposit, false when deposits are limited to whitelisted addresses.managedByIporAlpha: true when IPOR Labs runs the vault's Alpha.minVaultTvlUsdis the TVL threshold for IPOR Labs' Alpha to manage the vault and only applies to those vaults.alphaOperational: whether that Alpha meets the conditions to rebalance the vault.nullwhenmanagedByIporAlphais false,truewhen every start condition is met,falsewhen at least one is not.truedoes not mean a rebalance has run; it means none of the reasons below blocks one.alphaNotOperationalReasons: every unmet condition at once whenalphaOperationalis false, otherwise an empty array.- Vaults without statistics yet have null in every numeric field.
Values of alphaNotOperationalReasons:
| Reason | Meaning |
|---|---|
BELOW_MIN_TVL | The vault's TVL is below minVaultTvlUsd. |
MANAGEMENT_FEE_TOO_LOW | The share of the vault's on-chain management fee that goes to IPOR is below the required minimum. Raising it clears the reason. |
ALPHA_DEACTIVATED | The fee is sufficient, but the Alpha was switched off and stays off until it is re-enabled. |
BLOCKING_CONFIGURATION_ISSUES | The vault's configuration has issues that block execution. |
INSUFFICIENT_GAS | The Alpha address holds less than 0.001 of the chain's native token. |
MISSING_ALPHA_ROLE | The Alpha address does not hold ALPHA_ROLE on the vault. |
STATUS_PENDING | No status has been computed for the vault yet. |
The status is recomputed periodically, so a fix such as a gas top-up or a granted role shows up after a delay, not at once.
GET /v2/fusion/markets
Current supply and borrow rates for every market Fusion vaults can use, in one small file. Use protocol and marketId from here as {protocol} and {substrate} in market-history paths.
{
"creationDate": "2026-10-21T10:03:18.881822222Z",
"markets": [
{
"chainId": 1,
"protocol": "aave-v3",
"marketId": "0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48",
"supplyApy": 3.841992731054263, // JSON number, unlike the other fields
"supplyBaseApy": "3.841992731054263",
"supplyRewards": [],
"borrowApy": "4.6328741893073088",
"borrowBaseApy": "4.6328741893073088",
"borrowRewards": []
}
]
}
The list has no token symbol. For lending pools marketId is the token address; match it against assetAddress in /v2/fusion/vaults or resolve it on-chain. Besides lending markets, the list contains yield-bearing tokens (e.g. susde, weeth) and every Fusion vault itself as protocol ipor-plasma-vault.
GET /dapp/plasma-vaults-list
The short list of vaults the IPOR Labs front end uses (~100 KB), not a complete list of Fusion vaults; use /v2/fusion/vaults for that. It adds on-chain details the v2 list lacks: vault symbol, curator name, decimals, fuses, market ids and the price oracle middleware.
{
"timestamp": "2026-07-09T10:01:15.819Z",
"version": "…",
"plasmaVaults": [
{
"name": "IPOR USDC Lending Optimizer Ethereum",
"chainId": 1,
"address": "0x43ee0243ea8cf02f7087d8b16c8d2007cc9c7ca2", // may be MIXED-CASE
"apr": "3.286746235024654313",
"totalAssets": "463267371354", // raw base units
"assetAddress": "0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48",
"assetSymbol": "USDC",
"assetDecimals": 6,
"marketIds": ["1", "11", "3", "4", "14"],
"fuses": ["0x465D639EB964158beE11f35E8fc23f704EC936a2", "…"]
}
]
}
Remember to lowercase address before using it in a history path.
GET /v2/fusion/vault-history/{chainId}/{vaultAddress}-{period}
Per-vault time series: APY, TVL, and allocation across markets. This is the primary charting endpoint. Subject to the merge rule.
vaultAddress must be lowercase.
{
"history": [
{
"blockNumber": 25444165,
"blockTimestamp": "2026-07-02T10:14:47Z",
"apy": "3.2864762350246543",
"rewardsApy": "0.104",
"vestingApy": null,
"underlyingAssetApy": "3.11",
"assetsToSharesRatio": "1.0412…",
"tvl": "490462.27822644341523", // USD
"totalBalance": "490648.346799", // underlying asset units
"marketBalances": [
{
"protocol": "aave-v3",
"marketId": "0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48",
"balanceType": "DEPOSIT",
"balance": "11096.690979",
"balanceUsd": "11092.48278088003383"
}
]
}
]
}
marketBalances provides the data for the historical-allocation (stacked area) chart: each point records how the vault's assets were distributed across underlying protocols at that block. For the rate fields (apy, netApy, grossApy and the rest) see Which APY to use.
Some vaults additionally expose dexPositionBalances (LP positions, with token0/token1 USD amounts) and rewardsVestingApy. Treat all of these as optional and nullable.
GET /v2/fusion/market-history/{chainId}/{protocol}/{substrate}-{period}
Per-market time series: supply and borrow rates and market size for one market on one protocol. Use it to chart the yield of the venues a vault allocates into. Subject to the merge rule.
protocol— a protocol slug, e.g.aave-v3.substrate— identifies the market within the protocol, lowercase. For lending pools this is the underlying asset address; for vault-shaped markets (erc4626,meta-morpho,morpho-blue) it is the market or vault address.
{
"points": [
{
"blockNumber": 25444165,
"timestamp": "2026-07-02T10:14:47Z",
"supplyApy": "4.1034406162952586",
"supplyBaseApy": "4.1034406162952586",
"supplyRewards": [
{
"assetAddress": "0xc00e94cb662c3520282e6f5717214004a7f26888",
"rewardApy": "0.1008130660673186",
"type": "INSTANT"
}
],
"borrowApy": "4.9405590449566654",
"borrowBaseApy": "4.9405590449566654",
"borrowRewards": [],
"totalSupply": "2017881149.188354",
"totalBorrow": "1869968394.182922"
}
]
}
supplyApy = supplyBaseApy + the sum of supplyRewards[].rewardApy. Point density is at least hourly and may be finer than the vault history.
Two protocols use a substrate convention that differs from their market id: spark markets are keyed by the DAI address, and Harvest vaults use the protocol slug harvest-erc4626. Aave V4 market ids have the form {hubAddress}:{reserveId}, e.g. 0x94e7a5dcbe816e498b89ab752661904e2f56c485:0, colon included.
Not every market in /v2/fusion/markets has a history file; some (e.g. silo-v2-borrowable) return 404.
GET /fusion/vaults-customization-list
Atomist-supplied presentation metadata for vaults that have opted in. Only a minority of vaults appear; absence from this list is normal and not an error.
[
{
"chainId": 1,
"vaultAddress": "0x8f5855D30610b176c972F844B93A28c6D1553416",
"description": "x*y=k",
"vaultLogoUrl": "https://api.ipor.io/fusion/vaults-customization/1/0x8f58…/vault-logo",
"announcements": []
}
]
Legacy endpoints
These v1 endpoints still respond but are deprecated. Do not build new integrations on them.
| Endpoint | Use instead | Only reason to still call it |
|---|---|---|
GET /fusion/vaults (one ~85 MB snapshot of every vault and market) | /v2/fusion/vaults, /v2/fusion/markets and the -history endpoints | historicalApr: realised share-price APR over the last ~7 days, with fees added back (before fees) |
GET /fusion/vaults-history/{chainId}/{vaultAddress} (trailing 30 days) | /v2/fusion/vault-history/… | apr1d: realised share-price APR over about the last day, with fees added back (before fees). Its apr and rewardsApr have the same values as apy and rewardsApy. |
GET /fusion/markets/{chainId}/{protocol}/{marketId} (trailing 30 days) | /v2/fusion/market-history/… | None |
For a realised rate after fees, compare assetsToSharesRatio between two v2 history points instead.
Supported chains
chainId appears in every history path.
| Chain | chainId |
|---|---|
| Ethereum | 1 |
| Arbitrum | 42161 |
| Base | 8453 |
| Unichain | 130 |
| Ink | 57073 |
| Plasma | 9745 |
| Avalanche | 43114 |
| HyperEVM | 999 |
| Monad | 143 |
| TAC | 239 |
| Katana | 747474 |
Chain support expands over time. Rather than hardcoding this table, derive the live set from GET /v2/fusion/vaults → vaults[].chainId.
Protocol slugs
The {protocol} path segment takes a slug such as aave-v3, compound-v3, morpho-blue, meta-morpho, euler-v2, spark, moonwell, gearbox-v3, fluid-instadapp, pendle, or erc4626.
Do not hardcode the set. New protocols and yield-bearing assets are added continuously, so any list reproduced here becomes inaccurate over time. Derive it at runtime:
curl --compressed 'https://api.ipor.io/v2/fusion/markets' \
| jq -r '[.markets[].protocol] | unique | .[]'
Two slugs follow non-obvious conventions: spark markets use the DAI address as their substrate, and Harvest vaults use the slug harvest-erc4626 rather than erc4626.
Payload sizes and caching
Orders of magnitude, uncompressed, for a mature Ethereum vault. Payloads grow with vault age and market count, so treat these as a guide to relative cost, not as fixed values:
| Endpoint | 7d | 30d | 1y | all |
|---|---|---|---|---|
vault-history | ~200 KB | ~850 KB | ~8 MB | ~14 MB |
market-history | ~100 KB | ~700 KB | ~5 MB | ~6 MB |
| Endpoint | Size |
|---|---|
/v2/fusion/vaults | ~440 KB (~55 KB gzipped) |
/v2/fusion/markets | ~250 KB (~45 KB gzipped) |
/dapp/plasma-vaults-list | ~100 KB (~20 KB gzipped) |
/fusion/vaults-customization-list | ~15 KB |
Responses are served from CloudFront and carry etag and last-modified. Use conditional requests (If-None-Match) to avoid re-downloading unchanged objects.
Cache the long buckets. An application that re-fetches -all at a short interval re-downloads approximately 14 MB to obtain data that is regenerated only rarely. The appropriate polling strategy follows from the freshness table:
- Fetch
alland1yonce and cache them for hours or days. - Re-poll only
7d(and30d) at your refresh interval. These objects are small and contain all recently added points. - Re-run the merge against the cached long bucket on each refresh.
Revalidate the cached long buckets periodically with a conditional request rather than caching them indefinitely. Historical data is occasionally recomputed to correct defects, so an all object can change without gaining new points. See Data accuracy and corrections.
Reference TypeScript client
Dependency-free. Handles bucket merging, address normalisation, and the XML error case. Verified against the live API.
/**
* Minimal client for the IPOR static API (https://api.ipor.io).
*/
const BASE_URL = 'https://api.ipor.io';
export type Period = '7d' | '30d' | '1y' | 'all';
/**
* Buckets to fetch for a requested period, ordered least-current to most-current.
* On a duplicate blockNumber the last bucket takes precedence, so the most
* current bucket must be listed last.
*/
const BUCKETS_TO_MERGE: Record<Period, readonly Period[]> = {
'7d': ['7d'],
'30d': ['30d', '7d'],
'1y': ['1y', '30d', '7d'],
all: ['all', '1y', '30d', '7d'],
};
/** Numeric fields are decimal strings, not numbers — parse with care. */
type Decimal = string;
export interface MarketBalance {
marketId: string | null;
protocol: string | null;
balance: Decimal;
balanceUsd: Decimal;
balanceType: string | null;
}
export interface VaultHistoryPoint {
blockNumber: number;
blockTimestamp: string; // ISO-8601, e.g. "2026-07-09T09:14:47Z"
apy: Decimal | null;
rewardsApy: Decimal | null;
vestingApy: Decimal | null;
underlyingAssetApy: Decimal | null;
assetsToSharesRatio: Decimal | null;
tvl: Decimal;
totalBalance: Decimal;
marketBalances: MarketBalance[];
}
export interface VaultHistoryResponse {
history: VaultHistoryPoint[];
}
export interface RewardApy {
assetAddress: string;
rewardApy: Decimal;
type: string;
}
export interface MarketHistoryPoint {
blockNumber: number;
timestamp: string;
supplyApy: Decimal | null;
supplyBaseApy: Decimal | null;
supplyRewards: RewardApy[];
borrowApy: Decimal | null;
borrowBaseApy: Decimal | null;
borrowRewards: RewardApy[];
totalSupply: Decimal | null;
totalBorrow: Decimal | null;
}
export interface MarketHistoryResponse {
points: MarketHistoryPoint[];
}
/** A missing object. The API answers with an S3 XML body, not JSON. */
export class IporNotFoundError extends Error {
constructor(readonly path: string) {
super(`No data at ${path}`);
this.name = 'IporNotFoundError';
}
}
async function getJson<T>(path: string, signal?: AbortSignal): Promise<T> {
const response = await fetch(`${BASE_URL}${path}`, { signal });
// Unknown vault, unsupported period, or a non-lowercase address all land here.
if (response.status === 404) throw new IporNotFoundError(path);
if (!response.ok) {
throw new Error(`GET ${path} failed: ${response.status}`);
}
// Guard against XML slipping through on a non-404 error status.
const contentType = response.headers.get('content-type') ?? '';
if (!contentType.includes('application/json')) {
const body = await response.text();
throw new Error(`GET ${path} returned ${contentType}: ${body.slice(0, 200)}`);
}
return (await response.json()) as T;
}
/** S3 keys are case-sensitive; `plasma-vaults-list` returns mixed-case addresses. */
const normalizeAddress = (address: string): string => address.toLowerCase();
/**
* Concatenate buckets and sort by blockNumber ascending, retaining the LAST
* occurrence of any repeated blockNumber. Callers pass buckets least-current
* first, so an overlapping point is taken from the most current bucket
* containing it.
*/
function mergeByBlockNumber<T extends { blockNumber: number }>(
buckets: T[][],
): T[] {
const byBlock = new Map<number, T>();
for (const bucket of buckets) {
for (const point of bucket) byBlock.set(point.blockNumber, point);
}
return [...byBlock.values()].sort((a, b) => a.blockNumber - b.blockNumber);
}
/**
* Fetch every bucket for `period` and merge. Buckets are fetched concurrently;
* a missing one is tolerated as long as at least one resolves, since not every
* vault has a full `1y`/`all` history.
*/
async function fetchMerged<
TPayload extends object,
T extends { blockNumber: number },
>(
period: Period,
buildPath: (bucket: Period) => string,
extract: (payload: Awaited<TPayload>) => T[],
signal?: AbortSignal,
): Promise<T[]> {
const settled = await Promise.allSettled(
BUCKETS_TO_MERGE[period].map((bucket) =>
getJson<TPayload>(buildPath(bucket), signal),
),
);
const rejection = settled.find(
(r): r is PromiseRejectedResult =>
r.status === 'rejected' && !(r.reason instanceof IporNotFoundError),
);
if (rejection) throw rejection.reason;
const resolved = settled
.filter(
(r): r is PromiseFulfilledResult<Awaited<TPayload>> =>
r.status === 'fulfilled',
)
.map((r) => extract(r.value));
if (resolved.length === 0) throw new IporNotFoundError(buildPath(period));
return mergeByBlockNumber(resolved);
}
/**
* Vault APY / TVL / per-market allocation over time, hourly.
*
* The returned series is current to within ~1h regardless of `period`, because
* the stale tail of the long buckets is backfilled from the shorter buckets.
*/
export function fetchVaultHistory(args: {
chainId: number;
vaultAddress: string;
period: Period;
signal?: AbortSignal;
}): Promise<VaultHistoryPoint[]> {
const address = normalizeAddress(args.vaultAddress);
return fetchMerged<VaultHistoryResponse, VaultHistoryPoint>(
args.period,
(bucket) => `/v2/fusion/vault-history/${args.chainId}/${address}-${bucket}`,
(payload) => payload.history,
args.signal,
);
}
/**
* Supply/borrow APY and size for one market, hourly.
*/
export function fetchMarketHistory(args: {
chainId: number;
protocol: string;
substrate: string;
period: Period;
signal?: AbortSignal;
}): Promise<MarketHistoryPoint[]> {
const substrate = normalizeAddress(args.substrate);
return fetchMerged<MarketHistoryResponse, MarketHistoryPoint>(
args.period,
(bucket) =>
`/v2/fusion/market-history/${args.chainId}/${args.protocol}/${substrate}-${bucket}`,
(payload) => payload.points,
args.signal,
);
}
export interface PlasmaVaultListItem {
chainId: number;
/** Mixed-case as returned; lowercase before using it in a history path. */
address: string;
name: string;
apr: Decimal | null;
totalAssets: Decimal;
assetAddress: string;
assetSymbol: string;
assetDecimals: number;
marketIds: string[];
}
export interface PlasmaVaultsList {
timestamp: string;
version: string;
plasmaVaults: PlasmaVaultListItem[];
}
/** ~100 KB. The front end's short vault list; `/v2/fusion/vaults` has every vault. */
export function fetchPlasmaVaultsList(
signal?: AbortSignal,
): Promise<PlasmaVaultsList> {
return getJson<PlasmaVaultsList>('/dapp/plasma-vaults-list', signal);
}
Usage
// Full lifetime APY series, current to within the hour.
const history = await fetchVaultHistory({
chainId: 1,
vaultAddress: '0x43Ee0243eA8CF02f7087d8B16C8D2007cC9c7cA2', // casing handled
period: 'all',
});
const series = history
.filter((p) => p.apy !== null)
.map((p) => ({ t: Date.parse(p.blockTimestamp), apy: Number(p.apy) }));
// Underlying market rates.
const aaveUsdc = await fetchMarketHistory({
chainId: 1,
protocol: 'aave-v3',
substrate: '0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48',
period: '30d',
});
Data accuracy and corrections
Vault APY figures are not guaranteed to be exact. Two causes account for most discrepancies:
- Strategy changes. A vault's strategy can be reconfigured over its lifetime. Historical points computed under a previous configuration will not always be directly comparable with later ones.
- Indexing bugs. The pipeline that computes APY, TVL, and market balances is complex, and defects occur from time to time.
Corrections are applied retroactively. When a bug is identified, the affected history is recomputed and the underlying S3 objects are rewritten. A series you fetched previously may therefore differ from the same series fetched later, and the corrected values are the authoritative ones. If you cache history, treat etag and last-modified as the signal to re-read rather than assuming historical points are immutable. This applies to the long buckets as well: an all object that has not gained new points may still have been rewritten to fix older ones.
Please report anything that looks wrong. If you observe an APY, TVL, or allocation figure that does not match your own calculation or on-chain observation, report it in the IPOR Discord:
https://discord.com/invite/bSKzq6UMJ3 (opens in new tab)
Including the vault address, chain id, period, and the affected timestamps or block numbers will make the issue substantially faster to diagnose. Reports from integrators are a meaningful source of the corrections described above.
Known limitations
1y and all are stale by design. Described in Period buckets and freshness. Without merging, a chart will terminate weeks before the present.
APY values may be revised. Historical points are not immutable; see Data accuracy and corrections.
Overlapping buckets contain duplicate blockNumber values. Deduplicate during the merge. Note that the IPOR frontend's own implementation does not deduplicate. This has no visible effect on a line chart but produces incorrect results for any aggregate computation, so that implementation should not be treated as a reference for numeric work.
Addresses are case-sensitive in paths but returned checksummed in payloads. Addresses obtained from the vault list will return 404 if used without normalisation.
404 responses return XML. Check response.status before parsing the body.
No pagination, filtering, or date-range selection. Buckets are retrieved in full. There is no mechanism to request a value between two arbitrary dates; fetch the enclosing bucket and slice the series client-side.
No published SLA or changelog. This API serves app.ipor.io. It is public and stable in practice, but response shapes may acquire additional fields. Parse defensively: ignore unknown fields, and treat every documented field other than blockNumber, blockTimestamp/timestamp, tvl, and totalBalance as potentially null.
Provenance
Endpoint inventory derived from the IPOR webapp at origin/develop commit 6a734ab9d — principally src/config/constants.ts, src/fusion/api/vault-history/, src/fusion/api/markets/, and src/fusion/pages/list/list/usePlasmaVaultsList.ts. All shapes, sizes, status codes, freshness figures, and the merge behaviour were verified against the live API on 2026-07-09. The v2 list endpoints, field meanings, payload sizes and chain table were re-verified on 2026-10-21 together with the OpenAPI description.
Figures in the freshness and size tables are point measurements against vault 0x43ee0243ea8cf02f7087d8b16c8d2007cc9c7ca2 (Ethereum) and the aave-v3 / compound-v3 USDC markets. They characterise the regeneration cadence; they are not guarantees.
Counts that move — how many vaults, markets, protocols, or chains exist — are deliberately omitted from this document. Query them from /v2/fusion/vaults and /v2/fusion/markets instead.