Skip to content
Back to Blog
•11 min read

JavaScript Currency Converter API: Node.js and TypeScript

Build a JavaScript currency converter with Node.js fetch. Add historical rates, TypeScript responses and caching while keeping your API key on the server.

Updated

Build a JavaScript currency converter by fetching rates on your Node.js server and multiplying the source amount by the target rate. Keep your API key on the server: a browser bundle exposes it to anyone who opens developer tools. This guide covers a first request, conversion and historical rates, caching and a browser-safe proxy. The printed values are examples recorded on September 22, 2026; current rates will differ.

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"

Get exchange rates with JavaScript fetch

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.

Convert currency and request historical rates

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 coverage varies by currency. Check the coverage table for both currencies rather than assuming every pair goes back to 1999. A date within that span can still have no observation. If rates is empty or the target is absent, report missing data. If your workflow permits a previous available rate, request a bounded time series, select the latest usable observation on or before the requested date, and retain its actual date. The Python example shows that selection and its no-data case.

Cache exchange rates in Node.js

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: keep the API key on the server

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). The multiplication price * rates.EUR performs the conversion. Intl.NumberFormat only formats the result; changing its currency option does not fetch or apply an exchange rate. Always show the rate date. For bookkeeping calculations, use decimal arithmetic and an explicit rounding policy rather than relying on binary floating-point display rounding.

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); give historical data a refresh path for corrections and backfills.
  • 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.