What you need
Frankfurter gives the daily reference rates of the European Central Bank for about 30 major currencies, free, without a key and without usage limits. For other currencies, the open access endpoint of ExchangeRate-API (open access) covers more than 160 currencies without a key; it asks for a link back as attribution. Both allow calls from web pages (CORS).
Step 1: get the rates
Ask Frankfurter for the latest rates from one base currency. symbols limits the answer to the currencies you need; leave it out to get all of them. For a currency the ECB does not publish, Frankfurter answers with status 404.
curl
curl 'https://api.frankfurter.dev/v1/latest?base=EUR&symbols=USD,GBP'
Step 2: the other currencies
ExchangeRate-API's open access endpoint returns all its rates for a base currency, with result set to success, or error for a code it does not know. The rates are updated once a day, and time_next_update_utc says when the next update is due.
curl
curl 'https://open.er-api.com/v6/latest/EUR'
Step 3: the converter
This page converts with Frankfurter where it can and falls back to ExchangeRate-API. Rates change once a day, so the script keeps them in localStorage per base currency and day, and asks again only the next day. The line under the form gives both sources the credit they ask for.
HTML
<form id="converter">
<input id="amount" type="number" value="100" min="0" step="any" required>
<input id="from" value="EUR" size="4" maxlength="3" required>
to
<input id="to" value="USD" size="4" maxlength="3" required>
<button>Convert</button>
</form>
<p id="result"></p>
<p><small>Rates: European Central Bank via Frankfurter, other currencies
<a href="https://www.exchangerate-api.com">Rates By Exchange Rate API</a>.</small></p>
<script>
// The rates for one base currency, kept in the browser for the day
async function getRates(base, wanted) {
const key = 'rates-' + base + '-' + new Date().toISOString().slice(0, 10);
let rates = null;
try { rates = JSON.parse(localStorage.getItem(key)); } catch (e) {}
if (rates && wanted in rates) {
return rates;
}
const ecb = await fetch('https://api.frankfurter.dev/v1/latest?base=' + base);
rates = ecb.ok ? (await ecb.json()).rates : {};
if (!(wanted in rates)) {
const open = await (await fetch('https://open.er-api.com/v6/latest/' + base)).json();
if (open.result !== 'success') {
throw new Error('Unknown currency: ' + base);
}
rates = open.rates;
}
try { localStorage.setItem(key, JSON.stringify(rates)); } catch (e) {}
return rates;
}
async function convert(amount, from, to) {
if (from === to) {
return amount;
}
const rates = await getRates(from, to);
if (!(to in rates)) {
throw new Error('Unknown currency: ' + to);
}
return amount * rates[to];
}
document.getElementById('converter').addEventListener('submit', async (event) => {
event.preventDefault();
const amount = Number(document.getElementById('amount').value);
const from = document.getElementById('from').value.trim().toUpperCase();
const to = document.getElementById('to').value.trim().toUpperCase();
const result = document.getElementById('result');
try {
const converted = await convert(amount, from, to);
result.textContent = amount + ' ' + from + ' = ' + converted.toFixed(2) + ' ' + to;
} catch (error) {
result.textContent = error.message;
}
});
</script>
Going further
For prices in a shop, convert on your server and keep one set of rates for everyone, instead of letting every visitor's browser ask. Frankfurter is open source, so you can also run your own copy.
Frankfurter also has history: /v1/2024-01-02 gives the rates of that day, and /v1/2024-01-01..2024-01-31 a whole period, back to 1999. The ECB Data Portal API gives the same reference rates, and many other euro area statistics, straight from the source.
Need rates during the day or many currencies with a key and more features? Open Exchange Rates has a free plan with hourly updates.