Skip to content
Back to Blog
•9 min read

Python Exchange Rate API: Currency Conversion and History

Fetch exchange rates in Python, convert currencies by date and load a pandas time series. Includes API key setup, caching and missing-rate handling.

Updated

Use Python's requests library to fetch a rate, then multiply the source amount by the target rate. This guide uses the FxFeed API for latest rates, historical dates, currency conversion and a pandas time series. The printed rate values are examples recorded on September 22, 2026; current responses will differ.

What you need

  • Python 3.9 or newer
  • pip install requests pandas (pandas only for the last section)
  • 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.

Keep the key out of your code. Put it in an environment variable and read it from there:

export FXFEED_API_KEY="fxf_your_key_here"

Get exchange rates in Python

The API lives at https://api.fxfeed.io/v2. The /latest endpoint answers the current rates for a base currency; currencies narrows the answer to the codes you need.

import os

import requests

API_KEY = os.environ["FXFEED_API_KEY"]
BASE_URL = "https://api.fxfeed.io/v2"

response = requests.get(
    f"{BASE_URL}/latest",
    params={"base": "USD", "currencies": "EUR,GBP,JPY"},
    headers={"X-API-Key": API_KEY},
    timeout=10,
)
response.raise_for_status()
data = response.json()

print(data["date"])   # 2026-09-22T00:00:00Z
print(data["rates"])  # {'EUR': 0.87188334, 'GBP': 0.74757604, 'JPY': 157.46992597}

A trimmed response looks like this:

{
  "success": true,
  "base": "USD",
  "date": "2026-09-22T00:00:00Z",
  "timestamp": 1790035200,
  "rates": { "EUR": 0.87188334, "GBP": 0.74757604, "JPY": 157.46992597 }
}

Each rate says how many units of that currency one unit of the base buys: 1 USD bought 0.8719 EUR. Leave out currencies and you get every currency the API quotes (160+) in the same single request, which matters once you start caching.

On the free plan /latest answers the most recent daily rates; paid plans get hourly data. The date field tells you which rates you have, so show it next to any converted price.

A small client with real error handling

raise_for_status() is fine for a script, but an application should know why a request failed. The API answers failures with an HTTP status and a JSON body carrying a message:

Status Meaning What to do
400 Invalid parameter, e.g. an unknown currency Fix the request; the message says what is wrong
401 Missing or unknown API key Check the key
402 Monthly request limit reached Upgrade (the body carries an upgrade_url) or wait for the next month
429 Too many requests in a short time Back off and retry

This helper sends the key in the X-API-Key header instead of the query string, so it never ends up in URLs you log, retries a 429 with backoff, and turns every other failure into one exception type:

import time

import requests


class FxFeedError(Exception):
    """A request the FxFeed API refused, with its HTTP status."""

    def __init__(self, status, message, upgrade_url=None):
        super().__init__(f"{status}: {message}")
        self.status = status
        self.upgrade_url = upgrade_url


session = requests.Session()
session.headers["X-API-Key"] = API_KEY  # keeps the key out of URLs and logs


def fxfeed_get(path, params, retries=3):
    """GET an FxFeed endpoint and return its JSON, or raise FxFeedError."""
    for attempt in range(retries):
        response = session.get(f"{BASE_URL}{path}", params=params, timeout=10)
        if response.status_code != 429:
            break
        time.sleep(2 ** attempt)  # rate limited: wait 1s, then 2s

    try:
        body = response.json()
    except ValueError:
        body = {}

    if response.ok:
        return body

    message = body.get("message", response.reason)
    raise FxFeedError(response.status_code, message, body.get("upgrade_url"))

Using it:

try:
    fxfeed_get("/latest", {"base": "XXX"})
except FxFeedError as err:
    print(err)  # 400: invalid base currency: XXX
    if err.status == 402:
        print("Monthly limit reached, upgrade at", err.upgrade_url)

A 402 is worth handling separately: the request limit resets at the start of the next month (UTC), so a background job can stop and report instead of retrying all day.

Cache rates instead of asking every time

