This tutorial builds a small FX rates dashboard on top of an FX rates API. It shows current rates for a watchlist of currencies, a 30-day line chart for the selected pair, and the percent change over one day and 30 days. You can start with a free FX rates API key for the live watchlist; the 30-day chart uses historical data, which is on the paid plans.
The whole thing is two files: a small Node.js backend that holds the API key and refreshes rates every minute, and one HTML page with Chart.js that only ever talks to your backend.
What you are building
- A watchlist table: USD against EUR, GBP, JPY, CAD, AUD and INR.
- Each row shows the latest mid-market rate, the change since the previous day and the change over 30 days.
- Clicking a row draws its 30-day history as a line chart.
- A "Rates as of" line so the viewer knows how fresh the numbers are.
Rates on AllRatesToday are mid-market rates refreshed every 60 seconds. Delivery is plain REST over HTTPS, so "live" here means the backend polls once a minute and the page polls the backend once a minute. There is no WebSocket stream to subscribe to, and none is needed for a dashboard.
The architecture: never put the key in the browser
Anything you ship to the browser is public. If the page calls the rates API directly with your key in JavaScript, anyone can open the network tab, copy the key and spend your quota. So the browser calls /api/dashboard on your own server, and only the server calls the FX rates API.
This has two more benefits. First, the server makes one upstream call per minute no matter how many people have the page open. Second, you control the response shape, so the front end gets exactly the fields it needs. If you want to go further with key safety, see how to hide an exchange rate API key behind a proxy.
Get a free FX rates API key
Create an account at allratestoday.com/register and copy the key from the dashboard. The free plan covers latest rates, so you can build and test the watchlist straight away. The history endpoint used for the chart needs a paid plan; Small is €4.99 a month and includes history and commercial use. Plans are on the pricing page.
Store the key in an environment variable:
export ALLRATES_API_KEY="YOUR_API_KEY" The two API calls
The watchlist is one request. Pass all targets as a comma-separated list and the API returns one JSON array. A multi-target call costs one request, the same as a single pair.
curl -H "Authorization: Bearer YOUR_API_KEY" \ "https://allratestoday.com/api/v1/rates?source=USD&target=EUR,GBP,JPY,CAD,AUD,INR" [ {"rate":0.9215,"source":"USD","target":"EUR","time":"2026-04-03T12:00:00Z"}, {"rate":0.7893,"source":"USD","target":"GBP","time":"2026-04-03T12:00:00Z"} ] The response is always an array, even for one pair. Reading res.rate instead of res[0].rate is the most common bug with this endpoint.
The chart uses /api/historical-rates with period=30d:
curl -H "Authorization: Bearer YOUR_API_KEY" \ "https://allratestoday.com/api/historical-rates?source=USD&target=EUR&period=30d" { "source": "USD", "target": "EUR", "data": [{"date": "2026-10-12", "rate": 0.9182, "timestamp": "..."}], "source_api": "mid-market", "period": "30d" } The supported periods are 1d, 7d, 30d and 1y. An unknown value silently falls back to 7d, and from/to parameters are ignored, so check the period field in the response if your chart looks too short. Two more things to know: 1y returns weekly points and costs 12 requests, and 1d is an interpolated chart shape, not real intraday ticks. Use 1d for a sparkline, never for a reported price.
The backend (Node.js and Express)
The server keeps the latest rates and the 30-day history in memory. Latest rates refresh every minute. History refreshes once a day, because a daily chart does not change during the day and refetching it every minute would waste requests.
// server.mjs (Node 18+, npm install express) import express from "express"; const KEY = process.env.ALLRATES_API_KEY; const BASE = "https://allratestoday.com"; const SOURCE = "USD"; const WATCHLIST = ["EUR", "GBP", "JPY", "CAD", "AUD", "INR"]; const state = { latest: {}, updated: null, history: {}, historyDay: null }; async function api(path) { const res = await fetch(BASE + path, { headers: { Authorization: `Bearer ${KEY}` }, }); if (!res.ok) throw new Error(`${res.status} ${await res.text()}`); return res.json(); } async function refreshLatest() { const rows = await api( `/api/v1/rates?source=${SOURCE}&target=${WATCHLIST.join(",")}` ); for (const row of rows) state.latest[row.target] = row; // array, not object state.updated = new Date().toISOString(); } async function refreshHistory() { const today = new Date().toISOString().slice(0, 10); if (state.historyDay === today) return; for (const target of WATCHLIST) { const h = await api( `/api/historical-rates?source=${SOURCE}&target=${target}&period=30d` ); state.history[target] = h.data; } state.historyDay = today; } async function tick() { try { await refreshLatest(); await refreshHistory(); } catch (err) { console.error("refresh failed:", err.message); // keep serving last good data } } await tick(); setInterval(tick, 60_000); const pct = (now, then) => (now && then ? ((now - then) / then) * 100 : null); const app = express(); app.use(express.static("public")); app.get("/api/dashboard", (req, res) => { if (!state.updated) return res.status(503).json({ error: "warming up" }); const today = new Date().toISOString().slice(0, 10); const pairs = WATCHLIST.map((target) => { const now = state.latest[target]?.rate; const hist = state.history[target] || []; const prior = hist.filter((p) => p.date < today).at(-1); return { pair: `${SOURCE}/${target}`, rate: now, change1d: pct(now, prior?.rate), change30d: pct(now, hist[0]?.rate), history: hist.map((p) => ({ date: p.date, rate: p.rate })), }; }); res.set("Cache-Control", "public, max-age=30"); res.json({ updated: state.updated, pairs }); }); app.listen(3000, () => console.log("http://localhost:3000")); Run it with node server.mjs. If an upstream call fails, the catch block keeps the last good data in memory, so the page keeps working through a short outage and the "as of" time shows how old the numbers are. A 429 response means you are over your monthly quota; rejected 401, 403 and 429 calls are not charged.
The same design works as a Cloudflare Worker: a Cron Trigger refreshes rates into KV every minute and the fetch handler serves /api/dashboard from KV. The Express version is shorter to read, so it is the one shown here.
The front end (HTML and Chart.js)
Save this as public/index.html. It loads Chart.js from a CDN, fetches /api/dashboard, renders the table and redraws every minute.
<!doctype html> <html lang="en"> <head> <meta charset="utf-8"> <title>FX dashboard</title> <script src="https://cdn.jsdelivr.net/npm/chart.js@4.4.1/dist/chart.umd.min.js"></script> <style> body { font-family: system-ui, sans-serif; max-width: 760px; margin: 2rem auto; padding: 0 1rem; } table { width: 100%; border-collapse: collapse; } td, th { padding: .5rem; border-bottom: 1px solid #ddd; text-align: right; } td:first-child, th:first-child { text-align: left; } tr { cursor: pointer; } .up { color: #15803d; } .down { color: #b91c1c; } </style> </head> <body> <h1>FX watchlist</h1> <table id="board"> <thead><tr><th>Pair</th><th>Rate</th><th>1 day</th><th>30 days</th></tr></thead> <tbody></tbody> </table> <canvas id="chart" height="140"></canvas> <p id="asof"></p> <script> let chart, selected = 0, pairs = []; const fmt = (v) => v == null ? "-" : `<span class="${v >= 0 ? "up" : "down"}">${v >= 0 ? "+" : ""}${v.toFixed(2)}%</span>`; function draw(p) { const data = { labels: p.history.map((d) => d.date), datasets: [{ label: p.pair, data: p.history.map((d) => d.rate), pointRadius: 0, tension: 0.2 }], }; if (chart) { chart.data = data; chart.update(); } else chart = new Chart(document.getElementById("chart"), { type: "line", data, options: { plugins: { legend: { display: false } } } }); } async function load() { const res = await fetch("/api/dashboard"); if (!res.ok) return; const body = await res.json(); pairs = body.pairs; document.querySelector("#board tbody").innerHTML = pairs.map((p, i) => `<tr data-i="${i}"><td>${p.pair}</td><td>${p.rate?.toFixed(4) ?? "-"}</td><td>${fmt(p.change1d)}</td><td>${fmt(p.change30d)}</td></tr>` ).join(""); document.querySelectorAll("#board tbody tr").forEach((tr) => { tr.onclick = () => { selected = Number(tr.dataset.i); draw(pairs[selected]); }; }); draw(pairs[selected]); document.getElementById("asof").textContent = "Rates as of " + new Date(body.updated).toLocaleString(); } load(); setInterval(load, 60_000); </script> </body> </html> The page never sees the key and never calls the FX rates API directly. Viewers can refresh it as often as they like; the upstream request count stays the same.
How the percent change is calculated
Both changes compare the latest rate with a point from the 30-day history:
- 1 day: the latest rate against the most recent history point dated before today.
- 30 days: the latest rate against the first point in the 30-day series.
The formula is (now - then) / then * 100. Remember that a rise in USD/EUR means the dollar strengthened against the euro. If your users think in the other direction, show 1 / rate and invert the sign. For more on quoting direction, see how to handle base currency conversion.
Request budget: refresh frequency against plan quota
The latest-rates call runs once per refresh, whatever the size of the watchlist. History runs once a day per pair. Here is the arithmetic for the latest-rates call in a 30-day month. History adds one call per pair per day on top, so leave some headroom:
| Refresh interval | Latest-rate calls per 30 days | Fits |
|---|---|---|
| Every minute | 43,200 | Large (100,000/mo) |
| Every 5 minutes | 8,640 | Medium (10,000/mo) |
| Every 15 minutes | 2,880 | Small (5,000/mo) |
| Hourly | 720 | Small (5,000/mo) |
If you want one-minute freshness on a smaller plan, refresh only while someone is looking: fetch on demand from /api/dashboard and cache the result for 60 seconds. Overnight, with no viewers, the server makes no calls. The guide on caching exchange rates to avoid rate limits covers this pattern in detail.
Where to go next
- Add more pairs: they cost nothing extra on the latest-rates call, only on history.
- Add a
1yview with weekly points, fetched once a day; it costs 12 requests per pair. - Show official central bank rates next to the mid-market rate with the central bank rates API, each with the bank's own rate date.
If you are still choosing a provider, the free currency API comparison lists update frequency and limits side by side.
FAQ
Is there a free FX rates API for a dashboard?
Yes. The AllRatesToday free plan gives you a free API key with latest mid-market rates refreshed every 60 seconds, which is enough to build and test a live watchlist. The 30-day chart uses historical rates, which are included from the Small plan at 4.99 euros a month.
How many requests does a watchlist cost?
One. Pass all targets in one call, for example target=EUR,GBP,JPY,CAD, and the API returns one array for one request.
Can I use period=1d for an intraday chart?
Only as a visual shape. The 1d series is interpolated, not real intraday data, so do not use it to report prices or compute intraday highs and lows.
Why not call the API directly from the browser?
Because the key would be visible to every visitor. Put the key on your server, have the page call your own endpoint, and you also get one upstream call per refresh instead of one per viewer.
Does the dashboard get pushed updates?
No. Delivery is REST over HTTPS, with no WebSocket or streaming. The backend polls once a minute, which matches how often the rates refresh.
Originally published at allratestoday.com.



