Browse all documentation

Funds API reference

Prices, NAV, history and distributions for ETFs and mutual funds

Overview

Prices, price history, distributions, profiles and search for ETFs and mutual funds. Every request needs a Bearer key, and REST and MCP share one account quota.

An ETF id is its listing id, such as ARCX:SPY. A mutual fund id is FUND, a colon and its ticker, such as FUND:VFIAX. A bare US ticker such as SPY also works when it names one US listing. A stock id on a fund route returns 404 not_found, and a fund id on a stock route moves here with a 301.

An ETF has a price. A mutual fund has a net asset value (NAV) once a day, in nav, previous_nav and date. The price answer has a type field that tells you which one you got.

Base URL: https://api.narwhalapi.com. The MCP tool is funds, with one action for each route below.

Plans and limits

WhatFreeBasicPro
ETF latest price15 minutes lateLiveLive
Mutual fund NAVLatest NAVLatest NAVLatest NAV
Daily bars and past distributionsLast 12 monthsLast 5 yearsFull history
One-minute bars (ETFs only)Not includedIncluded, within the plan's historyIncluded, within the plan's history
Search and profileIncludedIncludedIncluded

See pricing for plan prices and monthly request limits. Each request counts as one call unless a route says otherwise.

  • Free ETF prices are delayed by 15 minutes. A delayed answer carries delayed: true and leaves out the day volume.
  • A mutual fund NAV is an end-of-day value and is never delayed. It is stale once it is older than the next business day.
  • A mutual fund has no one-minute bars. Asking for them returns 400 invalid_interval.
  • A mutual fund's daily bar is its NAV: open, high, low and close are the same number, and there is no volume.
  • Basic reaches back 5 years and Pro has full history. When your plan cuts older rows, start comes back with the first date served.
  • kind on a distribution is left out until our sources give it.

Endpoints

Every route is a GET and needs your key in the Authorization: Bearer header. The examples show the shape of an answer, not live data.

Profile of one fund

GET /v1/funds/{id}

The fund's symbol, name, type (etf or mutual_fund), exchange (ETFs only), country and currency, as our symbol list holds them. Open to every plan. Fields that are not known are left out.

MCP: tool funds, action profile.

Parameters

ParameterWhereRequiredTypeMeaning
idpathYesstringFund id. An ETF is its listing id: the exchange's four-letter code, a colon and the ticker (ARCX:SPY). A mutual fund is FUND, a colon and its ticker (FUND:VFIAX). A bare US ticker such as SPY also works when it names one US listing; the answer carries the canonical id.

Response fields

FieldTypeSentMeaning
idstringAlwaysCanonical fund id.
symbolstringAlwaysThe fund's ticker.
namestringAlwaysName as our symbol list holds it.
typeone of etf, mutual_fundAlwaysWhat kind of instrument it is.
micstringOptionalThe exchange's four-letter code. Left out for a mutual fund (no exchange).
countrystringOptionalCountry of the fund, three letters.
currencystringAlwaysCurrency of the prices.
Request
curl -H "Authorization: Bearer $NARWHAL_API_KEY" \
  "https://api.narwhalapi.com/v1/funds/ARCX:SPY"
Example response with illustrative values
{
  "id": "ARCX:SPY",
  "symbol": "SPY",
  "name": "SPDR S&P 500 ETF Trust",
  "type": "etf",
  "mic": "ARCX",
  "country": "USA",
  "currency": "USD"
}

Latest price of one fund

GET /v1/funds/{id}/price

