Historical Gold and Silver Prices via API: 25 Years of Daily Data
Get daily historical gold and silver prices back to 2000 with /historical, /timeframe, /change and /ohlc, page long ranges by year, store the data and chart it.
What history is available
MetalAPI keeps one daily bar per symbol and day, with open, high, low and close. For gold and silver that is more than 25 years of daily data: enough to chart long-term trends, test a pricing rule against the past or revalue old invoices at the price of the day.
| Category | Symbols | Daily history since |
|---|---|---|
| Precious metals | XAU, XAG, XPT, XPD | 2000 |
| Industrial metals and energy | XCU, ALU, ZNC, HRC, WTI, BRENT and others | 2000 |
| Currencies | 57, including USD, EUR, GBP and JPY | 1999-01-04 |
| Cryptocurrencies | 13, including BTC and ETH | 2014 |
Every plan can query the full history. Plans only differ in how many days a single range request may cover. Dates use the YYYY-MM-DD format in UTC, and each day's values are that day's closing prices. A date before a symbol's history starts returns error 300 (no_results).
Metal prices before October 2026 are derived from front-month futures, so they can differ slightly from the spot price on the same day. Keep that in mind when you compare old values with spot prices from another source.
Four endpoints cover historical work: /historical for one day, /timeframe for a range, /change for the movement between two days and /ohlc for a day's candle. The sections below take them in turn and then build a small local archive with a chart on top.
One day: /historical
To get the closing prices of a single day, put the date in the path. /historical?date=YYYY-MM-DD does the same, and /yesterday is a shortcut for the previous UTC day:
curl "https://metalapi.com/api/v1/2024-01-15?currencies=XAU,XAG" \
-H "X-API-Key: $METALAPI_KEY"The response has the same shape as /latest, plus historical: true. These are the example values from the API reference:
{
"success": true,
"timestamp": 1705276800,
"historical": true,
"date": "2024-01-15",
"base": "USD",
"rates": {
"XAU": 0.0004874244,
"USDXAU": 2051.6,
"XAG": 0.0431741646,
"USDXAG": 23.162
}
}USDXAU is the price of one troy ounce of gold in the base currency, and XAU is its inverse. base and unit work as usual, so base=EUR&unit=gram returns euros per gram for that day.
Weekends and holidays don't break the request: for a day without trading, the most recent trading day's close is returned. The date in the response is still the day you asked for, so if you need to know whether markets were open, keep a trading calendar on your side. A future date returns error 205 (invalid_date).
Date ranges: /timeframe
For a series, /timeframe returns one entry per day between start_date and end_date, both inclusive, keyed by date. /timeseries is an alias that marks the response with timeseries: true instead of timeframe: true.
curl "https://metalapi.com/api/v1/timeframe?start_date=2026-10-19&end_date=2026-10-23¤cies=XAU" \
-H "X-API-Key: $METALAPI_KEY"{
"success": true,
"timeframe": true,
"start_date": "2026-10-19",
"end_date": "2026-10-23",
"base": "USD",
"rates": {
"2026-10-19": { "XAU": 0.0002403788, "USDXAU": 4160.1 },
"2026-10-20": { "XAU": 0.0002413943, "USDXAU": 4142.6 },
"2026-10-21": { "XAU": 0.0002409639, "USDXAU": 4150 },
"2026-10-22": { "XAU": 0.0002462812, "USDXAU": 4060.4 },
"2026-10-23": { "XAU": 0.0002495072, "USDXAU": 4007.9 }
}
}Unlike the single-day endpoint, /timeframe doesn't fill gaps. Spot metals stop trading from Friday 17:00 to Sunday 17:00 New York time, so weekends and market holidays can be missing from the result. Don't assume one entry per calendar day, and if you mix symbols with different trading calendars, such as gold and bitcoin, check that a key exists before you read it.
The longest range a single request may cover depends on the plan:
| Plan | Longest range per request | Paging unit that always fits |
|---|---|---|
| Free | 30 days | One calendar month |
| Lite and above | 365 days | One calendar year |
The limit is the distance between the two dates, so 2025-01-01 to 2025-01-31 fits the Free limit and 2024-01-01 to 2024-12-31 fits the paid limit, even in a leap year. A longer range returns error 207 (timeframe_too_long) with an upgrade_url. Each /timeframe call counts as one request, however many days it returns.
Comparing two dates: /change
When you only need the movement between two days, such as year-to-date performance on a dashboard, /change returns the start rate, end rate, absolute change and percentage change without downloading the days in between:
curl "https://metalapi.com/api/v1/change?start_date=2026-01-02&end_date=2026-10-23¤cies=XAU,XAG" \
-H "X-API-Key: $METALAPI_KEY"{
"success": true,
"change": true,
"start_date": "2026-01-02",
"end_date": "2026-10-23",
"base": "USD",
"rates": {
"XAU": { "start_rate": 0.0002309682, "end_rate": 0.0002495072, "change": 0.000018539, "change_pct": 8.02665 },
"USDXAU": { "start_rate": 4329.6, "end_rate": 4007.9, "change": -321.7, "change_pct": -7.43025 }
}
}Read the prefixed key, USDXAU. The inverse key XAU moves the other way, so its change_pct has the opposite sign and a different size: in this example the gold price fell 7.43% while the inverse rose 8.03%. Showing the inverse percentage as the price change is an easy mistake to ship.
/change has the same range limits as /timeframe. Like /historical, it uses the most recent close when a date falls on a weekend or holiday.
Daily candles: /ohlc
/ohlc returns the open, high, low and close of one day, in both directions: USDXAU in dollars per ounce and XAU as the inverse. Without date, or with date=latest, it returns today's bar so far.
curl "https://metalapi.com/api/v1/ohlc?date=2026-10-23¤cies=XAU" \
-H "X-API-Key: $METALAPI_KEY"{
"success": true,
"ohlc": true,
"date": "2026-10-23",
"base": "USD",
"rates": {
"USDXAU": { "open": 4042.2, "high": 4050.2, "low": 3989.1, "close": 4007.9 },
"XAU": { "open": 0.000247390, "high": 0.000250683, "low": 0.000246901, "close": 0.000249507 }
}
}Note that in the inverse block the high comes from the lowest price and the low from the highest price, because inverting reverses the order. Chart the USDXAU block.
Each call covers one day, so a candlestick chart of a full year takes one request per trading day. For long charts, closing prices from /timeframe are far cheaper. Use /ohlc for recent candles or to show the day's trading range next to the current price.
For weekly or monthly candles over many years, you can also build bars from stored daily closes: the open is the first close of the period, the close is the last one, and high and low are the extremes of the closes. They miss intraday highs and lows, so label them as close-based, but they are fine for long-range charts.
Backfilling 25 years, one year per request
With a 365-day limit per request, the complete gold and silver history since 2000 takes one /timeframe call per calendar year: 27 requests for 2000 through 2026. That fits easily into any paid plan's monthly quota. On the Free plan, 30-day windows would need more than 300 requests, about three times its monthly quota of 100, so a full backfill isn't practical there; a few months of history is.
This Python script pages by year, waits out the per-minute limit, and writes the price keys of both metals into SQLite. Save it as backfill.py:
import os
import sqlite3
import time
from datetime import datetime, timezone
import requests
API = "https://metalapi.com/api/v1"
session = requests.Session()
session.headers["X-API-Key"] = os.environ["METALAPI_KEY"]
db = sqlite3.connect("metals.db")
db.execute(
"""create table if not exists daily_close (
day text not null,
symbol text not null,
base text not null,
price real not null,
primary key (day, symbol, base)
)"""
)
def fetch_timeframe(start, end, symbols, base="USD"):
"""Daily closes between start and end (inclusive), keyed by date."""
while True:
r = session.get(
f"{API}/timeframe",
params={"start_date": start, "end_date": end, "currencies": ",".join(symbols), "base": base},
timeout=30,
)
data = r.json()
if data.get("success"):
return data["rates"]
err = data["error"]
if err["code"] == 104: # per-minute limit: wait, then retry (not billed)
time.sleep(int(r.headers.get("Retry-After", "60")))
continue
if err["code"] == 300: # no data in this window
return {}
raise RuntimeError(f"{err['code']} {err['type']}: {err['info']}")
def store(rates, symbols, base="USD"):
rows = [
(day, s, base, values[f"{base}{s}"])
for day, values in rates.items()
for s in symbols
if f"{base}{s}" in values
]
db.executemany("insert or replace into daily_close values (?, ?, ?, ?)", rows)
db.commit()
return len(rows)
def backfill(first_year, symbols=("XAU", "XAG"), base="USD"):
today = datetime.now(timezone.utc).date()
for year in range(first_year, today.year + 1):
start = f"{year}-01-01"
end = min(f"{year}-12-31", today.isoformat())
n = store(fetch_timeframe(start, end, symbols, base), symbols, base)
print(f"{year}: {n} rows")
if __name__ == "__main__":
backfill(2000)The script is safe to re-run: insert or replace overwrites rows that already exist, so an interrupted backfill can simply be started again. The current year ends at today's UTC date, because a future end_date returns 205. Per-minute limits are lowest on the smallest plans (10 requests per minute on Free and 30 on Lite), which is why the script honors Retry-After on error 104 instead of failing.
It stores USDXAU and USDXAG, the prices, not the inverse keys. If you need the history in another currency, pass base or store the dollar prices of the currencies too and convert locally from the same day's rows.
Storing and updating the data
Closing prices of past days don't change, so a local copy is the cheapest and fastest way to serve charts and reports. A few rules keep it correct:
- Key rows by day, symbol and base. The table above uses
(day, symbol, base)as its primary key, so fetching a range twice overwrites rows instead of duplicating them. - Record what you requested. Store
base, andunitif you use it, with the data. Mixing per-gram and per-ounce rows in one column is a classic cause of spikes in a chart. - Top up once a day. After the backfill, one request per day keeps the table current. Fetch the last seven days, so a missed run or a late close is filled in automatically.
- Treat today as provisional. Only the current day's bar is still moving. Keep it out of the archive, or overwrite it on the next run as the script below does.
from datetime import datetime, timedelta, timezone
from backfill import fetch_timeframe, store
SYMBOLS = ("XAU", "XAG")
today = datetime.now(timezone.utc).date()
start = (today - timedelta(days=7)).isoformat()
end = (today - timedelta(days=1)).isoformat()
print(store(fetch_timeframe(start, end, SYMBOLS), SYMBOLS), "rows updated")At one request per day, the top-up costs about 30 requests a month, which even leaves room on the Free plan. For larger archives, the same pattern works with PostgreSQL, a Parquet file or any time-series database; only the store function changes.
Before you build anything on a backfill, check it. Three checks catch most problems:
- Row counts per year. Every complete year should have roughly one row per trading day for each metal. A year with far fewer rows means a request failed or returned no data.
- Gaps. An ordinary weekend leaves three days between two closes and a long holiday weekend four. Anything longer deserves a look, and re-fetching that window costs a single request.
- Jumps. A day-over-day move by a factor of about 31 or 1,000 points to mixed units, and a value close to zero points to an inverse key that slipped in.
The first two are short queries in SQLite:
-- Rows per year: a complete year has roughly one row per trading day
select substr(day, 1, 4) as year, symbol, count(*) as days from daily_close
where base = 'USD' group by year, symbol order by year, symbol;
-- Gaps of more than four days between stored closes
select symbol, prev_day, day from (
select symbol, day, lag(day) over (partition by symbol order by day) as prev_day
from daily_close where base = 'USD'
) where julianday(day) - julianday(prev_day) > 4;Charting the data
With the data stored locally, charts don't cost any requests. This matplotlib script plots the gold price in dollars together with the gold/silver ratio, the number of ounces of silver one ounce of gold buys:
import sqlite3
from datetime import date
import matplotlib.dates as mdates
import matplotlib.pyplot as plt
db = sqlite3.connect("metals.db")
rows = db.execute(
"""select g.day, g.price, s.price
from daily_close g
join daily_close s on s.day = g.day and s.base = g.base and s.symbol = 'XAG'
where g.symbol = 'XAU' and g.base = 'USD'
order by g.day"""
).fetchall()
days = [date.fromisoformat(day) for day, _, _ in rows]
gold = [g for _, g, _ in rows]
ratio = [g / s for _, g, s in rows]
fig, (top, bottom) = plt.subplots(2, 1, sharex=True, figsize=(10, 6))
top.plot(days, gold, linewidth=1)
top.set_ylabel("Gold, USD per troy oz")
bottom.plot(days, ratio, linewidth=1)
bottom.set_ylabel("Gold/silver ratio")
bottom.xaxis.set_major_locator(mdates.YearLocator(base=5))
fig.tight_layout()
fig.savefig("gold-silver.png", dpi=150)On a website, serve the stored series from your own backend and draw it in the browser. Your API key stays on the server, and page views don't spend your quota. A minimal Chart.js page that reads a JSON array of { day, price } objects from your own endpoint:
<canvas id="gold" height="320"></canvas>
<script src="https://cdn.jsdelivr.net/npm/[email protected]/dist/chart.umd.js"></script>
<script>
// Your own endpoint, returning [{ "day": "2026-10-23", "price": 4007.9 }, ...]
fetch("/api/gold-history")
.then((res) => res.json())
.then((points) => {
new Chart(document.getElementById("gold"), {
type: "line",
data: {
labels: points.map((p) => p.day),
datasets: [{ label: "Gold, USD per troy oz", data: points.map((p) => p.price), pointRadius: 0, borderWidth: 1 }],
},
options: {
animation: false,
scales: { x: { ticks: { maxTicksLimit: 10 } } },
},
});
});
</script>Twenty-five years of trading days is more than 6,000 points per series. Turning off point markers and animation, as above, keeps the chart responsive; on small screens, consider downsampling to weekly closes.
The historical data guide has a JavaScript range helper and a moving-average example, and the API docs list every parameter. If you work with AI assistants, the MetalAPI MCP server can fetch the same time series from a chat. For keys, errors and caching in general, start with the Gold price API guide, and compare range limits and quotas on the pricing page.
Try it with your own data
Create a free account and make your first request in under two minutes. 100 requests a month, no credit card.
Keep reading
Gold Price API: The Complete Developer Guide
How to use a gold price API: get a key, call it from curl, JavaScript and Python, read the rates correctly, and handle history, errors, quotas and caching.
How to Add a Live Gold Price Widget to Your Website
Add a live gold price widget to WordPress, Shopify or plain HTML: build it in the MetalAPI dashboard, choose symbols, layout and theme, then paste two lines.
Gold Karat Prices Explained: 24K, 22K, 18K and How to Calculate Them
What 24K, 22K and 18K mean, how fineness marks like 916 and 750 relate, how to calculate karat gold prices per gram and how to get them from a gold price API.