API guide

ETF API: real-time and historical ETF prices for developers

Fetching an ETF price is the easy part. Knowing which number you hold, a quote, a trade, a bar or the fund's own NAV, is what keeps an ETF screen honest.

On this page
  1. What an ETF API returns, and what it does not
  2. ETF symbols: alias codes and listing tickers
  3. Endpoints for ETF prices and history
  4. Anatomy of an ETF quote frame
  5. Stream ETF quotes and trades in Python
  6. ETF NAV, premium and discount: the data you add yourself
  7. A tracking check you can run without NAV
  8. Where ETF prices fit in a product
  9. Build notes
  10. Questions

Key takeaways

  • TickerLayer's ETF API covers 5,600+ ETFs with quotes, last trades, snapshots and OHLCV bars over REST, plus the `etfs.quotes` and `etfs.trades` WebSocket channels.
  • ETF symbols carry no market prefix: use an alias code such as `US500ETF` or `WORLDETF`, or the listing ticker, exactly as `/etfs/symbols` returns it.
  • NAV, intraday indicative value and holdings are out of scope: pair TickerLayer prices with the fund's own daily NAV file when you need a premium or discount.
  • From 22 September 2025 to 25 September 2026, `US500ETF` rose 15.67% against 15.68% for the `US500` index level, both price only.
  • ETF WebSocket frames can carry numbers or numeric strings for the same symbol, so convert every field before doing arithmetic.

An ETF API gives your code the market prices of exchange-traded funds as JSON: the current bid and ask, the last trade, daily and intraday bars, and a stream of quotes and trades as they change. On TickerLayer the ETF routes live under /etfs, use the same response shapes as stocks, forex and indices, and cover 5,600+ funds under unprefixed codes such as US500ETF and WORLDETF.

What an ETF API does not give you matters just as much. There is no NAV, no intraday indicative value and no holdings file here; those come from each fund. This guide covers symbols, endpoints, real responses and streaming, then shows how to combine market prices with fund data when you need a premium or discount. For the index side of the same exposure, see the indices API guide.

curlShell
curl -sS "https://api.tickerlayer.com/etfs/quote/US500ETF" \
  -H "x-api-key: $TICKERLAYER_API_KEY"
Response, 28 September 2026, 10:25 UTC (US pre-market)JSON
{"symbol":"US500ETF","bid":767.13,"ask":767.19,"bid_size":100,"ask_size":100,"timestamp":1790591129578}

What an ETF API returns, and what it does not

An ETF has two kinds of data. Market data describes the fund as a traded security: quotes, trades, bars. Fund data describes it as a portfolio: what it holds, what those holdings are worth per share, what it charges. A market data API covers the first kind. NAV, holdings and fees change at most once a day, so they do not need a stream; market data changes constantly, and that is where an API earns its keep.

FeatureOn TickerLayerPublished by
Real-time bid and askMarket data
Last trade price and sizeMarket data
Daily and intraday OHLCV barsMarket data
Streamed quotes and tradesMarket data
End-of-day NAVThe fund, once a day
Intraday indicative valueA calculation agent, during the session
Holdings and weightsThe fund, usually daily
Expense ratio and distributionsFund documents
TickerLayer covers the market side of an ETF. Fund-side data comes from each fund.

ETF symbols: alias codes and listing tickers

ETF codes have no market prefix. GET /etfs/symbols returns every code with a name (5,636 rows when we called it), and two naming styles work side by side: generic alias codes that describe the exposure, and the listing ticker of the fund. The docs use aliases, and so do our examples, because an alias says what you are holding without naming a fund issuer.

CodeName in /etfs/symbolsExposure
US500ETFUS 500US large-cap equity
US100ETFUS growth 100US large-cap growth
US30ETFUS 30US large-cap equity
WORLDETFGlobal blendGlobal equity
EMETFEmerging marketsEmerging-market equity
EUROPEETFEurope regionEuropean equity
USTECHUS technology sectorSector
USSMALLUS small-capUS small caps
USGOLDGoldCommodity
USBONDUS core bond marketBonds
USTBILLUS Treasury bills 0-3 monthCash-like bonds
A selection of alias codes. Listing tickers resolve as well; /etfs/symbols is the full list.

