Forex Rates API in Python: Fetch Live Exchange Rates with requests
A hands-on tutorial for calling a JSON forex rates endpoint from Python, with polling, timeouts, caching, and rate-limit handling.
Pulling live foreign exchange quotes into a Python service should be a fifteen-minute job, but most tutorials stop at a naive requests.get call and skip the parts that matter in production: timeouts, retries, polling cadence, and respecting the provider's rate limits. This guide walks through a minimal but resilient integration against a JSON forex rates API, using nothing beyond the standard library and the ubiquitous requests package.
The examples target Live-Rates' public JSON endpoint, but the patterns transfer to any REST rates provider that authenticates with an API key and returns bid/ask quotes for a list of pairs.
- Authenticate with an API key passed as a query parameter or header, never hard-coded in source.
- Always set an explicit timeout on
requestscalls; the default is no timeout at all.- Poll no faster than the source updates; for retail forex, 1–5 seconds is usually plenty.
- Respect documented rate limits (Live-Rates throttles at 400 requests per 5 minutes per key) and cache aggressively.
- Wrap network I/O in retries with exponential back-off so a single dropped packet does not crash the loop.
1. Prerequisites and setup
You need Python 3.9 or newer, the requests library, and an active API key. Grab a key from the Live-Rates plans page — the free tier is enough to follow along, and paid tiers lift the throttle ceiling and unlock the full pair catalogue at /rates/all.
python -m venv .venv
source .venv/bin/activate
pip install requests python-dotenvStore the key in an environment variable rather than pasting it into a script. A .env file loaded with python-dotenv keeps secrets out of version control:
echo 'LIVERATES_KEY=your_api_key_here' > .env2. A minimal request
The simplest possible call fetches the full rate table and prints the mid-price of a single pair. Note the explicit timeout and the raise_for_status() call — two lines that separate a demo from something you would actually deploy.
import os
import requests
from dotenv import load_dotenv
load_dotenv()
API_KEY = os.environ["LIVERATES_KEY"]
ENDPOINT = "https://www.live-rates.com/api/rates"
def fetch_rates():
response = requests.get(
ENDPOINT,
params={"key": API_KEY},
timeout=(3.05, 10), # (connect, read)
)
response.raise_for_status()
return response.json()
rates = fetch_rates()
eur_usd = next(r for r in rates if r["code"] == "EURUSD")
print(f"EUR/USD bid={eur_usd['bid']} ask={eur_usd['ask']}")The tuple form timeout=(3.05, 10) is a small trick worth internalising: the first number is the TCP connect budget, the second the read budget. A stalled TLS handshake and a slow response body are very different failure modes and deserve different limits. See the live tape for context at the EUR/USD live rate.
3. Parsing bid, ask, and spread
The endpoint returns a list of objects, one per pair. The fields most integrations care about are bid, ask, and timestamp. Cast to Decimal if you plan to do arithmetic — binary floats introduce rounding that becomes embarrassing in a P&L report.
from decimal import Decimal
def parse_quote(payload, pair):
row = next((r for r in payload if r["code"] == pair), None)
if row is None:
raise KeyError(f"pair {pair} not in payload")
bid = Decimal(str(row["bid"]))
ask = Decimal(str(row["ask"]))
spread = ask - bid
return {"bid": bid, "ask": ask, "spread": spread, "ts": row["timestamp"]}
quote = parse_quote(fetch_rates(), "GBPUSD")
print(quote)4. A polling loop with back-off
Most real integrations want a running feed, not a one-shot request. The pattern below polls every two seconds, retries transient network errors with exponential back-off, and gives up cleanly on authentication failures.
import time
import logging
from requests.exceptions import RequestException, HTTPError
log = logging.getLogger("liverates")
logging.basicConfig(level=logging.INFO)
POLL_SECONDS = 2
MAX_BACKOFF = 60
def poll(pair):
backoff = 1
while True:
try:
payload = fetch_rates()
quote = parse_quote(payload, pair)
log.info("%s bid=%s ask=%s", pair, quote["bid"], quote["ask"])
backoff = 1
time.sleep(POLL_SECONDS)
except HTTPError as e:
status = e.response.status_code
if status in (401, 403):
log.error("auth failed (%s); check LIVERATES_KEY", status)
return
if status == 429:
log.warning("rate-limited; sleeping %ss", backoff)
else:
log.warning("http %s; sleeping %ss", status, backoff)
time.sleep(backoff)
backoff = min(backoff * 2, MAX_BACKOFF)
except RequestException as e:
log.warning("network error %s; sleeping %ss", e, backoff)
time.sleep(backoff)
backoff = min(backoff * 2, MAX_BACKOFF)
if __name__ == "__main__":
poll("EURUSD")Choosing a poll interval
Retail forex ticks arrive several times per second in liquid pairs during London and New York hours, and only every few seconds in Asian off-hours. A two-second cadence captures meaningful moves without burning quota. High-frequency use cases should push for a streaming feed rather than shortening the polling interval — polling faster than one second wastes network round-trips against a source that updates in the same window.
5. Respecting rate limits and caching
Live-Rates enforces 400 requests per 5-minute window per API key. That is roughly 1.33 requests per second sustained, which comfortably supports one poller per key at the two-second cadence above. If you need to serve many downstream consumers, insert a cache between them and the API.
| Interval | Requests / 5 min | Fits 400 cap? |
|---|---|---|
| 0.5 s | 600 | No |
| 1 s | 300 | Yes |
| 2 s | 150 | Yes, comfortable |
| 5 s | 60 | Yes, generous headroom |
The simplest workable cache is an in-process TTL dictionary. One background poller writes; every consumer reads from memory without touching the network:
import threading, time
_cache = {"payload": None, "ts": 0}
_lock = threading.Lock()
TTL = 2
def get_rates_cached():
with _lock:
if _cache["payload"] and time.time() - _cache["ts"] < TTL:
return _cache["payload"]
payload = fetch_rates()
_cache.update(payload=payload, ts=time.time())
return payloadFor multi-process deployments swap the dictionary for Redis with the same TTL. The full pair list at /rates/all makes a good target for caching because a single request refreshes every symbol at once.
6. Common pitfalls
Forget the timeout and a stalled TCP connection will hang your worker forever. Skip raise_for_status() and a 401 will silently return HTML that your JSON parser then explodes on. Poll faster than the upstream tick rate and you waste quota without gaining freshness. Persist rates as floats instead of Decimal and expect rounding drift in downstream calculations.
FAQ
How do I get an API key for Live-Rates?
Register on the site and pick a plan; the key is issued instantly and shown in your dashboard. Pass it as the key query parameter on every request.
What is a safe polling interval?
Two seconds is a good default for a single consumer. It stays well under the 400-per-5-minute cap and roughly matches how often retail forex quotes change in liquid pairs.
How should I handle HTTP 429 responses?
Treat 429 as a signal to back off exponentially, starting at one second and capping at around a minute. Never retry immediately; that only deepens the throttle.
Do I need async or can I stay with plain requests?
For a handful of pairs and one poller, synchronous requests is simpler and fine. Reach for httpx or aiohttp only when you need to fan out to many endpoints concurrently.
Should I cache the JSON response?
Yes, whenever more than one consumer needs the data. A two-second in-memory TTL cache eliminates duplicate calls and keeps you comfortably inside the rate limit.
Ready to wire live quotes into your Python service? Pick a tier on the Live-Rates plans page, drop your key into .env, and the code above will be streaming bid/ask into your logs in a few minutes.
Real-time forex rates for your app
Live bid/ask for the pairs you need, updated every second, with a simple JSON & XML API. Try it free for 7 days.