Skip to content
Best practice

Caching exchange rates and handling API rate limits

Most applications need one exchange rate API call per update interval – not one per page view. Here’s how to choose TTLs, survive 429s and outages, and keep an audit trail of the rate you used.

Last updated: · fxapi team

Caching exchange rates is the single biggest lever for keeping an exchange rate integration fast, cheap and reliable. Rates only change as often as your data source updates them, so calling the API on every page view wastes quota and adds latency – and when the API or your network hiccups, a cache is what keeps your checkout running. This guide covers TTL choice, the one-request pattern, stale-while-revalidate, fxapi rate limits and 429 handling, auditability and a Redis implementation.

Caching exchange rates: pick the TTL from your plan

A cached rate can’t be “more stale” than the data behind it. Set the TTL to the plan’s update interval and you lose nothing:

PlanRates updateSuggested TTLRequests/month if one process polls at that TTLMonthly quota
Freedaily24 h (or until next update)~30300
Basichourly1 h~72015,000
Professionalevery 60 s60 s~43,200600,000
Enterpriseevery 60 s60 s~43,2001,700,000

Even polling every minute around the clock uses a fraction of the Professional quota – if the cache is shared. The usual quota killer is a per-process or per-user cache in a fleet of workers or serverless functions. Use a shared store (Redis, Memcached, a database row) so the whole fleet makes one call per TTL. Plans and prices: pricing.

Tip: every response has meta.last_updated_at. Instead of a fixed TTL you can expire the cache at last_updated_at + update interval, so you refresh right when new data is due.

One request for all currencies

Don’t fetch EUR→USD, EUR→GBP and GBP→JPY separately. Call /v1/latest once without currencies and you get all 190+ currencies relative to one base. Every other pair is a division:

rate(A → B) = rate(base → B) / rate(base → A)

with base = USD:  EUR→GBP = data.GBP.value / data.EUR.value

One cached response then serves every currency pair in your app. Details and precision notes: base currency and cross rates and currency rounding and precision.

Cache historical rates forever

A historical rate for a past date is final. Key it by base:date (for example fx:hist:USD:2024-12-31) and store it without expiry. If you repeatedly need series, one /v1/range or /v1/average request replaces hundreds of single-day calls.

Stale-while-revalidate

Use two lifetimes per cache entry:

  • Soft TTL – after this, the entry is due for refresh (your plan’s update interval).
  • Hard TTL – how long you’re willing to serve an old value if refreshing fails (hours or days, depending on your business).

When the soft TTL has passed, exactly one worker refreshes while everyone else keeps getting the cached value. If the refresh fails – network error, 5xx, 429 – keep serving the old value until the hard TTL. Users never wait on the exchange rate API, and a brief outage doesn’t break checkout.

Redis example (Python)

import json
import os
import time

import redis
import requests

r = redis.Redis.from_url(os.environ.get("REDIS_URL", "redis://localhost:6379/0"))

KEY = "fx:latest:USD"
SOFT_TTL = 3600           # Basic plan: data updates hourly
HARD_TTL = 3 * 24 * 3600  # serve stale for up to three days if refreshes fail


def fetch_latest():
    resp = requests.get(
        "https://api.fxapi.com/v1/latest",
        params={"base_currency": "USD"},  # all currencies in one request
        headers={"apikey": os.environ["FXAPI_KEY"]},
        timeout=10,
    )
    resp.raise_for_status()
    return resp.json()


def get_rates(wait_for_cold_start=3.0):
    raw = r.get(KEY)
    entry = json.loads(raw) if raw else None
    if entry and time.time() - entry["fetched_at"] < SOFT_TTL:
        return entry["payload"]  # fresh

    # Due for refresh: only the worker that wins the lock calls the API
    if r.set(KEY + ":lock", "1", nx=True, ex=30):
        try:
            payload = fetch_latest()
            r.set(KEY, json.dumps({"fetched_at": time.time(), "payload": payload}), ex=HARD_TTL)
            return payload
        except requests.RequestException:
            if entry:
                return entry["payload"]  # stale but usable
            raise
        finally:
            r.delete(KEY + ":lock")

    if entry:
        return entry["payload"]  # another worker is refreshing; serve stale

    # Cold start and someone else holds the lock: wait briefly for their result
    deadline = time.time() + wait_for_cold_start
    while time.time() < deadline:
        time.sleep(0.2)
        raw = r.get(KEY)
        if raw:
            return json.loads(raw)["payload"]
    raise RuntimeError("exchange rates unavailable")

