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:
| Plan | Rates update | Suggested TTL | Requests/month if one process polls at that TTL | Monthly quota |
|---|---|---|---|---|
| Free | daily | 24 h (or until next update) | ~30 | 300 |
| Basic | hourly | 1 h | ~720 | 15,000 |
| Professional | every 60 s | 60 s | ~43,200 | 600,000 |
| Enterprise | every 60 s | 60 s | ~43,200 | 1,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:
| Header | Meaning |
|---|---|
X-RateLimit-Limit-Quota-Month | Your monthly quota |
X-RateLimit-Remaining-Quota-Month | Requests left this month |
X-Cost | Quota units this call used |
X-Execution-Time | Server-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
- One shared cache per base currency, TTL = plan update interval.
- One request for all currencies; compute cross rates locally.
- Cache historical dates forever.
- Serve stale values on errors and 429s, up to a hard limit.
- Log
X-RateLimit-Remaining-Quota-Month; monitor with/v1/status. - Persist
rateandlast_updated_atwith each transaction. - Keep the API key on the server – never in browser or mobile code.
Frequently asked questions
How long should I cache exchange rates?
What are fxapi's rate limits?
Do failed requests count against my quota?
/v1/status are free as well.How do I know how many requests I have left?
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.