Skip to content
Back to Blog
11 min read

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 fetch built 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 fetch in 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

Get a free API key and run first-request.mjs: you will have live rates in your terminal in about a minute.

Share this article:
All Articles

Ready to integrate FX rates?

Start using FxFeed.io today with our free tier. No credit card required.