If you are building a fintech backend, a multi-currency checkout, or an internal pricing service in Python, sooner or later you need your own foreign exchange endpoint. Building a currency exchange rate API with FastAPI gives you a fast, async, fully typed microservice that wraps a live rates provider, adds caching so you stay inside a free tier, and exposes a clean /convert endpoint your other services can call. FastAPI is a natural fit for this: it is built on ASGI for real async I/O, it validates requests and responses with Pydantic, and it generates interactive OpenAPI docs for free. This tutorial walks the whole path — from your first upstream request to a production-ready converter with a shared HTTP client, cached reads, decimal-safe money, and typed error handling.
By the end you will have a small, self-contained FastAPI service backed by the Finexly API documentation and its coverage of 170+ currencies. If you have already read our language-level Python currency API tutorial, this is the service-level companion that shows how the pieces fit together in a real ASGI backend. Where the Django tutorial leans on the ORM and template layer, FastAPI keeps things lean and async-first.
Why FastAPI Is a Great Fit for a Currency Microservice
Currency conversion looks trivial — multiply an amount by a rate — but doing it well as a service touches concerns that FastAPI was designed for:
- Real async I/O. Fetching rates is network-bound. With
async defendpoints and an async HTTP client, a single worker can handle many concurrent conversions while requests are in flight, instead of blocking a thread per call. - Typed contracts. Pydantic models validate incoming query parameters and shape outgoing JSON, so a malformed
amountor an unknown currency code is rejected with a clear 422 before it ever reaches your logic. - Free interactive docs. Every endpoint you write shows up in Swagger UI at
/docs, which makes the service self-documenting for the rest of your team. - Lifespan management. FastAPI gives you a clean place to open one shared HTTP connection pool at startup and drain it at shutdown — critical for performance, as you will see below.
The architecture we are building is a thin, cache-aware proxy: your service calls the upstream rates API, caches the response for a short window, and serves conversions to your own frontend or other microservices. That pattern keeps your API keys server-side, insulates you from upstream rate limits, and lets you add business rules (rounding, markups, allow-lists) in one place.
Prerequisites and Project Setup
You will need Python 3.11 or newer. Create a virtual environment and install the dependencies:
python -m venv .venv
source .venv/bin/activate # on Windows: .venv\Scripts\activate
pip install "fastapi[standard]" httpx pydantic-settingsfastapi[standard] pulls in Uvicorn (the ASGI server) and the CLI. httpx is our async HTTP client, and pydantic-settings handles configuration from environment variables.
Grab a free API key from your Finexly dashboard and export it so it never lands in source control:
export FINEXLY_API_KEY="your_api_key_here"Create the project layout:
fx-service/
├── app/
│ ├── __init__.py
│ ├── config.py
│ ├── client.py
│ ├── models.py
│ └── main.py
└── .envStep 1: Configuration with pydantic-settings
Keep configuration in one typed place. pydantic-settings reads from environment variables and a .env file, and it fails loudly if a required value is missing.
# app/config.py
from pydantic_settings import BaseSettings, SettingsConfigDict
class Settings(BaseSettings):
model_config = SettingsConfigDict(env_file=".env", env_prefix="FINEXLY_")
api_key: str
base_url: str = "https://api.finexly.com/v1"
cache_ttl_seconds: int = 300
request_timeout_seconds: float = 10.0
settings = Settings() # raises at startup if FINEXLY_API_KEY is unsetBecause the settings object is created at import time, a missing key stops the service from booting rather than failing on the first request — exactly what you want in a deployment pipeline.
Step 2: A Shared, Async HTTP Client via Lifespan
This is the single most important performance decision in the whole service. Do not create a new httpx.AsyncClient per request. Each client owns a connection pool; creating one per call throws away keep-alive connections and forces a fresh TLS handshake every time. Instead, open one client when the app starts and reuse it for the life of the process.
The modern way to do this is FastAPI's lifespan context manager. (The older @app.on_event("startup") and @app.on_event("shutdown") decorators were deprecated in FastAPI 0.93+ in favor of lifespan.) Using async with guarantees the connection pool drains and sockets close on shutdown, even if startup is interrupted.
# app/client.py
import httpx
from contextlib import asynccontextmanager
from fastapi import FastAPI, Request
from .config import settings
@asynccontextmanager
async def lifespan(app: FastAPI):
# Runs once on startup: open a single pooled client
async with httpx.AsyncClient(
base_url=settings.base_url,
headers={"Authorization": f"Bearer {settings.api_key}"},
timeout=settings.request_timeout_seconds,
) as client:
app.state.http = client
yield
# On shutdown, the async-with block closes the pool automatically
def get_client(request: Request) -> httpx.AsyncClient:
# FastAPI dependency: hand endpoints the shared client
return request.app.state.httpNow every endpoint borrows the same pooled client through a dependency, and no one has to think about opening or closing connections.
Step 3: Typed Request and Response Models
Pydantic models give you validation and self-documenting responses. Note the use of Decimal for monetary amounts: binary floats cannot represent decimal fractions exactly, and rounding errors in money are a real bug, not a rounding curiosity.
# app/models.py
from decimal import Decimal
from pydantic import BaseModel, Field
class ConversionResult(BaseModel):
base: str = Field(..., examples=["USD"])
quote: str = Field(..., examples=["EUR"])
amount: Decimal = Field(..., examples=["100.00"])
rate: Decimal = Field(..., examples=["0.92"])
converted: Decimal = Field(..., examples=["92.00"])
as_of: str = Field(..., description="Timestamp of the upstream rate")
class RatesResponse(BaseModel):
base: str
rates: dict[str, Decimal]
as_of: strFastAPI will render these in the OpenAPI schema and coerce/validate values automatically, so a request for a nonsensical amount is rejected before your handler runs.
Step 4: Fetching Rates with a Small TTL Cache
Rates do not change millisecond to millisecond, and hammering the upstream API on every request is both slow and a fast way to burn through your quota. A tiny in-memory time-to-live (TTL) cache smooths this out. For a single-process service this dictionary-based cache is enough; for multiple workers, swap it for Redis later without changing the call sites.
# app/main.py
import time
from decimal import Decimal, ROUND_HALF_UP
import httpx
from fastapi import Depends, FastAPI, HTTPException, Query
from .client import lifespan, get_client
from .config import settings
from .models import ConversionResult
app = FastAPI(title="FX Service", lifespan=lifespan)
# Simple in-memory cache: {base_currency: (expiry_timestamp, rates_dict)}
_cache: dict[str, tuple[float, dict]] = {}
async def fetch_rates(base: str, client: httpx.AsyncClient) -> dict:
base = base.upper()
now = time.monotonic()
cached = _cache.get(base)
if cached and cached[0] > now:
return cached[1] # cache hit — no network call
try:
resp = await client.get("/latest", params={"base": base})
resp.raise_for_status()
except httpx.HTTPStatusError as exc:
code = exc.response.status_code
if code == 429:
raise HTTPException(503, "Upstream rate limit reached; try again shortly")
if code in (401, 403):
raise HTTPException(502, "Upstream authentication failed")
raise HTTPException(502, "Upstream rates provider error")
except httpx.RequestError:
raise HTTPException(504, "Could not reach rates provider")
rates = resp.json()["rates"]
_cache[base] = (now + settings.cache_ttl_seconds, rates)
return ratesNotice how upstream failures are translated into meaningful HTTP status codes for your callers. A 429 from the provider becomes a 503 with a retry hint; an auth failure becomes a 502 so you never leak credentials details downstream. Our companion guide on currency API caching and error handling goes deeper on these patterns.
Step 5: The /convert and /rates Endpoints
Now the endpoints themselves are tiny, because all the hard work lives in fetch_rates. We validate query parameters with FastAPI's Query, do decimal-safe math, and return typed models.
@app.get("/convert", response_model=ConversionResult)
async def convert(
base: str = Query(..., min_length=3, max_length=3),
quote: str = Query(..., min_length=3, max_length=3),
amount: Decimal = Query(..., gt=0),
client: httpx.AsyncClient = Depends(get_client),
):
base, quote = base.upper(), quote.upper()
rates = await fetch_rates(base, client)
if quote not in rates:
raise HTTPException(400, f"Unsupported currency: {quote}")
rate = Decimal(str(rates[quote]))
converted = (amount * rate).quantize(Decimal("0.01"), rounding=ROUND_HALF_UP)
return ConversionResult(
base=base, quote=quote, amount=amount,
rate=rate, converted=converted, as_of="latest",
)
@app.get("/health")
async def health():
return {"status": "ok"}Run it locally:
fastapi dev app/main.pyThen open http://127.0.0.1:8000/docs for the interactive Swagger UI, or hit the endpoint directly:
curl "http://127.0.0.1:8000/convert?base=USD"e=EUR&amount=100"{
"base": "USD",
"quote": "EUR",
"amount": "100",
"rate": "0.92",
"converted": "92.00",
"as_of": "latest"
}You now have a working currency microservice. If you would rather not run your own conversion math at all, Finexly also exposes a hosted currency converter and a direct conversion endpoint you can call.
Step 6: A Note on Decimal Precision and Zero-Decimal Currencies
Two money traps catch nearly every currency service:
- Never use binary floats for amounts.
0.1 + 0.2is not0.3in float arithmetic. Parse rates and amounts throughDecimal(str(value))and quantize the final result, as we did above. - Not every currency has two decimal places. ISO 4217 defines minor units per currency: JPY and KRW have zero decimal places, while BHD and KWD have three. Hard-coding
.quantize(Decimal("0.01"))will silently mis-round yen and dinar. A production service should look up the correct exponent per currency and quantize accordingly.
Getting this right is the difference between a demo and something you can put in front of real transactions.
Step 7: Testing the Service
Because the HTTP client is injected as a dependency, tests are easy — override the dependency with a mock and you never touch the network:
# test_convert.py
from fastapi.testclient import TestClient
from app.main import app, fetch_rates
async def fake_rates(base, client):
return {"EUR": "0.92", "GBP": "0.79"}
def test_convert(monkeypatch):
monkeypatch.setattr("app.main.fetch_rates", fake_rates)
with TestClient(app) as c:
r = c.get("/convert?base=USD"e=EUR&amount=100")
assert r.status_code == 200
assert r.json()["converted"] == "92.00"TestClient runs the lifespan events for you, so the shared client is set up and torn down exactly as in production.
Going to Production: Scaling and Rate Limits
A few things to plan for before you ship:
- Multiple workers. Under Uvicorn/Gunicorn with several workers, your in-memory cache is per-process. Move to Redis for a shared cache so a rate fetched by one worker serves all of them. The
fetch_ratesseam makes this a one-function change. - Upstream quota. Caching is your first line of defense. With a 5-minute TTL, one base currency costs at most 12 upstream calls per hour regardless of how many conversions you serve. Review your plan limits on the pricing page and set the TTL to fit your tier.
- Historical rates. For invoices, reporting, or audit trails you will want a
/historicalendpoint that queries rates for a specific date. The same client and cache patterns apply; just key the cache by(base, date). - Observability. Log cache hit/miss ratios and upstream latency so you can tune the TTL with real data.
If you are still choosing a provider, our compare currency APIs page lays out coverage, limits, and pricing side by side.
Frequently Asked Questions
Why use httpx instead of requests in FastAPI?
requests is synchronous and blocks the event loop, which defeats the purpose of an async framework. httpx offers a fully async client (httpx.AsyncClient) with connection pooling, timeouts, and retry transports, so it plugs directly into FastAPI's async def endpoints without blocking other requests.Should I create the httpx client per request or once?
Once. Create a singlehttpx.AsyncClient in the lifespan context manager and reuse it across all requests. A per-request client discards the connection pool and forces a new TLS handshake every call, which is measurably slower under load and can exhaust sockets.How do I avoid hitting the currency API rate limit?
Cache upstream responses with a short TTL (for example, 5 minutes). Because rates move slowly, caching by base currency reduces thousands of user conversions down to a handful of upstream calls per hour. For multi-worker deployments, use Redis so the cache is shared across processes.How do I handle currencies with different decimal places?
Use Python'sDecimal type, never float, and quantize the result to the correct number of minor units for each currency per ISO 4217. JPY and KRW use zero decimals, most currencies use two, and BHD/KWD use three. Look up the exponent per currency rather than hard-coding two decimal places.Can I use this pattern for a synchronous framework like Flask or Django?
The caching and Decimal patterns transfer directly, but the shared-client and async-endpoint parts are FastAPI-specific. For Django, see our dedicated Django currency API tutorial, which uses the framework's cache backend and ORM instead.Start Building
You now have a complete, async, cache-aware currency microservice in FastAPI — typed with Pydantic, safe with Decimal, and resilient to upstream failures. It is small enough to read in one sitting and structured enough to grow into a real production service.
Ready to integrate real-time exchange rates into your project? Get your free Finexly API key — no credit card required. Start with a generous free tier covering 170+ currencies, and scale up as your traffic grows. Prefer to explore first? Browse the free currency API overview or dive into the API documentation.
Explore More
Vlado Grigirov
Senior Currency Markets Analyst & Financial Strategist
Vlado Grigirov is a senior currency markets analyst and financial strategist with over 14 years of experience in foreign exchange markets, cross-border finance, and currency risk management. He has wo...
View full profile →