An ETF's last price, in the stock price shape plus type: etf (Free sees it 15 minutes late and the answer carries delayed: true), or a mutual fund's latest NAV (type: mutual_fund: nav, previous_nav, change, change_percent and the NAV's date; an end-of-day value, never delayed, stale once it is older than the next business day). change_percent is in percent. A stock id is 404 not_found.

MCP: tool funds, action price.

Parameters

ParameterWhereRequiredTypeMeaning
idpathYesstringFund id. An ETF is its listing id: the exchange's four-letter code, a colon and the ticker (ARCX:SPY). A mutual fund is FUND, a colon and its ticker (FUND:VFIAX). A bare US ticker such as SPY also works when it names one US listing; the answer carries the canonical id.

Response fields

FieldTypeSentMeaning
idstringAlwaysCanonical listing id.
symbolstringAlwaysTicker on that exchange.
currencystringAlwaysCurrency of every price field. Pence are converted to pounds.
pricestringOptionalLast price, in currency.
previous_closestringOptionalPrevious session's close. Left out when it is not known.
changestringOptionalprice minus previous_close. Left out with previous_close.
change_percentstringOptionalChange since previous_close, in percent (1.5 means 1.5%).
volumestringOptionalShares traded today. Left out when it is not known, and always left out on a delayed answer.
market_sessionone of pre, open, post, closedOptionalState of the listing's exchange at timestamp: pre, open, post or closed. Only US exchanges have pre and post.
timestampdate-timeOptionalWhen the price was valid (UTC, whole seconds).
stalebooleanAlwaysTrue when the price is older than 15 minutes while the exchange is open, or could not be refreshed. A closed market is never stale.
delayedalways trueOptionalPresent, as true, only when the price is 15 minutes late (Free plan).
typeone of etf, mutual_fundAlwaysetf for an ETF, mutual_fund for a mutual fund.
navstringOptionalNet asset value per share, in currency.
previous_navstringOptionalThe NAV of the day before. Left out when it is not known.
datedateOptionalThe day the NAV is for (YYYY-MM-DD).
Request
curl -H "Authorization: Bearer $NARWHAL_API_KEY" \
  "https://api.narwhalapi.com/v1/funds/ARCX:SPY/price"
Example response with illustrative values
{
  "id": "ARCX:SPY",
  "symbol": "SPY",
  "currency": "USD",
  "price": "578.12",
  "previous_close": "576.40",
  "change": "1.72",
  "change_percent": "0.30",
  "volume": "61230500",
  "market_session": "open",
  "timestamp": "2026-10-26T15:42:00Z",
  "stale": false,
  "type": "etf"
}

Latest prices for up to 50 funds

GET /v1/funds/prices

Latest price of each listed fund: an ETF's last price (Free sees it 15 minutes late) or a mutual fund's latest NAV. One call is counted for each id that returns a price, taken in one step: if fewer calls are left than ids that resolved, the whole request is refused with 429 quota_exceeded. Ids that return no price are listed in errors and cost nothing. change_percent is in percent.

Send up to 50 ids, ETFs and mutual funds together. A batch is charged in one step: if you have fewer calls left than ids that resolve, the whole request returns 429 quota_exceeded.

MCP: tool funds, action prices.

Parameters

ParameterWhereRequiredTypeMeaning
idsqueryYesstringComma-separated fund ids, at most 50 after duplicates are removed. Each id follows the same rules as the single-price route. One call is counted for each id that returns a price.

Response fields

FieldTypeSentMeaning
priceslist of objectAlwaysOne price per id that resolved, by id.
prices[].idstringAlwaysCanonical listing id.
prices[].symbolstringAlwaysTicker on that exchange.
prices[].currencystringAlwaysCurrency of every price field. Pence are converted to pounds.
prices[].pricestringOptionalLast price, in currency.
prices[].previous_closestringOptionalPrevious session's close. Left out when it is not known.
prices[].changestringOptionalprice minus previous_close. Left out with previous_close.
prices[].change_percentstringOptionalChange since previous_close, in percent (1.5 means 1.5%).
prices[].volumestringOptionalShares traded today. Left out when it is not known, and always left out on a delayed answer.
prices[].market_sessionone of pre, open, post, closedOptionalState of the listing's exchange at timestamp: pre, open, post or closed. Only US exchanges have pre and post.
prices[].timestampdate-timeOptionalWhen the price was valid (UTC, whole seconds).
prices[].stalebooleanAlwaysTrue when the price is older than 15 minutes while the exchange is open, or could not be refreshed. A closed market is never stale.
prices[].delayedalways trueOptionalPresent, as true, only when the price is 15 minutes late (Free plan).
prices[].typeone of etf, mutual_fundAlwaysetf for an ETF, mutual_fund for a mutual fund.
prices[].navstringOptionalNet asset value per share, in currency.
prices[].previous_navstringOptionalThe NAV of the day before. Left out when it is not known.
prices[].datedateOptionalThe day the NAV is for (YYYY-MM-DD).
errorslist of objectAlwaysIds that returned no price. They are not counted against the quota.
errors[].idstringAlwaysThe id as it was asked for.
errors[].codeone of not_found, not_covered, upstream_busyAlwaysnot_found: no such listing. not_covered: the exchange has no prices yet. upstream_busy: the price could not be fetched now; try again shortly.
Request
curl -H "Authorization: Bearer $NARWHAL_API_KEY" \
  "https://api.narwhalapi.com/v1/funds/prices?ids=ARCX:SPY,FUND:VFIAX"
Example response with illustrative values
{
  "prices": [
    {
      "id": "ARCX:SPY",
      "symbol": "SPY",
      "currency": "USD",
      "price": "578.12",
      "previous_close": "576.40",
      "change": "1.72",
      "change_percent": "0.30",
      "volume": "61230500",
      "market_session": "open",
      "timestamp": "2026-10-26T15:42:00Z",
      "stale": false,
      "type": "etf"
    },
    {
      "id": "FUND:VFIAX",
      "symbol": "VFIAX",
      "currency": "USD",
      "type": "mutual_fund",
      "nav": "551.07",
      "previous_nav": "549.80",
      "change": "1.27",
      "change_percent": "0.23",
      "date": "2026-10-23",
      "stale": false
    }
  ],
  "errors": [
    {
      "id": "FUND:NOPE",
      "code": "not_found"
    }
  ]
}

Price bars of one fund

GET /v1/funds/{id}/bars

Daily (1d) or one-minute (1m) bars, oldest first, in the stock bar shape. A mutual fund's daily bar is its daily NAV (open, high, low and close are the NAV, no volume); it has no one-minute bars (400 invalid_interval). One-minute bars are for ETFs on Basic and Pro (Free gets 403 plan_upgrade_required). History: Free 12 months, Basic 5 years, Pro everything; start is sent back when the plan raised it. Up to 5,000 bars a page, with next_cursor; a cursor that no longer matches the prices is 409 cursor_expired. One call a page.

If a split or distribution changes the history between two pages, the cursor stops working and returns 409 cursor_expired.

MCP: tool funds, action bars.

Parameters

ParameterWhereRequiredTypeMeaning
idpathYesstringFund id. An ETF is its listing id: the exchange's four-letter code, a colon and the ticker (ARCX:SPY). A mutual fund is FUND, a colon and its ticker (FUND:VFIAX). A bare US ticker such as SPY also works when it names one US listing; the answer carries the canonical id.
intervalqueryNoone of 1d, 1m1d (one bar per session; a mutual fund's bar is its daily NAV) or 1m (one-minute bars: ETFs only, Basic and Pro only). Anything else is 400 invalid_interval.
startqueryNostringFirst bar. 1d: a date (YYYY-MM-DD), inclusive; left out means the oldest bar your plan reaches. 1m: a UTC time (YYYY-MM-DDTHH:MM:SSZ, or a date for 00:00 UTC), inclusive; left out means one day before end.
endqueryNostringLast bar. 1d: a date, inclusive; left out means the latest session whose bar is out. 1m: a UTC time, exclusive; left out means now, or five days after start. At most 5 days between start and end for 1m (400 range_too_large).
cursorqueryNostringnext_cursor of the previous page, with the same other parameters. A cursor stops working when the prices behind it changed (a split or dividend): 409 cursor_expired.

Response fields

FieldTypeSentMeaning
idstringAlwaysCanonical listing id.
currencystringAlwaysCurrency of every price. Pence become pounds.
adjustedbooleanAlwaysAlways true: prices are adjusted for splits and dividends. A page and the next one always agree; if a split or dividend changes the history between two pages, the cursor stops working (409 cursor_expired) instead of mixing the two.
startstringOptionalSent only when the plan raised the start: the first day (daily) or time (1m) served.
endstringOptionalSent only on Free daily bars, when the 15-minute delay cut the range: the last day served. Today's bar reaches Free 15 minutes after the exchange closes.
barslist of objectAlwaysAt most 5,000 bars a page.
bars[].datestringOptionalSession date (YYYY-MM-DD). Daily bars only; left out on one-minute bars.
bars[].timestampdate-timeAlwaysStart of the bar's period (UTC): the exchange's open for a daily bar, the minute for a one-minute bar.
bars[].openstringAlwaysFirst price of the period.
bars[].highstringAlwaysHighest price of the period.
bars[].lowstringAlwaysLowest price of the period.
bars[].closestringAlwaysLast price of the period.
bars[].volumestringOptionalShares traded in the period. Left out when it is not known.
next_cursorstringAlwaysPass it back to read the next page. Null on the last page.
Request
curl -H "Authorization: Bearer $NARWHAL_API_KEY" \
  "https://api.narwhalapi.com/v1/funds/ARCX:SPY/bars?interval=1d&start=2026-10-01"
Example response with illustrative values
{
  "id": "ARCX:SPY",
  "currency": "USD",
  "adjusted": true,
  "bars": [
    {
      "date": "2026-10-23",
      "timestamp": "2026-10-23T13:30:00Z",
      "open": "575.10",
      "high": "577.60",
      "low": "573.90",
      "close": "576.40",
      "volume": "48210300"
    }
  ],
  "next_cursor": null
}

Distributions of one fund

GET /v1/funds/{id}/distributions

Distributions of one fund, newest ex-date first, paged. The plan sets how far back the past goes: Free 12 months, Basic 5 years, Pro full; start is sent only when older rows were cut off. Each request counts one call. An unknown id is 404 not_found.

MCP: tool funds, action distributions.

Parameters

ParameterWhereRequiredTypeMeaning
idpathYesstringFund id. An ETF is its listing id: the exchange's four-letter code, a colon and the ticker (ARCX:SPY). A mutual fund is FUND, a colon and its ticker (FUND:VFIAX). A bare US ticker such as SPY also works when it names one US listing; the answer carries the canonical id.
cursorqueryNostringnext_cursor of the previous page, with the same other parameters.

Response fields

FieldTypeSentMeaning
idstringAlwaysCanonical fund id.
startdateOptionalFirst day the plan's history window reaches (Free 12 months, Basic 5 years, Pro full). Present only when older rows exist that the plan cannot see.
distributionslist of objectAlwaysNewest ex-date first.
distributions[].ex_datedateAlwaysFirst day the fund trades without the distribution.
distributions[].amountstringAlwaysDistribution per share, in currency.
distributions[].currencystringAlwaysCurrency of amount.
distributions[].pay_datedateOptionalDay it is paid. Left out when not known.
distributions[].record_datedateOptionalDay the holder must be on record. Left out when not known.
distributions[].kindone of income, capital_gainOptionalWhat the distribution is. Left out where the source does not say.
next_cursorstringAlwaysPass as cursor for the next page; null on the last page.
Request
curl -H "Authorization: Bearer $NARWHAL_API_KEY" \
  "https://api.narwhalapi.com/v1/funds/ARCX:SPY/distributions"
Example response with illustrative values
{
  "id": "ARCX:SPY",
  "distributions": [
    {
      "ex_date": "2026-09-18",
      "amount": "1.78",
      "currency": "USD",
      "pay_date": "2026-10-30",
      "record_date": "2026-09-18"
    }
  ],
  "next_cursor": null
}

Errors

Errors use the shared problem shape with a request_id. See Errors and quota for the shape and the quota headers. Failed requests are not charged.

CodeStatusMeaning
invalid_query400A parameter is missing, unknown, repeated or outside its range.
invalid_id400An id or pair is not written the way this API expects.
too_many_ids400A batch holds more than 50 ids.
invalid_interval400The interval is not 1d or 1m, or not available for this instrument.
range_too_large400The window is longer than the route allows.
plan_upgrade_required403The request needs a Basic or Pro plan.
not_found404No such listing, fund or pair.
invalid_cursor400The cursor is not one this request returned, or it comes from a different request.
cursor_expired409The prices behind the cursor changed. Start again without the cursor.
quota_exceeded429Your plan has fewer calls left than the request needs.
upstream_busy503A price could not be fetched now. In a batch it is listed in errors. Try again shortly.
area_unavailable503This area cannot answer right now.