If you are moving a watchlist over from another tool, paste it into the symbol coverage checker first. It normalizes formats, flags anything outside the catalog, and returns the REST path and stream channel for every match. Then keep the codes unprefixed, exactly as /etfs/symbols returns them.

Endpoints for ETF prices and history

RequestReturnsUse it for
GET /etfs/symbolsEvery code with its nameDiscovery and validation
GET /etfs/quote/{symbol}bid, ask, sizes, timestampThe current market
GET /etfs/trade/last/{symbol}price, size, timestampThe latest trade
GET /etfs/snapshot/{symbol}Quote plus last_price, prev_close, changeWatchlists
GET /etfs/agg/{symbol}/prevThe last completed daily barYesterday's close
GET /etfs/agg/{symbol}/{n}/{span}/{from}/{to}OHLCV bars, paginatedCharts, backtests, NAV joins
etfs.quotes, etfs.tradesStreamed quotes and tradesLive prices
Bar intervals: 1, 5 or 15 minutes, 1 or 4 hours, 1 day. Dates are UTC and inclusive; pages hold up to 5,000 bars.

Authenticate with the x-api-key header. ETFs are their own feed: $59 a month on the Individual plan and $549 on Business, with two and ten years of history respectively (pricing). WebSocket comes with paid plans, and free accounts can request a trial from the dashboard. The ETF REST reference lists parameters and error codes.

Anatomy of an ETF quote frame

A live etfs.quotes frame

{
  "type": "quote",
  "channel": "etfs.quotes",1
  "asset": "etfs",
  "symbol": "US500ETF",
  "bid": "767.16",2
  "ask": "767.21",
  "bid_size": "80",3
  "ask_size": "120",
  "ts": 1790591511111,4
  "timestamp": 17905915111115
}
  1. channelWhich subscription produced the frame. Route on type and channel, not on field names.
  2. bidA numeric string in this frame. The snapshot frame a moment earlier carried the same field as a number.
  3. bid_sizeSize at the best bid, also a string here.
  4. tsQuote time in Unix milliseconds, UTC. Store this, not your receive time.
  5. timestampA mirror of ts on ETF frames. Read ts.
Captured on 28 September 2026 during the US pre-market.

Same channel, same symbol, two encodings less than a second apart. In JavaScript, msg.bid + msg.ask gives 1534.37 on the first frame and the string "767.16767.21" on the second, and nothing throws. Convert every numeric field once, at the edge of your code, and the problem disappears.

Stream ETF quotes and trades in Python

etf_stream.pyPython
import asyncio
import json
import os
from urllib.parse import quote

import websockets  # pip install websockets

URL = "wss://stream.tickerlayer.com/?apiKey=" + quote(os.environ["TICKERLAYER_API_KEY"], safe="")
SUBSCRIBE = {
    "action": "subscribe",
    "channels": ["etfs.quotes", "etfs.trades"],
    "symbols": ["US500ETF", "US100ETF"],
}


def num(value):
    """ETF frames can carry numbers or numeric strings, even for one symbol."""
    return float(value)


async def main():
    # compression=None: the stream sends uncompressed frames; skip the inflate work.
    async with websockets.connect(URL, compression=None, open_timeout=10) as ws:
        ready = json.loads(await ws.recv())
        if ready.get("event") != "ready":
            raise RuntimeError(f"expected a ready frame, got {ready}")
        await ws.send(json.dumps(SUBSCRIBE))
        async for raw in ws:
            msg = json.loads(raw)
            if msg.get("type") == "quote":
                bid, ask = num(msg["bid"]), num(msg["ask"])
                bps = (ask - bid) / ((ask + bid) / 2) * 10_000
                tag = "  (snapshot)" if msg.get("snapshot") else ""
                print(f'{msg["symbol"]:<9} quote {bid:.2f} / {ask:.2f}  {bps:.2f} bps{tag}')
            elif msg.get("type") == "trade":
                print(f'{msg["symbol"]:<9} trade {num(msg["price"]):.2f} x {num(msg["size"]):g}')
            else:
                print("control:", msg)


if __name__ == "__main__":
    asyncio.run(main())
