GraphQL to REST Migration
Move a Morpho GraphQL API integration to the Morpho REST API: what changes, which REST route replaces each common query, and field-by-field examples for a Vault V2, a variable rate market and a fixed rate market.
REST gives each resource its own route with a fixed response shape, documented field by field on the Morpho API page. Requests are plain GET calls that work from any HTTP client, with no GraphQL client or query to maintain.
The docs now cover REST only. This page helps you move an existing GraphQL integration to REST.
What changes
| Topic | GraphQL | REST |
|---|---|---|
| Request | One POST /graphql request with a query | One GET request per route |
| Base URL | https://api.morpho.org/graphql | https://api.morpho.org (public) or https://private.api.morpho.org (with an API key) |
| Vault, token and oracle IDs | address and chainId arguments | One {chainId}:{address} selector in the path, for example 4663:0xBeEf...09dd |
| Blue market IDs | marketId and chainId | {chainId}:{marketId} selector in the path |
| Field names | camelCase (totalAssets) | snake_case under data (data.total_assets) |
| Fees and rates | Fractions (0.1 for 10%) | Raw values with a suffix: _wad is scaled by 10^18, _ray by 10^27 |
| APYs | Instant rates (netApy, supplyApy, borrowApy) and averages over a window you choose (avgNetApy) | Realized averages over fixed lookback windows only, with no instant rate. Merkl rewards are excluded from Morpho's REST endpoints and can be fetched via Merkl's REST API. |
| USD values | ...Usd fields | Not returned: multiply amounts by the price from Get token price |
| Pagination | first and skip | limit and cursor, where a route supports them |
In both APIs, token amounts are integer strings in the token's smallest unit, and in REST rates carry their scale in the field name (_wad, _ray), so values keep full precision. Parse integer strings as bigint and scale them by the token's decimals (for example with viem's formatUnits) before converting them to a float.
List routes return data and a cursor. While cursor isn't null, repeat the request with the same filters, sort and cursor=<CURSOR>. Don't build or edit a cursor: it isn't an offset. Replace page counts with data.length; walk every page to calculate a total instead of countTotal. The maximum limit depends on the route, for example 1,000 for List markets and 100 for position lists. Unknown query parameters are ignored without an error, so check parameter names against the API reference.
Find the REST route for a GraphQL query
| GraphQL query or field | REST route |
|---|---|
vaultV2ByAddress | Get V2 vault for configuration and fees Get V2 vault state for live state Get V2 vault realized average APY for average APY |
vaultByAddress (Vault V1, legacy) | Get V1 vault Get V1 vault state |
vaultByAddress (unlisted Vault V1) | To read an unlisted Vault V1 by address, use Get Vault V1 |
Vault.state.curators, VaultV2.curators (curator names per vault) | Take curator from Get Vault V2 or Get Vault V1, then call Get curator details with {chainId}:{curator}. Skip the call when curator is null. Read data.curators[].name, logo_uri (GraphQL image) and socials; handle zero or multiple registry profiles. These are registry matches, not GraphQL's per-vault attribution |
vaultV2s, vaults | List V2 vaults lists listed Vault V2 instances List V1 vaults lists listed Vault V1 instances and Vault V1 instances referenced by Vault V2 adapters |
marketById | Get market Get market state |
markets | List markets: includes every indexed market unless you pass listed=true. To find the chain of bare market IDs, filter by market_id (up to 100 IDs per call) and keep each row's chain_id |
userByAddress (vault positions) | List user V2 vault positions List user V1 vault positions |
marketPositions, userByAddress (market positions) | List user Blue positions for all of a wallet's market positions User market position for one known market |
adapters, allocation, caps | Get V2 vault allocations: combine data.allocations[].caps[] and data.unscoped_caps[]; caps targeting inactive adapters are omittedGet V1 vault allocations |
historicalState (weeklyNetApy, monthlyNetApy, quarterlyNetApy, allTimeNetApy) | Get V2 vault APY history Get V1 vault APY history. lookback selects the history range, not the APY averaging window: seven_days returns 1-day averages, thirty_days returns 7-day averages, and ninety_days and inception return 30-day averages. Use thirty_days for weekly averages and ninety_days or inception for monthly averages. These routes do not return quarterly or all-time averaging windows |
vaultV2PositionByAddress (pnl) | User V2 vault position performance, where pnl becomes net_earnings_assets |
vaultV2transactions | User V2 vault position activity for one user in one vault |
vaultV1Transactions | User V1 vault position activity for one user in one vault |
sharePrice | Get V2 vault state: divide share_price_ray by 10^27 |
forceDeallocatableLiquidity | Get V2 vault withdrawal options: read adapter_penalties[].force_deallocatable_assets with each adapter's penalty_rate_wad. For a market-type liquidity adapter, its entry excludes the designated liquidity market but includes its other markets, so do not exclude the whole adapter. Filter to penalty_rate_wad == 0 only if you want zero-penalty capacity; the current GraphQL field can include penalized capacity. Per-adapter capacities are not a guaranteed executable maximum or an exact GraphQL-equivalent total |
reallocatableLiquidityAssets | Get market liquidity: reallocatable_liquidity counts Public Allocator capacity from Vault V2 vaults only. Vault V1 capacity, which GraphQL counted, isn't included, so the value can be lower |
state { collateralAssets } (markets) | Get market state history: total_collateral_assets of the latest point, in the collateral token's smallest unit |
historicalPriceUsd, priceUsd | Get token price for the current price. Use a historical price source for USD history |
Market position routes return shares, not assets. To get a position's supplied assets, read the market totals from Get market state and use integer division, which rounds down as the Morpho contracts do:
supplyAssets = supply_shares * (total_supply_assets + 1) / (total_supply_shares + 1000000)List user Blue positions returns active positions only by default. Pass active_only=false to also get positions that are now empty. It takes one wallet per call, so call it once per wallet and keep the wallet address with each row. The vault position lists also take active_only=false, which keeps the vaults a user has fully exited, for example to rebuild a user's transactions across vaults.
For other reads, see Find the REST endpoint for a task.
Read APY history data
For Vault V1 APY history, Vault V2 APY history and variable rate market APY history, lookback selects the chart range and determines each point's trailing averaging window. A seven-day chart does not contain seven-day averages.
Sample frequency is how often the API returns a point. Trailing APY averaging window is how far back each point measures the return. For six_hours, points are sampled hourly, but each point measures the preceding six hours.
For current averages, vault routes accept lookback; market APY averages instead returns several fixed windows in one response, without a lookback parameter.
lookback | Result range | Sample frequency | Trailing APY averaging window | Rewards |
|---|---|---|---|---|
six_hours | Last six hours | Hourly | Six hours | Excluded |
one_day | Last day | Hourly | One day | Excluded |
seven_days | Last seven days | Hourly | One day | Excluded |
thirty_days (default) | Last thirty days | Daily | Seven days | Excluded |
ninety_days | Last ninety days | Daily | Thirty days | Excluded |
one_year | Last year | Weekly | Thirty days | Excluded |
inception | All available history | Selected by age: hourly, daily or monthly | Thirty days | Excluded |
Sampling is selected by the API, not a request parameter. Read each returned timestamp; do not assume a complete, equally spaced series. Ranges start no earlier than available history. For vaults, early points can use shorter windows or be omitted, and points with a near-empty window endpoint are omitted; see the Vault V1 history rules and Vault V2 history rules. Market history omits points without enough data for the derived window.
To preserve a seven-day chart range, use lookback=seven_days, but label its points as trailing one-day APY excluding rewards. To obtain seven-day averages, use thirty_days; for thirty-day averages, use ninety_days or inception. Quarterly and all-time averaging windows are not available in these history routes. A ninety-day or inception chart is only a chart-range substitute, not an equivalent quarterly or all-time APY metric. REST has no custom startTimestamp, endTimestamp, interval or lookbackHours parameter here.
Example: Vault V2 data
This example moves a GraphQL vaultV2ByAddress query that reads a vault's identity, fees, total assets, liquidity, USD values and APY. It uses the Steakhouse USDG vault on Robinhood Chain (4663:0xBeEff033F34C046626B8D0A041844C5d1A5409dd). Values are illustrative.
Read the vault's identity, asset and fees
Call Get Vault V2:
curl -sS "https://api.morpho.org/v0/vaults-v2/4663:0xBeEff033F34C046626B8D0A041844C5d1A5409dd"{
"data": {
"chain_id": 4663,
"address": "0xBeEff033F34C046626B8D0A041844C5d1A5409dd",
"name": "Steakhouse USDG",
"symbol": "steakUSDG",
"asset": {
"address": "0x5fc5360D0400a0Fd4f2af552ADD042D716F1d168",
"decimals": 6,
"name": "Global Dollar",
"symbol": "USDG"
},
"management_fee_wad": null,
"performance_fee_wad": null
}
}| GraphQL field | REST field | What to change |
|---|---|---|
address, name, asset { ... } | data.address, data.name, data.asset | Read under data |
chain { id } | data.chain_id | Rename |
symbol | data.symbol | REST returns null when no symbol is set, where GraphQL returned "" |
performanceFee | data.performance_fee_wad | Divide by 10^18: 100000000000000000 would be 0.1 (10%). null means no fee, as for this vault |
managementFee | data.management_fee_wad | REST returns a per-second rate. Multiply management_fee_wad / 10^18 by 31,536,000 (seconds in a 365-day year) to get GraphQL's annual fraction. null means no fee |
For token logos and metadata, join data.asset.address to Get token using the same chain_id. Cache token metadata by {chainId}:{address}, including decimals and logo_uri. Reuse this join for allocated-market loan and collateral tokens. Keep prices in a separate, shorter-lived cache and check their timestamps; metadata caching does not make a price current.
Read total assets, supply and liquidity
Call Get Vault V2 state:
curl -sS "https://api.morpho.org/v1/vaults-v2/4663:0xBeEff033F34C046626B8D0A041844C5d1A5409dd/state"{
"data": {
"total_assets": "514998453815401",
"total_supply": "508725850563559769395883605",
"withdrawable_assets": "41017793666055",
"idle_assets": "4644670992879"
}
}| GraphQL field | REST field |
|---|---|
totalAssets | data.total_assets |
totalSupply | data.total_supply |
liquidity | data.withdrawable_assets (idle assets plus assets available through the liquidity adapter) |
To inspect additional withdrawal capacity, call Get Vault V2 withdrawal options. withdrawable_assets covers plain withdrawals. Each adapter_penalties entry gives force-deallocation capacity and its penalty_rate_wad. A market-type liquidity adapter's entry leaves out its designated liquidity market, not its other markets. Keep those other markets when estimating additional capacity, and filter out nonzero penalties only for a zero-penalty display. Adding all entries is not a guaranteed maximum withdrawal: adapters can share underlying liquidity, and execution must account for penalties and current state. To support these withdrawals, see Support forceDeallocate withdrawals.
Calculate USD values
Calculate totalAssetsUsd and liquidityUsd using Get token price with the asset address from the first step:
curl -sS "https://api.morpho.org/v0/tokens/4663:0x5fc5360D0400a0Fd4f2af552ADD042D716F1d168/price"{
"data": {
"price": 0.9997634072500001,
"timestamp": "1793783685",
"provider": "defillama"
}
}totalAssetsUsd = total_assets / 10^decimals * price
liquidityUsd = withdrawable_assets / 10^decimals * priceWhen price is null, return null for the derived USD value. timestamp and provider can still be populated; they do not make a null price usable. timestamp is when the price was recorded, so you can use it for a staleness check.
Read the average APY
Replace avgNetApyExcludingRewards with data.apy from Get Vault V2 realized average APY:
curl -sS "https://api.morpho.org/v1/vaults-v2/4663:0xBeEff033F34C046626B8D0A041844C5d1A5409dd/apy-averages?lookback=six_hours"{ "data": { "lookback": "six_hours", "apy": 0.04083787931963583 } }- GraphQL averaged over six hours by default. Pass
lookback=six_hoursto keep the same window. - Other
lookbackvalues:one_hour,one_day,seven_days,thirty_days,ninety_days,one_year,inception. apycan benullwhen observations are missing or cannot form a meaningful return, including near-empty vaults.
Example: Variable rate market data
This example moves a GraphQL marketById query that reads a Morpho Blue market's configuration, totals, liquidity and APYs. It uses the USDC / cbBTC market on Base (8453:0x9103c3b4e834476c9a62ea009ba2c884ee42e94e6e314a26f04d312434191836). Values are illustrative.
Read the market's tokens, oracle and LLTV
Call Get market:
curl -sS "https://api.morpho.org/v0/blue/markets/8453:0x9103c3b4e834476c9a62ea009ba2c884ee42e94e6e314a26f04d312434191836"{
"data": {
"chain_id": 8453,
"market_id": "0x9103c3b4e834476c9a62ea009ba2c884ee42e94e6e314a26f04d312434191836",
"loan_token": "0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913",
"collateral_token": "0xcbB7C0000aB88B473b1f5aFd9ef808440eed33Bf",
"oracle_address": "0x663BECd10daE6C4A3Dcd89F1d76c1174199639B9",
"irm_address": "0x46415998764C29aB2a25CbeA6254146D50D22687",
"lltv_wad": "860000000000000000"
}
}loanAsset.addressandcollateralAsset.addressbecomeloan_tokenandcollateral_token. For a token's symbol and decimals, call Get token.oracleAddressandirmAddressbecomeoracle_addressandirm_address.lltvbecomeslltv_wad: divide by 10^18, so860000000000000000is 86%.
Read the market's totals and liquidity
Call Get market state:
curl -sS "https://api.morpho.org/v0/blue/markets/8453:0x9103c3b4e834476c9a62ea009ba2c884ee42e94e6e314a26f04d312434191836/state"{
"data": {
"total_supply_assets": "1805645987796067",
"total_borrow_assets": "1623083435464591",
"fee_wad": "0"
}
}state.supplyAssetsandstate.borrowAssetsbecometotal_supply_assetsandtotal_borrow_assets, in the loan token's smallest unit.state.liquidityAssets: subtracttotal_borrow_assetsfromtotal_supply_assets.state.utilization: dividetotal_borrow_assetsbytotal_supply_assets.state.feebecomesfee_wad: divide by 10^18.- For USD values, multiply by the loan token price from Get token price, as in the Vault V2 example.
Read the market's supply and borrow APY
Call Get market APY averages:
curl -sS "https://api.morpho.org/v0/blue/markets/8453:0x9103c3b4e834476c9a62ea009ba2c884ee42e94e6e314a26f04d312434191836/apy-averages"{
"data": {
"supply_apy_averages": { "6h": 0.0493, "24h": 0.0509, "7d": 0.0608, "30d": 0.0498, "90d": 0.0456, "1y": 0.046 },
"borrow_apy_averages": { "6h": 0.055, "24h": 0.0567, "7d": 0.0671, "30d": 0.0553, "90d": 0.0509, "1y": 0.052 }
}
}state.supplyApyandstate.borrowApywere instant rates. REST has no instant rate: it returns averages over past windows, which is a different metric. Pick a short window such as6hand label it as an average.- APYs are fractions (
0.0493is 4.93%) and exclude rewards. - A window is
nullwhen the market has no history for it.
Example: Fixed rate market data
Fixed rate markets are Morpho Midnight markets, read through the routes under /v0/midnight. A Midnight market ID is unique across chains, so the path takes the market ID alone, without a chain ID. This example reads a USDC market on Ethereum (0xcf447db8cff164867b02edbf7ee7af06d241d4f3a44b34d6e3a3ea872ddea5d6). Values are illustrative. For every Midnight route, see Morpho Midnight.
Read the market's maturity and collaterals
Call Get market:
curl -sS "https://api.morpho.org/v0/midnight/markets/0xcf447db8cff164867b02edbf7ee7af06d241d4f3a44b34d6e3a3ea872ddea5d6"{
"data": {
"chain_id": 1,
"market_id": "0xcf447db8cff164867b02edbf7ee7af06d241d4f3a44b34d6e3a3ea872ddea5d6",
"loan_token": "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48",
"collaterals": [
{ "token": "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", "lltv": "980000000000000000", "oracle": "0x8d1A84515B54C58bAc3b18315B6b1f17dA5cf6ca" },
{ "token": "0xcbB7C0000aB88B473b1f5aFd9ef808440eed33Bf", "lltv": "860000000000000000", "oracle": "0xA6D6950c9F177F1De7f7757FB33539e3Ec60182a" }
],
"maturity": 1798210800,
"enter_gate": "0x0000000000000000000000000000000000000000"
}
}loan_tokenis the token lent and borrowed, andmaturityis the Unix timestamp in seconds when the market matures.- Each entry of
collateralsgives a collateraltoken, itslltv(scaled by 10^18) and itsoracle. One market can accept several collaterals. enter_gateis the zero address when the market has no entry gate.
Read the market's units and fees
Call Get market state:
curl -sS "https://api.morpho.org/v0/midnight/markets/0xcf447db8cff164867b02edbf7ee7af06d241d4f3a44b34d6e3a3ea872ddea5d6/state"{
"data": {
"total_units": "25855265277861",
"current_settlement_fee_wad": "0",
"continuous_fee_rate": "0"
}
}total_unitsis the total of credit units recorded in the market, in the loan token's smallest unit. Each credit unit is a claim to receive one loan token at maturity.current_settlement_fee_wadis the settlement fee that applies now, andcontinuous_fee_rateis the per-second continuous fee, both scaled by 10^18.
Read the market's fixed rates
Call Get book, which returns the best offers on each side:
curl -sS "https://api.morpho.org/v0/midnight/books/0xcf447db8cff164867b02edbf7ee7af06d241d4f3a44b34d6e3a3ea872ddea5d6"{
"data": {
"maturity": 1798210800,
"asks": [],
"bids": [
{ "tick": 4396, "price": "993982800000000000", "units": "24136544738255", "assets": "23991310321255", "count": 1 }
]
}
}asksare the offers a lender can take, andbidsthe offers a borrower can take.priceis the price of one unit, scaled by 10^18.- To turn a price into an annual rate, with
TTMthe seconds left tomaturity:(1 / (price / 1e18) - 1) × 365 × 86400 / TTM. With 52 days to maturity, the bid above gives a borrow rate of about 4.25%. - An empty side has no offer to take at the moment. To price a given amount, call Get quote.
Other examples
Example: Vault V1 profile
Replace a legacy vaultByAddress profile with these requests. Here VAULT is a {chainId}:{address} selector:
VAULT="1:0xBEEF01735c132Ada46AA9aA4c54623cAA92A64CB"
curl -sS "https://api.morpho.org/v0/vaults-v1/$VAULT"
curl -sS "https://api.morpho.org/v0/vaults-v1/$VAULT/state"
curl -sS "https://api.morpho.org/v0/vaults-v1/$VAULT/allocations"
curl -sS "https://api.morpho.org/v0/vaults-v1/$VAULT/apy-averages?lookback=seven_days"
curl -sS "https://api.morpho.org/v0/vaults-v1/$VAULT/apy/history?lookback=seven_days"Use detail data.name, symbol, asset and fee_wad for identity and the performance fee (fee_wad / 10^18). Use state total_assets, total_supply and withdrawable_assets for balances. Join the asset to token metadata and price as in the Vault V2 example; calculate USD values rather than expecting USD fields. Allocations supplies the per-market breakdown. If curator is present, Get curator details returns registry profiles for {chainId}:{curator}, not necessarily the same vault attribution as GraphQL.
The averages request returns a seven-day realized average; the history request returns a seven-day chart of one-day averages. Both exclude rewards. Apply the direct-vault Merkl recipe separately if needed. Vault metadata.description has no REST source.
Example: Market IDs to chain and USD balances
A market ID alone is not a cross-chain selector. Resolve it with List markets, retaining every matching chain_id. The filter accepts up to 100 comma-separated IDs; follow every cursor page before concluding that an ID was not found.
curl -sS "https://api.morpho.org/v0/blue/markets?market_id=$MARKET_ID&limit=1000"
# For each returned chain_id and market_id:
curl -sS "https://api.morpho.org/v0/blue/markets/$CHAIN_ID:$MARKET_ID/liquidity"
curl -sS "https://api.morpho.org/v0/blue/markets/$CHAIN_ID:$MARKET_ID/state/history?lookback=one_day"List rows identify loan_token and collateral_token. Join each nonzero token address to /v0/tokens/{chainId}:{address} and /v0/tokens/{chainId}:{address}/price. Use data.market_liquidity from the liquidity response for available loan-token assets. For collateral, take total_collateral_assets from the history point with the greatest timestamp, not from current market state, which has no collateral total.
liquidityUsd = market_liquidity / 10^loanDecimals * loanPrice
collateralUsd = total_collateral_assets / 10^collateralDecimals * collateralPriceKeep null when a required amount or price is null, or no history point exists. The latest history point can lag current state. A current price applied to that point is a current-price estimate of a sampled balance, not historical USD value. reallocatable_liquidity is separate Public Allocator capacity and covers Vault V2 contributors only; do not treat it as immediately available market liquidity.
Example: Supply positions for several wallets
REST has no multi-wallet position query. Repeat List user Blue positions for each wallet, then follow that wallet's cursor independently:
curl -sS "https://api.morpho.org/v0/blue/users/$USER_ADDRESS/positions?chain_ids=1,8453&active_only=true&limit=100"
curl -sS "https://api.morpho.org/v0/blue/markets/$CHAIN_ID:$MARKET_ID/state"Rows contain chain_id, market_id, supply_shares, borrow_shares and collateral_assets. Retain rows with positive supply_shares for a supply-only view. active_only=true excludes exited positions; use false if you need those rows. Cache market configuration and loan-token metadata across wallets, but refresh state for valuation.
Convert Blue supply accounting units with integer arithmetic, not the token-decimal conversion alone:
const suppliedAssets =
(BigInt(position.supply_shares) * (BigInt(state.total_supply_assets) + 1n)) /
(BigInt(state.total_supply_shares) + 1_000_000n)The 1 virtual asset and 1_000_000 virtual accounting units avoid a zero denominator; integer division rounds down. Scale suppliedAssets with the loan token's decimals, then multiply by its non-null price for USD. This is an estimate at the state's accrual time, not a block-atomic balance across wallets. Missing state or price means unknown valuation, not zero. There is no global position list or direct instantaneous net supply APY replacement.
Example: Reconstruct a partial user activity feed
Discover the wallet's positions in Vault V1 and Vault V2 with active_only=false, following all pages. For each discovered vault, request its per-user activity:
curl -sS "https://api.morpho.org/v0/vaults-v1/users/$USER_ADDRESS/positions?active_only=false&limit=100"
curl -sS "https://api.morpho.org/v0/vaults-v2/users/$USER_ADDRESS/positions?active_only=false&limit=100"
curl -sS "https://api.morpho.org/v0/vaults-v1/$CHAIN_ID:$VAULT_ADDRESS/users/$USER_ADDRESS/position/activity?limit=100"
curl -sS "https://api.morpho.org/v0/vaults-v2/$CHAIN_ID:$VAULT_ADDRESS/users/$USER_ADDRESS/position/activity?limit=100"Follow each activity cursor, tag events with chain and vault, then merge by timestamp. Use (chain_id, tx_hash, log_index) to deduplicate the same event across overlapping requests. Vault V2 activity includes deposit, withdraw, shares_transfer_in and shares_transfer_out; transfers carry accounting units, not a deposit or withdrawal asset amount. Do not invent an asset amount for those events.
This feed is partial: position discovery is not a complete history of every vault a wallet ever touched. These routes do not reconstruct all GraphQL transactions, market actions, migrations or wrapper activity. An empty response is not proof of no historical activity; a failed page leaves the feed incomplete.
For position PnL, call the Vault V1 or Vault V2 /users/{user-address}/position/performance route instead of subtracting deposits from withdrawals. data.net_earnings_assets includes realized and unrealized earnings; data.return_on_capital is non-annualized. The API uses average_cost accounting with transfers netted into the balance, not necessarily GraphQL's accounting. Keep those semantics in the UI, and do not present the result as an exact historical USD PnL.
Calculate net APY (native APY + rewards)
Fetch Merkl rewards
Reward campaigns are served by Merkl's opportunities endpoint at https://api.merkl.xyz. Get an API key from Merkl and send it as the X-API-Key header.
Filter the endpoint by chainId, mainProtocolId=morpho and campaigns=true, plus:
- For a vault: the vault address as
identifier. - For a Morpho Blue market:
type=MORPHOSUPPLYand the first 20 bytes of the market ID asidentifier(its first 42 characters, including0x).
This recipe covers only campaigns targeting the vault or market directly (MORPHOSUPPLY for markets), so it can return a lower reward APR than GraphQL's total, which also counted other campaign types and rewards a Vault V2 receives through its allocations.
Combine with native APY
Choose the reward metric:
- Reward APR per token: keep the campaigns where
startTimestamp ≤ now < endTimestampandapr > 0, group them by reward token, and sum theirapr. Merkl'sapris a percentage, so divide by 100 to get GraphQL'ssupplyAprfraction. - APY including rewards: add the average reward APR over the same window, from Merkl's APR records, to the REST
apy(for a Vault V2,data.apyfrom Get Vault V2 realized average APY).
For each opportunity, concatenate all APR-record pages before calculating. Drop hasUntrustedContribution=true and weight the retained avg percentages equally by bucket, not by samples. Merkl percentages become fractions after division by 100; Morpho apy is already a fraction:
observedApr = sum(retained avg) / recordsKept
coverage = recordsKept / expectedBuckets
historicalRewardApr = observedApr * coverage / 100
netApyApproximation = nativeApy + sum(historicalRewardApr across opportunity IDs)Example script
Keep every matching opportunity ID for historical APY, even when its campaigns have ended. Filter live campaigns only for current per-token rewards. This Vault V2 example requires Bash, curl, jq and an exported MERKL_API_KEY. Choose the native APY lookback and matching hourly bucket count:
lookback | grain | expectedBuckets |
|---|---|---|
six_hours | HOUR | 6 |
one_day | HOUR | 24 |
seven_days | HOUR | 168 |
The example ends the reward window at the last complete hour. Use the same duration as native APY and align the cutoff as closely as available timestamps permit; separate API calls are not an atomic snapshot. It prints an APY fraction, or null (unknown) on failure or null native APY. Request failures also exit nonzero.
(
set -Eeuo pipefail
trap 'printf "null\n"; exit 1' ERR
test -n "${MERKL_API_KEY:-}"
chain_id=4663
vault=0xBeEff033F34C046626B8D0A041844C5d1A5409dd
lookback=${LOOKBACK:-six_hours}
case "$lookback" in
six_hours) expected_buckets=6 ;;
one_day) expected_buckets=24 ;;
seven_days) expected_buckets=168 ;;
*) printf 'Choose six_hours, one_day or seven_days\n' >&2; false ;;
esac
window_end=$(( $(date +%s) / 3600 * 3600 ))
window_start=$(( window_end - expected_buckets * 3600 ))
fetch_pages() {
local url=$1 items=$2 page=0 collected='[]' batch count
shift 2
while :; do
batch=$(curl -fsS --get "$url" \
-H "X-API-Key: $MERKL_API_KEY" \
--data-urlencode "items=$items" --data-urlencode "page=$page" "$@")
count=$(printf '%s' "$batch" | jq -er '
if type == "array" then length else error("Expected an array") end')
collected=$(jq -cn --argjson all "$collected" --argjson next "$batch" '$all + $next')
if [ "$count" -lt "$items" ]; then break; fi
page=$((page + 1))
done
printf '%s\n' "$collected"
}
native=$(curl -fsS \
"https://api.morpho.org/v1/vaults-v2/$chain_id:$vault/apy-averages?lookback=$lookback" |
jq -er '.data | if has("apy") and (.apy == null or (.apy | type == "number"))
then .apy | tojson else error("Invalid native APY") end')
if [ "$native" = null ]; then printf 'null\n'; exit 0; fi
opportunities=$(fetch_pages 'https://api.merkl.xyz/v4/opportunities' 100 \
--data-urlencode "chainId=$chain_id" \
--data-urlencode 'mainProtocolId=morpho' \
--data-urlencode "identifier=$vault" --data-urlencode 'campaigns=true')
ids=$(printf '%s' "$opportunities" | jq -ce --argjson chain "$chain_id" --arg vault "$vault" '
if all(.[]; .chainId == $chain and
((.identifier | ascii_downcase) == ($vault | ascii_downcase)))
then [.[].id | tostring] | unique
else error("Opportunity selector mismatch") end')
reward_apr=0
while IFS= read -r id; do
records=$(fetch_pages "https://api.merkl.xyz/v4/opportunities/$id/apr-records" 200 \
--data-urlencode "from=$window_start" --data-urlencode "to=$window_end" \
--data-urlencode 'grain=HOUR')
contribution=$(printf '%s' "$records" | jq -er --argjson expected "$expected_buckets" '
[.[] | select(.hasUntrustedContribution != true)] as $r
| if ($r | length) == 0 then 0 else
(($r | map(.avg) | add) / ($r | length))
* (($r | length) / $expected) / 100
end')
reward_apr=$(jq -n --argjson total "$reward_apr" --argjson next "$contribution" '$total + $next')
done < <(printf '%s' "$ids" | jq -r '.[]')
jq -n --argjson native "$native" --argjson reward "$reward_apr" '$native + $reward'
)Things to check
Within a complete successful lookup, missing or untrusted buckets contribute zero through coverage; no retained records means a zero contribution without division by zero. No matching opportunities means zero historical rewards, but failed requests, missing pages or selector mismatches make the result unknown. For current per-token rewards only, no live campaigns after a complete lookup means zero.
For history charts, repeat the calculation over each point's trailing averaging window, not the full chart range, and do not average Morpho's APY points again. Adding reward APR to native APY remains an approximation with narrower campaign coverage, not exact GraphQL parity or a promise of returns.
Don't use the opportunity's totalApr or nativeApr: they include Merkl's own estimate of the base yield.
Merkl returns APRs, not APYs, except for campaigns that use the UNCLAIMED_DISTRIBUTOR processor, whose apr already counts unclaimed rewards. For a user's claimable rewards from Merkl, see Fetch Rewards Data. For interface examples, see Integrate Rewards Display.