Open a browser console and type 0.1 + 0.2. You get 0.30000000000000004. Most of the time that last digit is harmless. In a currency converter it is the difference between a total that reconciles and one that is a cent out, and a cent out across ten thousand invoices is a support queue.
This project is a converter you run from the terminal: node convert.js 19.99 USD JPY. It fetches a live rate, caches it for ten minutes, does the arithmetic without ever touching a floating-point number, and prints a correctly formatted result. Then you will wrap the same code in a small web endpoint. The API calls are the easy part. The interesting work is in what you do with the numbers once they arrive.
Picking the right API
The catalog has two APIs that look interchangeable, and the difference between them is exactly the subject of this lesson.
Currency Converter (currencyconverter) takes value, from and to and does the multiplication for you. It returns convertedValue, rate, changeDirection, date and lastUpdated; the 24-hour change, high and low are premium.
curl "https://api.apiverve.com/v1/currencyconverter?value=19.99&from=USD&to=JPY" \
-H "x-api-key: YOUR_API_KEY"convertedValue is a raw float with many decimal places. Rounding it is your job.
When this lesson was written, that call returned a convertedValue of 3140.6347794587996. That is not a price. Nobody pays 0.6347794587996 of a yen, and the converter is doing the arithmetic with more precision than the rate it shows you, so multiplying value by rate yourself gives a slightly different float. Fine for a quick display, but you would still have to round it, and you would be rounding a number whose history you cannot see.
Exchange Rate (exchangerate) takes currency1 and currency2 and returns just the rate. The detail that makes it the better fit here: exchangeRate is a string, like "0.891915". A string can be parsed straight into an exact integer representation without ever becoming a float. The inverse rate is premium; you will not need it.
curl "https://api.apiverve.com/v1/exchangerate?currency1=USD¤cy2=EUR" \
-H "x-api-key: YOUR_API_KEY"exchangeRate arrives as a string with six decimal places. Parse it as text, not with parseFloat.
Both are 2 credits per call in the catalog, and both accept a premium date for historical rates. This project uses exchangerate and does its own maths.
The third API, Currency Symbols (currencysymbols), maps a currency code to the countries that use it. On plans that include them, it also returns currency_symbol and currency_mnr_unts, the number of minor units, which is exactly the "how many decimal places does this currency have" fact you need.
curl "https://api.apiverve.com/v1/currencysymbols?currency=JPY" \
-H "x-api-key: YOUR_API_KEY"countriesFound lists who uses the yen. The symbol and minor units are premium fields.
Because those two fields are premium, the Node code gets the same facts from Intl.NumberFormat, which ships with Node and knows every ISO 4217 currency. The Python version, which has no equivalent in the standard library, is where Currency Symbols earns its place.
The money rules
Three rules, and the code follows directly from them.
Store and compute in minor units. A minor unit is the smallest denomination: cents for USD, yen for JPY (which has no subunit), fils for KWD (which has three decimal places). $19.99 is the integer 1999. Integers add, compare and multiply exactly. In JavaScript you use BigInt so even very large amounts never lose precision; in Python, int or Decimal.
Never parse money through a float. parseFloat("19.99") is already approximately 19.99. Parse the string's digits directly into an integer, and parse the rate the same way: "0.891915" is 891915 over 10^6.
Round once, at the end, with a stated rule. The result of multiplying two exact numbers is exact; only the final conversion back to minor units needs rounding. This project rounds half-up. Some accounting contexts require half-to-even (banker's rounding), which avoids a systematic upward bias over many transactions. Whichever you pick, pick it deliberately and write it down.
A user converts 19.99 USD. Which first step keeps the calculation exact?
Answer the check above to continue.
Step 2: rates with a ten-minute cache
A command-line tool exits after each run, so an in-memory cache would be empty every time. This one keeps its cache in a small JSON file next to the script. If a refresh fails and an older rate exists, it serves that rate and says so, rather than leaving the user with nothing.
const BASE = 'https://api.apiverve.com/v1';
export async function verve(api, params, { timeoutMs = 10000 } = {}) {
const key = process.env.APIVERVE_API_KEY;
if (!key) throw new Error('APIVERVE_API_KEY is not set');
const res = await fetch(`${BASE}/${api}?${new URLSearchParams(params)}`, {
headers: { 'x-api-key': key },
signal: AbortSignal.timeout(timeoutMs),
});
const body = await res.json().catch(() => null);
if (!res.ok || body?.status !== 'ok') {
const outOfCredits = res.status === 429 && res.headers.get('x-api-remaining-credits') === '0';
throw new Error(outOfCredits ? 'Out of credits for this month' : body?.error || `HTTP ${res.status}`);
}
return body.data;
}import { readFile, writeFile } from 'node:fs/promises';
import { verve } from './verve.js';
import { minorDigits, toMinor, precision, convertMinor, formatMinor } from './money.js';
const FILE = new URL('./rates-cache.json', import.meta.url);
const TTL_MS = 10 * 60 * 1000;
const MAX_STALE_MS = 24 * 60 * 60 * 1000;
let cache;
async function loadCache() {
if (!cache) {
try { cache = JSON.parse(await readFile(FILE, 'utf8')); } catch { cache = {}; }
}
return cache;
}
export async function getRate(from, to) {
const c = await loadCache();
const key = `${from}:${to}`;
const hit = c[key];
if (hit && Date.now() - hit.fetchedAt < TTL_MS) return { ...hit, stale: false };
try {
const d = await verve('exchangerate', { currency1: from, currency2: to });
c[key] = { rate: d.exchangeRate, lastUpdated: d.lastUpdated, fetchedAt: Date.now() };
await writeFile(FILE, JSON.stringify(c, null, 2));
return { ...c[key], stale: false };
} catch (err) {
if (hit && Date.now() - hit.fetchedAt < MAX_STALE_MS) return { ...hit, stale: true };
throw err;
}
}
export async function convert(amount, from, to) {
from = from.toUpperCase();
to = to.toUpperCase();
const fromDigits = minorDigits(from);
const toDigits = minorDigits(to);
const minor = toMinor(amount, fromDigits);
let used = await getRate(from, to);
let divide = false;
if (precision(used.rate) < 6) {
const reverse = await getRate(to, from);
if (precision(reverse.rate) > precision(used.rate)) {
used = reverse;
divide = true;
}
}
const out = convertMinor(minor, fromDigits, toDigits, used.rate, { divide });
return {
from: formatMinor(minor, fromDigits, from),
to: formatMinor(out, toDigits, to),
minorUnits: out.toString(),
asOf: used.lastUpdated,
stale: used.stale,
};
}The cache key is the ordered pair. USD:EUR and EUR:USD are separate entries, because deriving one from the other by division would add a rounding step you do not need. Ten minutes matches how fast these rates move for display purposes; if you are pricing a checkout you might want shorter, and the lastUpdated timestamp is always shown so the user knows what they are looking at.
Step 3: the command-line tool
import { convert } from './rates.js';
const [amount, from, to] = process.argv.slice(2);
if (!amount || !from || !to) {
console.error('Usage: node convert.js <amount> <FROM> <TO> e.g. node convert.js 19.99 USD JPY');
process.exit(1);
}
try {
const r = await convert(amount, from, to);
console.log(`${r.from} = ${r.to}`);
console.log(`rate as of ${r.asOf}${r.stale ? ' (could not refresh, showing the last known rate)' : ''}`);
} catch (err) {
console.error(`Conversion failed: ${err.message}`);
process.exit(1);
}Set up and run it. The project is an ES module so the top-level await works.
mkdir fx && cd fx
npm init -y
npm pkg set type=module
export APIVERVE_API_KEY=YOUR_API_KEY
node convert.js 19.99 USD JPY
node convert.js 10000000 VND USDRun the first command twice in a row. The second run reads the rate from rates-cache.json and makes no API call at all.
The Python version uses Decimal, which does exact decimal arithmetic natively, and takes the minor-unit count from Currency Symbols when your plan returns it, falling back to a small table when it does not.
import json, os, sys, time
from decimal import Decimal, ROUND_HALF_UP
from pathlib import Path
import requests
BASE = "https://api.apiverve.com/v1"
CACHE = Path(__file__).with_name("rates-cache.json")
TTL = 600
FALLBACK_DIGITS = {"JPY": 0, "KRW": 0, "VND": 0, "KWD": 3, "BHD": 3, "OMR": 3}
def verve(api, params):
res = requests.get(f"{BASE}/{api}", params=params,
headers={"x-api-key": os.environ["APIVERVE_API_KEY"]}, timeout=10)
body = res.json()
if res.status_code != 200 or body.get("status") != "ok":
raise RuntimeError(body.get("error") or f"HTTP {res.status_code}")
return body["data"]
def minor_digits(code):
try:
found = verve("currencysymbols", {"currency": code}).get("countriesFound") or []
units = found[0].get("currency_mnr_unts") if found else None # premium field
if units is not None and str(units).isdigit():
return int(units)
except Exception:
pass
return FALLBACK_DIGITS.get(code, 2)
def get_rate(a, b):
cache = json.loads(CACHE.read_text()) if CACHE.exists() else {}
hit = cache.get(f"{a}:{b}")
if hit and time.time() - hit["fetchedAt"] < TTL:
return Decimal(hit["rate"])
rate = verve("exchangerate", {"currency1": a, "currency2": b})["exchangeRate"]
cache[f"{a}:{b}"] = {"rate": rate, "fetchedAt": time.time()}
CACHE.write_text(json.dumps(cache, indent=2))
return Decimal(rate)
def significant(d):
return len(d.normalize().as_tuple().digits)
def convert(amount, src, dst):
src, dst = src.upper(), dst.upper()
value = Decimal(amount)
rate = get_rate(src, dst)
if significant(rate) < 6:
reverse = get_rate(dst, src)
result = value / reverse if significant(reverse) > significant(rate) else value * rate
else:
result = value * rate
places = Decimal(1).scaleb(-minor_digits(dst))
return result.quantize(places, rounding=ROUND_HALF_UP)
if __name__ == "__main__":
amount, src, dst = sys.argv[1:4]
print(f"{amount} {src.upper()} = {convert(amount, src, dst)} {dst.upper()}")Decimal(amount) is built from the string the user typed, never from a float, for the same reason as the JavaScript version. quantize is the single rounding step, and ROUND_HALF_EVEN is one word away if your accountant prefers it.
Harden it and put it on the web
The converter is already careful about money. A few more things make it safe to expose.
Validate currency codes before spending credits. Check the input against /^[A-Z]{3}$/ before calling anything. Intl.NumberFormat throws a RangeError on a malformed code, and that is better caught on your side than discovered as a failed API call.
Show the timestamp, always. lastUpdated tells the user how fresh the rate is. When stale is true, say so plainly. A converter that silently shows yesterday's rate is worse than one that admits it.
Keep amounts as strings across the wire. If you put this behind a web form, send "19.99" in JSON, not 19.99. A JSON number is parsed into a float by the time your handler sees it.
import express from 'express';
import { convert } from './rates.js';
const app = express();
app.use(express.json());
app.post('/api/convert', async (req, res) => {
const { amount, from, to } = req.body ?? {};
if (typeof amount !== 'string' || !/^[A-Z]{3}$/i.test(from ?? '') || !/^[A-Z]{3}$/i.test(to ?? '')) {
return res.status(400).json({ error: 'Send amount as a string and two 3-letter currency codes.' });
}
try {
res.json(await convert(amount, from, to));
} catch (err) {
res.status(502).json({ error: err.message });
}
});
app.listen(3000, () => console.log('POST http://localhost:3000/api/convert'));Install Express (npm install express@4) and test it with curl -X POST localhost:3000/api/convert -H 'Content-Type: application/json' -d '{"amount":"19.99","from":"USD","to":"EUR"}'. The key stays on the server, and the browser only ever sees results.
For what each error status means, and how to tell a rate limit from an empty credit balance, see error handling.
Ideas to extend it
- Historical prices. On plans that include the
dateparameter, convert an invoice at the rate on the day it was issued rather than today. - Multi-currency price list. Convert one base price into ten currencies, cache each pair, and round each result to a "nice" local price like ¥3,100 as a separate, explicit step.
- Trend arrow.
currencyconverterreturnschangeDirectionon every plan. Show an up or down arrow next to the rate without doing any arithmetic on it. - Test the edge cases. Write unit tests for
convertMinorwith JPY, KWD and VND before you change it. Money code without tests drifts.
Next you will generate images rather than numbers, and learn what to do with a download link that expires. Continue to Generate QR codes.