Exchange rates do not change between two requests a second apart, and every request counts towards your monthly allowance. Two rules cover most applications:

  1. Latest rates: fetch every currency for a base once, and keep the answer for an hour (or a day on the free plan's daily data).
  2. Historical rates: cache for longer, but allow refreshes for source corrections and backfills. Store the exact rate and observation date used for a completed calculation separately.
from functools import lru_cache

LATEST_TTL = 60 * 60  # seconds
_latest = {}


def latest_rates(base="USD"):
    """Every latest rate for base, fetched at most once an hour."""
    hit = _latest.get(base)
    if hit and time.monotonic() - hit[0] < LATEST_TTL:
        return hit[1]
    rates = fxfeed_get("/latest", {"base": base})["rates"]
    _latest[base] = (time.monotonic(), rates)
    return rates


@lru_cache(maxsize=1024)
def historical_rates(day, base="USD"):
    """Cache historical responses for this process; clear when refreshing data."""
    return fxfeed_get("/historical", {"base": base, "date": day})["rates"]


print(latest_rates("USD")["EUR"])
print(latest_rates("USD")["GBP"])  # from the cache: no second request
print(historical_rates("2024-01-02", "EUR")["USD"])  # 1.095601

Because latest_rates asks for every currency at once, requests sharing a cached base reuse the same response. In a web application with several workers, use a shared cache and coordinate concurrent misses. Clear the example's historical cache with historical_rates.cache_clear() when you need a refresh; for a long-running service, use a bounded expiry.

Historical exchange rates and missing dates

The /historical endpoint takes a date in YYYY-MM-DD form and returns "historical": true. Check coverage for both currencies: some history reaches back to 1999, but many currencies begin later. Coverage endpoints describe the available span, not a guarantee that every day inside it has a rate.

An exact date can have an empty rates object, including on weekends or holidays. If your workflow permits the previous available rate, query the preceding week and keep the actual date alongside the result. This example explicitly fails when the pair has no observation in that window:

from datetime import date, timedelta


def rate_on_or_before(day, base, currency):
    """The rate on day, or on the last working day before it."""
    start = (date.fromisoformat(day) - timedelta(days=7)).isoformat()
    series = fxfeed_get(
        "/timeseries",
        {"base": base, "currencies": currency, "start_date": start, "end_date": day},
    )["rates"]
    eligible = [
        d for d, values in series.items()
        if start <= d <= day and values.get(currency, 0) > 0
    ]
    if not eligible:
        raise ValueError(f"No {base}/{currency} rate from {start} through {day}")
    last = max(eligible)
    return last, series[last][currency]


print(historical_rates("2024-01-06", "USD"))          # {} (a Saturday)
print(rate_on_or_before("2024-01-06", "USD", "EUR"))  # ('2024-01-05', 0.915667)

Convert currency with the Python API

The API has a /convert endpoint that does the multiplication for you, optionally on a past date:

result = fxfeed_get(
    "/convert", {"from": "USD", "to": "EUR", "amount": 100, "date": "2024-01-02"}
)
print(result["result"])        # 91.2741
print(result["info"]["rate"])  # 0.912741

That is convenient for one-off conversions, but each call is a request. When you convert many amounts, convert locally with a cached rate, and use Decimal so money does not pick up floating-point noise:

from decimal import ROUND_HALF_UP, Decimal


def convert(amount, from_currency, to_currency, day=None, decimals=2):
    """Convert with a cached rate, keeping money in Decimal."""
    if day:
        rates = historical_rates(day, from_currency)
    else:
        rates = latest_rates(from_currency)
    if rates.get(to_currency, 0) <= 0:
        raise ValueError(f"No {from_currency}/{to_currency} rate for {day or 'latest'}")
    rate = Decimal(str(rates[to_currency]))
    quantum = Decimal(1).scaleb(-decimals)
    return (Decimal(amount) * rate).quantize(quantum, rounding=ROUND_HALF_UP)


print(convert("100.00", "EUR", "USD", "2024-01-02"))  # 109.56

Pass the amount as a decimal string and round only at the end. The example defaults to two decimals for EUR and USD; use decimals=0 for JPY or the precision supplied by the currency metadata. If you show a converted price to a customer, retain and display the observation date too.

Load an exchange-rate time series into pandas

For charts, reports or a quick volatility check, /timeseries returns every day between start_date and end_date in one request, keyed by date. That shape drops straight into a DataFrame:

import pandas as pd

data = fxfeed_get(
    "/timeseries",
    {
        "base": "USD",
        "currencies": "EUR,GBP",
        "start_date": "2024-01-01",
        "end_date": "2024-03-31",
    },
)

df = pd.DataFrame.from_dict(data["rates"], orient="index")
df.index = pd.to_datetime(df.index)
df = df.sort_index()

print(df.head(3))
monthly = df.resample("MS").mean()  # average rate per calendar month
moves = df.pct_change().dropna()    # day-to-day changes
print(monthly.round(4))
print(moves["EUR"].std())           # daily volatility of USD/EUR

The output starts like this:

                 EUR       GBP
2024-01-02  0.912741  0.790845
2024-01-03  0.915834  0.791922
2024-01-04  0.912991  0.787711

Note that 1 January is missing. resample("MS").mean() averages the available observations, not every calendar day, and a partial month remains partial. Inspect df.resample("MS").count() alongside the mean. If your workflow needs a carried-forward calendar-day series, make that policy explicit before using df.asfreq("D").ffill(). Neither calculation is an official tax-authority average.

From there it is ordinary pandas: df.plot() for a chart, df["EUR"].rolling(20).mean() for a moving average, or a join against your own transactions on the date column to value each one at its day's rate.

Production checklist

  • Keep the key in the environment or a secret manager, never in the repository, and never in code that runs in a browser.
  • Set a timeout on every request (timeout=10 above) so a network problem cannot hang a worker.
  • Cache: reuse rates by base and date, with expiry and a refresh path for historical corrections.
  • Handle 402 and 429 differently: 429 means "slow down", 402 means "no more requests this month".
  • Store the date of the rate you used alongside any converted amount, so you can explain a figure later.
  • Use Decimal for money and round once, at the end.

Where to go next

Ready to try it with your own data? Get a free API key and run the first snippet: it takes 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.