Docs

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

TopicGraphQLREST
RequestOne POST /graphql request with a queryOne GET request per route
Base URLhttps://api.morpho.org/graphqlhttps://api.morpho.org (public) or https://private.api.morpho.org (with an API key)
Vault, token and oracle IDsaddress and chainId argumentsOne {chainId}:{address} selector in the path, for example 4663:0xBeEf...09dd
Blue market IDsmarketId and chainId{chainId}:{marketId} selector in the path
Field namescamelCase (totalAssets)snake_case under data (data.total_assets)
Fees and ratesFractions (0.1 for 10%)Raw values with a suffix: _wad is scaled by 10^18, _ray by 10^27
APYsInstant 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 fieldsNot returned: multiply amounts by the price from Get token price
Paginationfirst and skiplimit 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 fieldREST route
vaultV2ByAddressGet 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, vaultsList V2 vaults lists listed Vault V2 instances
List V1 vaults lists listed Vault V1 instances and Vault V1 instances referenced by Vault V2 adapters
marketByIdGet market
Get market state
marketsList 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, capsGet V2 vault allocations: combine data.allocations[].caps[] and data.unscoped_caps[]; caps targeting inactive adapters are omitted
Get 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
vaultV2transactionsUser V2 vault position activity for one user in one vault
vaultV1TransactionsUser V1 vault position activity for one user in one vault
sharePriceGet V2 vault state: divide share_price_ray by 10^27
forceDeallocatableLiquidityGet 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
reallocatableLiquidityAssetsGet 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, priceUsdGet 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.

lookbackResult rangeSample frequencyTrailing APY averaging windowRewards
six_hoursLast six hoursHourlySix hoursExcluded
one_dayLast dayHourlyOne dayExcluded
seven_daysLast seven daysHourlyOne dayExcluded
thirty_days (default)Last thirty daysDailySeven daysExcluded
ninety_daysLast ninety daysDailyThirty daysExcluded
one_yearLast yearWeeklyThirty daysExcluded
inceptionAll available historySelected by age: hourly, daily or monthlyThirty daysExcluded

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 fieldREST fieldWhat to change
address, name, asset { ... }data.address, data.name, data.assetRead under data
chain { id }data.chain_idRename
symboldata.symbolREST returns null when no symbol is set, where GraphQL returned ""
performanceFeedata.performance_fee_wadDivide by 10^18: 100000000000000000 would be 0.1 (10%). null means no fee, as for this vault
managementFeedata.management_fee_wadREST 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 fieldREST field
totalAssetsdata.total_assets
totalSupplydata.total_supply
liquiditydata.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 * price

When 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_hours to keep the same window.
  • Other lookback values: one_hour, one_day, seven_days, thirty_days, ninety_days, one_year, inception.
  • apy can be null when 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.address and collateralAsset.address become loan_token and collateral_token. For a token's symbol and decimals, call Get token.
  • oracleAddress and irmAddress become oracle_address and irm_address.
  • lltv becomes lltv_wad: divide by 10^18, so 860000000000000000 is 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.supplyAssets and state.borrowAssets become total_supply_assets and total_borrow_assets, in the loan token's smallest unit.
  • state.liquidityAssets: subtract total_borrow_assets from total_supply_assets.
  • state.utilization: divide total_borrow_assets by total_supply_assets.
  • state.fee becomes fee_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.supplyApy and state.borrowApy were instant rates. REST has no instant rate: it returns averages over past windows, which is a different metric. Pick a short window such as 6h and label it as an average.
  • APYs are fractions (0.0493 is 4.93%) and exclude rewards.
  • A window is null when 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_token is the token lent and borrowed, and maturity is the Unix timestamp in seconds when the market matures.
  • Each entry of collaterals gives a collateral token, its lltv (scaled by 10^18) and its oracle. One market can accept several collaterals.
  • enter_gate is 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_units is 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_wad is the settlement fee that applies now, and continuous_fee_rate is 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 }
    ]
  }
}
  • asks are the offers a lender can take, and bids the offers a borrower can take. price is the price of one unit, scaled by 10^18.
  • To turn a price into an annual rate, with TTM the seconds left to maturity: (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 * collateralPrice

Keep 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=MORPHOSUPPLY and the first 20 bytes of the market ID as identifier (its first 42 characters, including 0x).

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 < endTimestamp and apr > 0, group them by reward token, and sum their apr. Merkl's apr is a percentage, so divide by 100 to get GraphQL's supplyApr fraction.
  • 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.apy from 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:

lookbackgrainexpectedBuckets
six_hoursHOUR6
one_dayHOUR24
seven_daysHOUR168

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.