Output (trimmed), 28 September 2026, US pre-market
control: {'type': 'system', 'event': 'subscribed', 'channels': ['etfs.quotes', 'etfs.trades'], 'symbols': ['US100ETF', 'US500ETF']}
US100ETF  quote 737.36 / 737.41  0.68 bps  (snapshot)
US500ETF  quote 767.41 / 767.44  0.39 bps  (snapshot)
US100ETF  trade 737.46 x 40
US500ETF  trade 767.43 x 160
US500ETF  quote 767.39 / 767.43  0.52 bps
US500ETF  trade 767.41 x 40
US500ETF  quote 767.33 / 767.40  0.91 bps

In this pre-market sample the two funds were quoted between 0.39 and 0.91 basis points wide. That column is worth keeping in any ETF screen: the spread is the cost of getting in and out, and on a thinly traded fund one round trip can cost more than a year of its fees. The script stops at the first dropped connection; the WebSocket reconnect guide adds backoff and resubscription.

ETF NAV, premium and discount: the data you add yourself

An ETF's NAV (net asset value) is the value of everything the fund holds, minus liabilities, divided by its shares outstanding. The fund strikes it once a day after the close. The market price trades all day around it, and the gap between the two is the premium (price above NAV) or discount (price below).

premium (bps) = (price − NAV) ÷ NAV × 10,000

price
The market price at the same moment NAV is struck, usually the closing price.
NAV
The fund's published net asset value per share for that day.
bps
Basis points; a negative result is a discount.
Illustrative: a close of 771.35 against a NAV of 771.20 is a premium of 1.9 bps.

Large authorized dealers can create new ETF shares by delivering the underlying basket, or redeem shares for it, so a big premium or discount is an arbitrage they close. That keeps the gap to a few basis points on large funds that hold liquid shares. It opens wider on bond funds, on funds whose underlying markets are shut while the ETF trades, on thin funds, and in stressed markets.

  1. Fund NAV filepublished daily by the fund
  2. TickerLayer closeGET /etfs/agg/{symbol}/1/day/…
  3. Join on session datesame symbol, same day
  4. Premium or discountstore it with both inputs
A premium needs two sources. Keep both inputs next to the result so you can audit it later.

During the session, a calculation agent also publishes an intraday indicative value: a running estimate of NAV from the live prices of the holdings. It is a useful reference for large domestic equity funds. For a fund holding Japanese shares that trades while Tokyo sleeps, the estimate is built on stale prices, and the ETF price itself is often the better real-time read of what the basket is worth.

Holdings files

Holdings are fund data too, so an ETF holdings API is ultimately a wrapper around files the funds publish themselves. Most funds post their full holdings on their own website every business day, and that file is the primary source for look-through views such as sector or country exposure. Holdings change slowly, so one fetch a day is enough; map each holding to a TickerLayer symbol once (for stocks, US:KO style), and price the whole basket from the stream if you need a live look-through value.

A tracking check you can run without NAV

Short of NAV, the cleanest sanity check on ETF data is to compare the fund with the index level it follows. Over a year the two price returns should be close, apart from fees and the timing of dividend payouts. A large gap means you paired the wrong symbols or one of the series has a problem.

etf_tracking.pyPython
import os
import sys

import requests

BASE_URL = "https://api.tickerlayer.com"
session = requests.Session()
session.headers.update({"x-api-key": os.environ["TICKERLAYER_API_KEY"]})


def get(path, params=None):
    resp = session.get(BASE_URL + path, params=params, timeout=15)
    if resp.status_code != 200:
        sys.exit(f"GET {path}: HTTP {resp.status_code} {resp.text[:160]}")
    return resp.json()


def daily_closes(asset, symbol, start, end):
    """{bar start in ms: close} for every daily bar in the range, following pagination."""
    params = {"sort": "asc", "limit": 5000, "offset": 0}
    out = {}
    while True:
        body = get(f"/{asset}/agg/{symbol}/1/day/{start}/{end}", params)
        out.update({bar["t"]: round(bar["c"], 2) for bar in body["results"]})
        if body.get("next_offset") is None:
            return out
        params["offset"] = body["next_offset"]


q = get("/etfs/quote/US500ETF")
mid = (q["bid"] + q["ask"]) / 2
print(f"US500ETF quote {q['bid']} / {q['ask']}, spread {(q['ask'] - q['bid']) / mid * 10_000:.2f} bps")

