Înapoi la Blog

How to Build a Currency Exchange Rate API with FastAPI (Async httpx, Caching, Pydantic)

V
Vlado Grigirov
August 11, 2026
FastAPI Python Currency API Exchange Rates Tutorial Fintech

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 def endpoints 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 amount or 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-settings

fastapi[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
└── .env

Step 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 unset

Because 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.http

Now 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: str

FastAPI 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 rates

Notice 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.py

Then 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&quote=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:

  1. Never use binary floats for amounts. 0.1 + 0.2 is not 0.3 in float arithmetic. Parse rates and amounts through Decimal(str(value)) and quantize the final result, as we did above.
  2. 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&quote=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_rates seam 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 /historical endpoint 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 single httpx.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's Decimal 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.

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 →

Distribuie acest articol