For full background revalidation, move the refresh into a scheduled job (cron, Celery beat, a Kubernetes CronJob) that runs at the soft TTL, and let request handlers only read. Language-specific in-process caches are in the Python, JavaScript, Go (Golang) and C# guides.

Exchange rate API rate limits and quota headers

fxapi enforces a monthly request quota per plan. The Free plan is additionally limited to 10 requests per minute; paid plans have no per-minute limit. Only successful calls count – validation errors (422) and server errors don’t.

Every response carries headers you can log and alert on:

HeaderMeaning
X-RateLimit-Limit-Quota-MonthYour monthly quota
X-RateLimit-Remaining-Quota-MonthRequests left this month
X-CostQuota units this call used
X-Execution-TimeServer-side processing time

On the Free plan, per-minute headers are included too. /v1/status returns your month and grace quota buckets (total, used, remaining) and doesn’t count against the quota – ideal for a monitoring check.

Handling HTTP 429

A 429 Too Many Requests means a limit was reached. Handle the two cases differently:

def handle_429(resp, cached_payload):
    remaining = int(resp.headers.get("X-RateLimit-Remaining-Quota-Month", "0") or 0)
    if remaining > 0:
        # Free plan per-minute limit: retry after a short back-off
        time.sleep(15)
        return None  # caller retries once
    # Monthly quota exhausted: retrying won't help until the quota resets or you upgrade
    notify_ops("fxapi monthly quota exhausted")  # your alerting hook
    return cached_payload
  • Per-minute limit (Free plan): back off for 10–60 seconds and retry once; better, add a cache so you never send bursts.
  • Monthly quota exhausted: stop retrying, serve the cached rates, alert a human and consider a larger plan.
  • Never retry in a tight loop. Retrying 401, 403 and 422 is pointless as well – those need a configuration or input fix.

Store the rate with every transaction

A cache answers “what’s the rate now?”. Finance and support will later ask “which rate did we use?”. Persist the rate and its timestamp with each order, invoice or payment:

CREATE TABLE orders (
    id              bigserial      PRIMARY KEY,
    amount_minor    bigint         NOT NULL,  -- charged amount in minor units
    currency        char(3)        NOT NULL,  -- ISO 4217 code, e.g. EUR
    base_currency   char(3)        NOT NULL,  -- currency the price was defined in
    fx_rate         numeric(24,12) NOT NULL,  -- exact rate applied
    fx_rate_as_of   timestamptz    NOT NULL,  -- meta.last_updated_at of that rate
    created_at      timestamptz    NOT NULL DEFAULT now()
);

Storing fx_rate_as_of makes every conversion reproducible, explains differences between quotes and invoices, and supports audits. Lock the rate at checkout so the customer pays what they were shown – see multi-currency pricing.

Checklist

  1. One shared cache per base currency, TTL = plan update interval.
  2. One request for all currencies; compute cross rates locally.
  3. Cache historical dates forever.
  4. Serve stale values on errors and 429s, up to a hard limit.
  5. Log X-RateLimit-Remaining-Quota-Month; monitor with /v1/status.
  6. Persist rate and last_updated_at with each transaction.
  7. Keep the API key on the server – never in browser or mobile code.

Frequently asked questions

How long should I cache exchange rates?
For as long as the data can’t change: one day on the Free plan, one hour on Basic and 60 seconds on Professional and Enterprise. Historical rates for past dates never change, so cache them indefinitely.
What are fxapi's rate limits?
Each plan has a monthly request quota (300 on Free up to 1,700,000 on Enterprise). The Free plan is also limited to 10 requests per minute; paid plans have no per-minute limit. Exceeding a limit returns HTTP 429.
Do failed requests count against my quota?
No. Only successful calls count; validation errors (422) and server errors don’t. Calls to /v1/status are free as well.
How do I know how many requests I have left?
Read the X-RateLimit-Remaining-Quota-Month header on any response, or call /v1/status, which returns monthly and grace quota buckets and doesn’t count against the quota.
Should each user's request call the exchange rate API?
No. Fetch all currencies for one base currency on your server, cache the result, and serve every user from that cache. Cross rates can be computed locally.
Free plan · no credit card

Get your free exchange rate API key

300 requests a month, latest and historical rates, fluctuation and averages – free forever. Upgrade when you need faster updates or more requests.