A request to /v1/range returns a data array of stored observations for the window. The default base is USD, symbols selects the quote currencies, and JSON pages include has_more and next_cursor when the window is longer than 366 rows. Each row carries its own date, rates, and source fields.
- JSON responses contain up to 366 rows per page; follow
next_cursorwhilehas_moreis true. - Params are
start_dateandend_date(snake_case).symbolsis a comma-separated string. - Each row carries
sourceandis_forward_filled. Reference sources publish on business days, so weekend dates are not returned as rows. - The supported daily window is up to 1,830 days per request; actual depth depends on the pair.
- Rates are indicative, published for reference and analytics.
The range endpoint
GET /v1/range takes start_date, end_date, base (default USD), and comma-separated symbols. Free and keyless cover 30 days, Basic one year, Plus and Pro full history. This example needs Basic or above. JSON pages hold 366 rows; follow next_cursor while has_more is true. Use format=csv or format=parquet for one download.
If you need a browser-based lookup instead of an API response, use the historical exchange-rate lookup. It is designed for checking a date or downloading a small result; this guide is for code and repeatable data work.
$ curl -G "https://api.exchangerate.dev/v1/range" \ -H "Authorization: Bearer exr_live_..." \ --data-urlencode "base=USD" \ --data-urlencode "symbols=EUR,GBP" \ --data-urlencode "start_date=2026-06-10" \ --data-urlencode "end_date=2026-06-13"Each element of data is a self-contained daily row with its own date, rates, source, and is_forward_filled:
{ "result": "success", "base": "USD", "start_date": "2026-06-10", "end_date": "2026-06-13", "data": [ { "date": "2026-06-10", "rates": { "EUR": 0.86663, "GBP": 0.74727 }, "source": "ecb_daily", "is_forward_filled": false }, { "date": "2026-06-11", "rates": { "EUR": 0.86678, "GBP": 0.74829 }, "source": "ecb_daily", "is_forward_filled": false }, { "date": "2026-06-12", "rates": { "EUR": 0.86453, "GBP": 0.74613 }, "source": "ecb_daily", "is_forward_filled": false } ], "has_more": false, "next_cursor": null}Note that the Saturday (2026-06-13) is absent. Reference sources publish on business days only, so the range returns the published days in the window rather than one row per calendar day.
Python example
The same request in Python with requests. Iterate the data array, reading each row by key:
import requestsAPI = "https://api.exchangerate.dev/v1"KEY = "exr_live_..." # Basic or higher: https://exchangerate.dev/pricingparams = { "base": "USD", "symbols": "EUR,GBP", "start_date": "2026-06-10", "end_date": "2026-06-13",}rows = []while True: resp = requests.get( f"{API}/range", headers={"Authorization": f"Bearer {KEY}"}, params=params, timeout=10, ) resp.raise_for_status() data = resp.json() rows.extend(data["data"]) if not data["has_more"]: break cursor = data.get("next_cursor") if not cursor or cursor <= params.get("cursor", ""): raise RuntimeError("Incomplete historical response") params["cursor"] = cursorfor row in rows: filled = row["is_forward_filled"] print(row["date"], row["rates"]["EUR"], row["rates"]["GBP"], "filled" if filled else "")The is_forward_filled flag
Every range row carries is_forward_filled: false because it represents a stored observation. Missing weekend and holiday dates stay absent. A lookup for one date can return the earlier published value and flag it as carried forward; that is a different response from a range.
The range returns published days, so a Saturday will not appear in data. To get the carried-forward value for one specific weekend or holiday date, request it directly with /v1/{date}/{base}, which returns the prior value and sets is_forward_filled: true.
/v1/range vs looping /v1/{date}/{base}
For a single day, use /v1/{date}/{base}. It carries is_forward_filled at the response root. For a series, use /v1/range and follow its cursors until every JSON page has arrived. CSV and Parquet exports return the full supported window in one download.
| Approach | /v1/{date}/{base} loop | /v1/range |
|---|---|---|
| HTTP requests for a 30-day series | Up to 30 requests | 1 request (plus paging) |
| Rate-limit cost | One call per requested date | One call per JSON page |
| Response shape | One day per response | A data array of days |
| Forward-fill flag | Root-level field | Per-row field in data |
| Best for | One-off date lookups | Charting, backtesting, bulk export |
Window size and pair coverage
Daily requests support windows up to 1,830 days. That is a request limit, not a promise that every pair has data that far back. Coverage is pair-specific: check the returned rows and source values before building a long backfill. A pair with no observations returns an empty data array rather than a fabricated rate.
Loading into pandas
The data array loads into a DataFrame with pd.json_normalize, which flattens the nested rates object into rates.EUR, rates.GBP, and so on:
import pandas as pd# rows contains every page from the loop abovedf = pd.json_normalize(rows)df["date"] = pd.to_datetime(df["date"])df = df.set_index("date").sort_index()# keep only rows that were actually published that daydf = df[~df["is_forward_filled"]]print(df[["rates.EUR", "rates.GBP"]].tail())The code above is one convenience pattern. The API returns plain JSON, so any language or tool that can parse JSON works the same way.