etf = daily_closes("etfs", "US500ETF", "2025-09-22", "2026-09-25")
idx = daily_closes("indices", "US500", "2025-09-22", "2026-09-25")
days = sorted(etf.keys() & idx.keys())
if len(days) < 2:
    sys.exit("Not enough overlapping sessions to compare.")
a, b = days[0], days[-1]
print(f"{len(days)} sessions in both series")
print(f"US500     {idx[a]:>8.2f} -> {idx[b]:>8.2f}  {idx[b] / idx[a] - 1:+.2%}")
print(f"US500ETF  {etf[a]:>8.2f} -> {etf[b]:>8.2f}  {etf[b] / etf[a] - 1:+.2%}")
ratios = [idx[d] / etf[d] for d in days]
print(f"index / ETF ratio ranged {min(ratios):.3f} to {max(ratios):.3f}")
Output
US500ETF quote 767.13 / 767.19, spread 0.78 bps
255 sessions in both series
US500      6693.75 ->  7743.41  +15.68%
US500ETF    666.84 ->   771.35  +15.67%
index / ETF ratio ranged 10.008 to 10.088
  • +15.68%US500 index level, 22 Sep 2025 to 25 Sep 2026
  • +15.67%US500ETF price over the same 255 sessions
  • 0.78 bpsUS500ETF quoted spread, 28 Sep pre-market
Price returns only: neither figure includes dividends.

The ratio between the index level and the fund's price drifts slowly, from 10.008 to 10.088 over the year. Each fund picks its own starting share price, and dividends the fund has collected but not yet paid out build up in its price between distributions. That drift is a property of the fund, not an error in either series. ETF vs index fund looks at the same pair minute by minute through one session.

Where ETF prices fit in a product

  • Portfolio trackersValue holdings at the last trade or the mid, and show the spread so users see the cost of selling.
  • BacktestsDaily bars back to the start of your plan's history. The golden cross backtest shows the pattern on an index.
  • Fund against indexChart an ETF next to the level it tracks. How a stock index is built explains why the weighting matters.
  • Research datasetsPull years of bars in pages of up to 5,000. Historical stock data covers adjustments, session boundaries and export.

Build notes

  • Keep ETF codes unprefixed, exactly as /etfs/symbols returns them.
  • Convert every numeric field on WebSocket frames; numbers and numeric strings both occur.
  • Use ts for time; on ETF frames timestamp is a mirror of it.
  • Snapshot on subscribe replays only recent ETF values; send "snapshot": "always" if you need the last known quote at any hour.
  • Daily bars are stamped at UTC midnight of the session date; join them to NAV files on that date, not on a local-time conversion.
  • Label prices as market prices. Never call a close a NAV.
  • History depth follows the plan: two years on Individual, ten on Business.

Questions

Is there a free ETF API?

TickerLayer's free tier includes 3,000 REST requests a month to get started. Live ETF endpoints need the ETFs feed, which starts at $59 a month on the Individual plan.

Does the ETF API include NAV or holdings?

No. It covers market data: quotes, trades, snapshots and bars. NAV, intraday indicative value and holdings are published by each fund, and you can join them to TickerLayer prices by date.

What is an ETF premium or discount?

It is the gap between an ETF's market price and its net asset value, usually quoted in basis points. Price above NAV is a premium, price below is a discount; on large funds holding liquid shares it is typically a few basis points.

How do I get historical ETF prices?

Call GET /etfs/agg/{symbol}/{multiplier}/{timespan}/{from}/{to} for bars from one minute to one day, following next_offset for long ranges. History depth is two years on Individual and ten on Business.

Can I stream ETF prices in real time?

Yes, on paid plans: subscribe to etfs.quotes and etfs.trades on the WebSocket stream. Free accounts can request a WebSocket trial from the dashboard.

What symbol format do ETFs use?

Unprefixed codes: an alias such as US500ETF or WORLDETF, or the listing ticker. GET /etfs/symbols returns every valid code with its name.

Keep reading

Ready to integrate?

Start with the free tier, explore the docs, and connect via REST or WebSocket in minutes.