How to Get Exchange Rates in JavaScript and Node.js
Fetch latest and historical exchange rates with fetch in Node.js 18+, type the responses in TypeScript, cache them, and serve them to the browser without exposing your API key.
JavaScript runs in two very different places, and exchange rates need a different approach in each. On the server, Node.js can call an FX API directly with the built-in fetch. In the browser, it must not: anything you ship to the browser, including an API key, is readable by anyone who opens the developer tools. This guide covers both, with the FxFeed API: a first request, a typed client with error handling, caching, historical rates and conversion, and a small proxy that lets your front end show converted prices safely. Every snippet was run against the live API with Node.js; the TypeScript was type-checked with strict on.
What you need
- Node.js 18 or newer, which has
fetchbuilt in. No HTTP library required. - An FxFeed API key. Get a free API key: the free plan includes 1,000 requests a month with daily data, and no credit card.
Put the key in an environment variable rather than in your code:
export FXFEED_API_KEY="fxf_your_key_here"
Your first request
The API lives at https://api.fxfeed.io/v2. /latest answers the current rates for a base currency, and currencies narrows the answer to the codes you need. Save this as first-request.mjs (the .mjs extension allows top-level await):
// first-request.mjs — run with: node first-request.mjs
const API_KEY = process.env.FXFEED_API_KEY;
const url = new URL("https://api.fxfeed.io/v2/latest");
url.search = new URLSearchParams({ base: "USD", currencies: "EUR,GBP,JPY" });
const response = await fetch(url, { headers: { "X-API-Key": API_KEY } });
if (!response.ok) {
throw new Error(`FxFeed ${response.status}: ${await response.text()}`);
}
const data = await response.json();
console.log(data.date); // 2026-09-22T00:00:00Z
console.log(data.rates); // { EUR: 0.87188334, GBP: 0.74757604, JPY: 157.46992597 }
Each rate is how many units of that currency one unit of the base buys. Leave out currencies and one request returns every currency the API quotes (160+), which is what you want when you cache.
The key travels in the X-API-Key header. The API also accepts it as an api_key query parameter, as in the reference examples, but a header keeps it out of URLs, which tend to end up in logs and error trackers.
The date field says which rates you received. On the free plan /latest answers the most recent daily rates; paid plans get hourly data.
Types for the responses
These interfaces describe the success responses of the four endpoints, as the API returns them today:
// fxfeed.ts
export type Rates = Record<string, number>;
export interface RatesResponse {
success: true;
base: string;
date: string; // ISO 8601, e.g. "2026-09-22T00:00:00Z"
timestamp: number;
historical?: boolean;
rates: Rates;
}
export interface ConvertResponse {
success: true;
date: string;
historical: boolean;
query: { from: string; to: string; amount: number };
info: { rate: number; timestamp: number };
result: number;
}
export interface TimeseriesResponse {
success: true;
timeseries: true;
base: string;
start_date: string;
end_date: string;
rates: Record<string, Rates>; // keyed by "YYYY-MM-DD"
}
// Error bodies look like {"code": 402, "message": "...", "upgrade_url": "..."}
interface ErrorBody {
message?: string;
upgrade_url?: string;
}
A few details worth knowing: date is an ISO 8601 timestamp, not a bare date; time series are keyed by YYYY-MM-DD; and failures carry an HTTP status with a JSON body whose message explains the problem.
A client with error handling
A thin wrapper does three jobs: build the URL, retry when the API says "too many requests" (429), and turn every other failure into one error type you can branch on.
const BASE_URL = "https://api.fxfeed.io/v2";
export class FxFeedError extends Error {
readonly status: number;
readonly upgradeUrl?: string;
constructor(status: number, message: string, upgradeUrl?: string) {
super(`FxFeed ${status}: ${message}`);
this.name = "FxFeedError";
this.status = status;
this.upgradeUrl = upgradeUrl;
}
}
const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms));
export async function fxfeed<T>(
path: string,
params: Record<string, string | number>,
retries = 3,
): Promise<T> {
const url = new URL(BASE_URL + path);
for (const [name, value] of Object.entries(params)) {
url.searchParams.set(name, String(value));
}
let response!: Response;
for (let attempt = 0; attempt < retries; attempt++) {
response = await fetch(url, {
headers: { "X-API-Key": process.env.FXFEED_API_KEY ?? "" },
signal: AbortSignal.timeout(10_000),
});
if (response.status !== 429) break;
await sleep(1000 * 2 ** attempt); // rate limited: wait 1s, then 2s
}
const body = await response.json().catch(() => ({}));
if (!response.ok) {
const error = body as ErrorBody;
throw new FxFeedError(response.status, error.message ?? response.statusText, error.upgrade_url);
}
return body as T;
}
What the statuses mean:
| Status | Meaning | Typical handling |
|---|---|---|
| 400 | Invalid parameter, such as an unknown currency | A bug or bad input: log message |
| 401 | Missing or unknown key | Configuration problem: alert |
| 402 | Monthly request limit reached | Stop calling until next month, or upgrade via upgrade_url |
| 429 | Too many requests in a short burst | Retry after a pause (done above) |
AbortSignal.timeout makes sure a slow network cannot hang a request forever, which matters in serverless functions with a hard time limit.
One helper per endpoint
With the client in place, each endpoint is a one-liner:
export const latest = (base: string, currencies?: string[]) =>
fxfeed<RatesResponse>("/latest", {
base,
...(currencies ? { currencies: currencies.join(",") } : {}),
});
export const historical = (date: string, base: string, currencies?: string[]) =>
fxfeed<RatesResponse>("/historical", {
date,
base,
...(currencies ? { currencies: currencies.join(",") } : {}),
});
export const convert = (amount: number, from: string, to: string, date?: string) =>
fxfeed<ConvertResponse>("/convert", { amount, from, to, ...(date ? { date } : {}) });
export const timeseries = (start: string, end: string, base: string, currencies: string[]) =>
fxfeed<TimeseriesResponse>("/timeseries", {
start_date: start,
end_date: end,
base,
currencies: currencies.join(","),
});
And in use (run with Node.js 23.6 or newer, which runs TypeScript files directly, or with tsx on older versions):
import { cachedRates, convert, FxFeedError, historical, timeseries } from "./fxfeed.ts";
const rates = await cachedRates("USD");
console.log(rates.EUR, rates.GBP); // one request, both rates
const past = await historical("2024-01-02", "EUR", ["USD"]);
console.log(past.rates.USD); // 1.095601
const conversion = await convert(100, "USD", "EUR", "2024-01-02");
console.log(conversion.result); // 91.2741
try {
await historical("2024-01-02", "XXX");
} catch (err) {
if (err instanceof FxFeedError && err.status === 402) {
console.error("Monthly limit reached, upgrade at", err.upgradeUrl);
} else {
console.error((err as Error).message); // FxFeed 400: invalid base currency: XXX
}
}
/historical takes any date back to 1999. Rates are published on working days, so a weekend or bank holiday answers an empty rates object; for those dates, most accounting uses the previous working day, which you can find with a /timeseries request over the week before.
Cache, because rates do not change every second
Every request counts towards your monthly allowance, and your users do not need a fresh rate on every page view. Fetch every currency for a base once, keep it for an hour, and share it:
const ONE_HOUR = 60 * 60 * 1000;
const cache = new Map<string, { at: number; value: Promise<Rates> }>();
// Every rate for a base currency, fetched at most once an hour. Caching the
// promise means concurrent callers share one request.
export function cachedRates(base: string): Promise<Rates> {
const hit = cache.get(base);
if (hit && Date.now() - hit.at < ONE_HOUR) return hit.value;
const value = latest(base).then((r) => r.rates);
value.catch(() => cache.delete(base)); // never cache a failure
cache.set(base, { at: Date.now(), value });
return value;
}
Caching the promise rather than the result has a useful side effect: if fifty requests arrive while the first fetch is still in flight, they all wait for the same response instead of starting fifty fetches. A failed fetch is removed from the cache so the next caller tries again.
In a serverless deployment, a module-level Map lives only as long as the instance does. That is often fine for exchange rates; if not, keep the rates in Redis, your database or your platform's key-value store.
Time series
/timeseries returns every day between two dates in one request, which is enough for a chart or a quick summary:
const series = await timeseries("2024-01-01", "2024-03-31", "USD", ["EUR"]);
const days = Object.entries(series.rates)
.map(([day, dayRates]) => ({ day, eur: dayRates.EUR }))
.sort((a, b) => a.day.localeCompare(b.day));
const low = days.reduce((a, b) => (b.eur < a.eur ? b : a));
const high = days.reduce((a, b) => (b.eur > a.eur ? b : a));
console.log(`${days.length} days with rates, low ${low.eur} on ${low.day}, high ${high.eur} on ${high.day}`);
The output for the first quarter of 2024 was 63 days with rates, low 0.910166 on 2024-01-11, high 0.933445 on 2024-02-14. Weekends and holidays are simply absent from the series.
In the browser: never ship the key
A front end that calls api.fxfeed.io directly has to include the key in its JavaScript bundle, and then it is public: anyone can copy it and spend your monthly requests. Environment variables do not change that. VITE_*, NEXT_PUBLIC_* and REACT_APP_* variables are inlined into the bundle at build time, so they are exactly as visible as a string literal.
The fix is a small endpoint on your own server that holds the key, calls the API and returns only what the page needs. With Express:
// server.mjs — npm install express, then: node server.mjs
import express from "express";
const app = express();
const API_KEY = process.env.FXFEED_API_KEY; // stays on the server
const ALLOWED_BASES = new Set(["USD", "EUR", "GBP"]);
const cache = new Map(); // base -> { at, body }
app.get("/api/rates", async (req, res) => {
const base = String(req.query.base ?? "USD").toUpperCase();
if (!ALLOWED_BASES.has(base)) {
return res.status(400).json({ message: "unsupported base currency" });
}
const hit = cache.get(base);
if (!hit || Date.now() - hit.at > 60 * 60 * 1000) {
const url = `https://api.fxfeed.io/v2/latest?base=${base}`;
const upstream = await fetch(url, { headers: { "X-API-Key": API_KEY } });
if (!upstream.ok) {
return res.status(502).json({ message: "rates unavailable" });
}
const { date, rates } = await upstream.json();
cache.set(base, { at: Date.now(), body: { base, date, rates } });
}
res.set("Cache-Control", "public, max-age=3600");
res.json(cache.get(base).body);
});
app.listen(3000);
This proxy does more than hide the key:
- It allows only the base currencies your site uses, so nobody can use your endpoint as a free general-purpose FX API.
- It caches for an hour, so a thousand visitors cost one upstream request per base currency per hour.
- It sets
Cache-Control, so browsers and a CDN in front of your server can reuse the answer too. - It hides upstream errors behind a generic 502 instead of passing the API's details to the page.
The browser code then calls your endpoint, with no key anywhere:
// In the browser: call your own endpoint, never api.fxfeed.io with a key.
const response = await fetch("/api/rates?base=USD");
const { date, rates } = await response.json();
const price = 49.0; // USD
const inEuro = new Intl.NumberFormat("de-DE", { style: "currency", currency: "EUR" })
.format(price * rates.EUR);
console.log(`${inEuro} (rate of ${date.slice(0, 10)})`);
With a rates.EUR of 0.87188334, that printed 42,72 € (rate of 2026-09-22). Intl.NumberFormat handles the currency symbol, decimals and locale conventions; always show which day's rate a converted price uses.
The same pattern works in any server framework: a Next.js route handler, a Cloudflare Worker, a Lambda behind API Gateway. What matters is that the key lives in server-side configuration and the browser only ever sees your own endpoint.
Checklist
- Use the built-in
fetchin Node.js 18+; set a timeout on every request. - Keep the key in server-side environment variables. Never put it in a browser bundle, including
NEXT_PUBLIC_/VITE_variables. - Cache latest rates for an hour (or a day on daily data) and past days indefinitely.
- Treat 402 (monthly limit) and 429 (slow down) differently.
- Show the date of the rate next to any converted amount.
Next steps
- The API reference documents every parameter and response.
- Building a React UI on top of the proxy? The React currency converter tutorial walks through the components.
- Prefer Python on the back end? See How to Get Exchange Rates in Python.
- Browse the supported currencies, or see how FxFeed compares with other FX APIs.
Get a free API key and run first-request.mjs: you will have live rates in your terminal in about a minute.
Ready to integrate FX rates?
Start using FxFeed.io today with our free tier. No credit card required.