A maioria das aplicações Django que mexem com dinheiro acaba precisando de taxas de câmbio — seja para faturar em três moedas, liquidar pagamentos do Stripe de volta a uma moeda base ou renderizar preços localizados num checkout. A resposta certa é plugar uma API de taxas de câmbio para Django e tratar FX como um problema resolvido. As respostas erradas são fixar taxas em settings.py ou bater no upstream a cada requisição.
Este guia percorre uma integração de nível produção do começo ao fim. Vamos construir um pequeno conversor de moedas em Django 5.x usando a API da Finexly, o framework de cache do Django para leituras quentes, um model para persistência, um management command para refresh agendado, precisão com Decimal para não sangrar centavos por erro de ponto flutuante, um endpoint REST via Django Ninja, uma UI com htmx e pytest com responses para testes que nunca tocam a rede. No final você terá uma arquitetura de service class que dá pra encaixar em qualquer projeto Django — faturamento SaaS, e-commerce, contabilidade, qualquer lugar onde dinheiro cruza fronteiras.
Por que uma API de moedas dedicada bate taxas fixas
Fixar taxas em settings.py é a primeira resposta errada. A segunda é chamar o upstream em cada view. Uma API de moedas usada do jeito certo te dá quatro coisas que valores fixos não dão:
- Frescor sob demanda. Taxas mudam continuamente no horário de mercado. Mesmo uma taxa de 24h pode se mover 1–2% em pares voláteis como USD/JPY ou EUR/TRY — o suficiente para liquidar a margem de um plano SaaS.
- Cobertura ampla. A Finexly cobre 170+ moedas, incluindo emergentes e taxas de referência CBDC. O feed do Banco Central Europeu que muitos pacotes Django abertos trazem cobre umas 32 majors. Se um único usuário paga em peso argentino ou lira turca, esse gap importa.
- Um contrato só. Uma forma JSON em todos os endpoints
latest,historicaleconvert, em vez de três feeds diferentes amarrados com fita adesiva. - Quota previsível. Um limite documentado em vez de "o BCE bloqueou nosso IP porque sondamos demais".
Se ainda está pesando opções, nosso comparativo de API de moedas grátis vs paga para 2026 e o análise ExchangeRate-API vs CurrencyLayer vs Finexly detalham as alternativas.
O que vamos construir
Um módulo Django opinativo chamado fx que faz cinco coisas bem feitas:
- Busca as taxas mais recentes para qualquer moeda base na Finexly via uma service class tipada.
- Cacheia leituras quentes no cache do Django (Redis em produção, locmem em dev).
- Persiste cada fetch num model
ExchangeRatepara que o checkout não caia se o upstream piscar. - Atualiza taxas em agenda via management command + cron (ou Celery beat).
- Expõe um endpoint JSON de conversão e um form HTML com htmx.
Plugue como app reutilizável em qualquer projeto Django existente.
Pré-requisitos
Você precisa de Python 3.11+, Django 5.0+ e uma chave de API da Finexly. Pegue uma grátis em a página de cadastro da Finexly — o tier grátis dá 1.000 requisições por mês, mais que suficiente para um MVP se você cachear direito.
Passo 1: monte o projeto Django
python -m venv .venv
source .venv/bin/activate
pip install "django>=5.0,<6.0" httpx python-decouple django-ninja
django-admin startproject site .
python manage.py startapp fxAdicione o novo app ao INSTALLED_APPS em site/settings.py:
INSTALLED_APPS = [
"django.contrib.admin",
"django.contrib.auth",
"django.contrib.contenttypes",
"django.contrib.sessions",
"django.contrib.messages",
"django.contrib.staticfiles",
"fx",
]Usamos httpx no lugar de requests porque dá um cliente único com timeouts, retries via httpx.HTTPTransport(retries=...) e um cliente async que dá pra trocar depois. Usamos python-decouple para variáveis de ambiente e django-ninja para o endpoint REST — ambos minimalistas e sem atrapalhar.
Passo 2: configure variáveis de ambiente
Crie um arquivo .env na raiz do projeto:
DEBUG=True
SECRET_KEY=change-me-in-production
FINEXLY_API_KEY=your_finexly_api_key_here
FINEXLY_BASE_URL=https://finexly.com/api/v1
FX_CACHE_TTL=900
FX_DEFAULT_BASE=USDDepois ligue isso ao site/settings.py:
from decouple import config
SECRET_KEY = config("SECRET_KEY")
DEBUG = config("DEBUG", default=False, cast=bool)
FINEXLY_API_KEY = config("FINEXLY_API_KEY")
FINEXLY_BASE_URL = config("FINEXLY_BASE_URL", default="https://finexly.com/api/v1")
FX_CACHE_TTL = config("FX_CACHE_TTL", default=900, cast=int)
FX_DEFAULT_BASE = config("FX_DEFAULT_BASE", default="USD")
CACHES = {
"default": {
"BACKEND": "django.core.cache.backends.redis.RedisCache",
"LOCATION": config("REDIS_URL", default="redis://127.0.0.1:6379/1"),
}
}Em dev sem Redis disponível, troque RedisCache por LocMemCache — o resto do código não muda.
Um TTL de 15 minutos (900 segundos) é um default sensato para a maioria de apps voltadas ao usuário. Para trading de alta frequência você quer sub-segundo; para relatórios contábeis, TTL de 6 horas serve. Nosso post de boas práticas de cache e tratamento de erros para API de moedas discute como escolher o número certo para o seu caso.
Passo 3: o model ExchangeRate
Persistência importa porque te dá fallback quando o upstream cai e uma trilha de auditoria quando o financeiro perguntar "que taxa usamos naquela fatura de 3 de abril?". Crie fx/models.py:
from decimal import Decimal
from django.db import models
class ExchangeRate(models.Model):
base = models.CharField(max_length=3, db_index=True)
quote = models.CharField(max_length=3, db_index=True)
rate = models.DecimalField(max_digits=20, decimal_places=10)
fetched_at = models.DateTimeField(auto_now_add=True, db_index=True)
class Meta:
indexes = [
models.Index(fields=["base", "quote", "-fetched_at"]),
]
constraints = [
models.CheckConstraint(
check=models.Q(rate__gt=Decimal("0")),
name="rate_positive",
),
]
def __str__(self) -> str:
return f"{self.base}/{self.quote} = {self.rate} @ {self.fetched_at:%Y-%m-%d %H:%M}"Duas decisões de design que valem comentário. Primeiro, DecimalField, não FloatField — erro de ponto flutuante compõe ao longo de milhares de conversões e você não quer ser quem explica ao financeiro por que os livros estão fora por $0,03 por fatura. Dez casas decimais é exagero para exibir mas é padrão para armazenar FX. Segundo, o índice composto (base, quote, -fetched_at) transforma "me dá a taxa USD/EUR mais recente" num único seek B-tree em vez de um sort.
Rode a migration:
python manage.py makemigrations fx
python manage.py migratePasso 4: a camada de serviço
A service class é onde mora toda a lógica que conversa com o upstream. Views nunca chamam httpx direto. Crie fx/services.py:
import logging
from decimal import Decimal
from typing import Mapping
import httpx
from django.conf import settings
from django.core.cache import cache
logger = logging.getLogger(__name__)
class ExchangeRateError(Exception):
"""Upstream fetch failed and no usable fallback exists."""
class ExchangeRateService:
def __init__(
self,
api_key: str | None = None,
base_url: str | None = None,
cache_ttl: int | None = None,
default_base: str | None = None,
) -> None:
self.api_key = api_key or settings.FINEXLY_API_KEY
self.base_url = base_url or settings.FINEXLY_BASE_URL
self.cache_ttl = cache_ttl or settings.FX_CACHE_TTL
self.default_base = default_base or settings.FX_DEFAULT_BASE
def latest(self, base: str | None = None) -> Mapping[str, Decimal]:
base = (base or self.default_base).upper()
cache_key = f"fx:latest:{base}"
cached = cache.get(cache_key)
if cached is not None:
return cached
rates = self._fetch_latest(base)
cache.set(cache_key, rates, self.cache_ttl)
self._persist(base, rates)
return rates
def convert(self, amount: Decimal, frm: str, to: str) -> Decimal:
frm, to = frm.upper(), to.upper()
if frm == to:
return amount
rates = self.latest(frm)
if to not in rates:
raise ExchangeRateError(f"Currency {to} not found in {frm} rates")
return (amount * rates[to]).quantize(Decimal("0.01"))
def _fetch_latest(self, base: str) -> dict[str, Decimal]:
url = f"{self.base_url}/latest"
headers = {"Authorization": f"Bearer {self.api_key}"}
transport = httpx.HTTPTransport(retries=2)
try:
with httpx.Client(transport=transport, timeout=8.0) as client:
response = client.get(url, params={"base": base}, headers=headers)
response.raise_for_status()
except httpx.HTTPError as exc:
logger.warning("Finexly fetch failed for %s: %s", base, exc)
fallback = self._fallback_from_db(base)
if fallback is not None:
return fallback
raise ExchangeRateError(str(exc)) from exc
payload = response.json()
rates = payload.get("rates")
if not isinstance(rates, dict):
raise ExchangeRateError("Malformed response: missing 'rates'")
return {quote: Decimal(str(rate)) for quote, rate in rates.items()}
def _fallback_from_db(self, base: str) -> dict[str, Decimal] | None:
from .models import ExchangeRate
recent = (
ExchangeRate.objects.filter(base=base)
.order_by("quote", "-fetched_at")
.distinct("quote")
)
rates = {row.quote: row.rate for row in recent}
return rates or None
def _persist(self, base: str, rates: Mapping[str, Decimal]) -> None:
from .models import ExchangeRate
ExchangeRate.objects.bulk_create(
[ExchangeRate(base=base, quote=q, rate=r) for q, r in rates.items()],
batch_size=200,
)Muita coisa rolando aqui, vamos por partes. O método latest() é o único caminho público de leitura: tenta cache, recorre ao upstream, persiste na volta. O convert() trata o caso de mesma moeda (não multiplique por 1 sem necessidade e arrisque introduzir arredondamento) e quantiza o resultado a duas casas decimais — escolha de apresentação; para armazenamento, guarde mais precisão. _fallback_from_db é a rede de segurança em produção: se a Finexly devolver 503, servimos a taxa mais recente por quote do nosso próprio banco. Checkout não cai por um soluço de terceiro.
O retries=2 em httpx.HTTPTransport pega a grande maioria dos 502/503 transitórios automaticamente. O timeout de 8 segundos é conservador — abaixo de 5s é apertado se você cruza continentes, acima de 10s significa que um upstream trêmulo arruína seu orçamento de latência.
Passo 5: uma view de conversão
Agora as views. Crie fx/views.py:
from decimal import Decimal, InvalidOperation
from django.http import HttpRequest, JsonResponse
from django.shortcuts import render
from django.views.decorators.http import require_http_methods
from .services import ExchangeRateError, ExchangeRateService
@require_http_methods(["GET", "POST"])
def convert_view(request: HttpRequest):
context: dict = {"result": None}
if request.method == "POST":
service = ExchangeRateService()
try:
amount = Decimal(request.POST.get("amount", "0"))
frm = request.POST.get("from", "USD")
to = request.POST.get("to", "EUR")
converted = service.convert(amount, frm, to)
context.update({"result": converted, "from": frm, "to": to, "amount": amount})
except (InvalidOperation, ExchangeRateError) as exc:
context["error"] = str(exc)
template = "fx/_result.html" if request.headers.get("HX-Request") else "fx/convert.html"
return render(request, template, context)A checagem do header HX-Request é o padrão htmx: mesma view, dois templates. Um POST normal devolve a página inteira; um POST disparado por htmx devolve só o fragmento de resultado, que o htmx encaixa no DOM. O usuário vê o valor convertido aparecer no lugar com zero JavaScript escrito por você.
Passo 6: templates htmx
Crie fx/templates/fx/convert.html:
{% load static %}
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>Currency Converter</title>
<script src="https://unpkg.com/htmx.org@1.9.10"></script>
<style>
body { font-family: system-ui, sans-serif; max-width: 480px; margin: 4rem auto; }
input, select, button { padding: 0.5rem; font-size: 1rem; }
.result { margin-top: 1rem; font-size: 1.4rem; }
</style>
</head>
<body>
<h1>Currency Converter</h1>
<form hx-post="{% url 'fx:convert' %}" hx-target="#result" hx-swap="innerHTML">
{% csrf_token %}
<input type="number" name="amount" step="0.01" required placeholder="Amount">
<input type="text" name="from" maxlength="3" required placeholder="USD" value="USD">
<input type="text" name="to" maxlength="3" required placeholder="EUR" value="EUR">
<button type="submit">Convert</button>
</form>
<div id="result">{% include "fx/_result.html" %}</div>
</body>
</html>E o partial fx/templates/fx/_result.html:
{% if error %}
<p class="error">{{ error }}</p>
{% elif result %}
<p class="result">{{ amount }} {{ from }} = <strong>{{ result }} {{ to }}</strong></p>
{% endif %}Ligue em fx/urls.py:
from django.urls import path
from . import views
app_name = "fx"
urlpatterns = [
path("convert/", views.convert_view, name="convert"),
]E em site/urls.py:
from django.urls import include, path
urlpatterns = [
path("", include("fx.urls")),
]Passo 7: um endpoint REST com Django Ninja
Para consumidores JSON — seu app mobile, seu worker de background, sua SPA frontend — exponha um endpoint tipado. Crie fx/api.py:
from decimal import Decimal
from ninja import NinjaAPI, Schema
from ninja.errors import HttpError
from .services import ExchangeRateError, ExchangeRateService
api = NinjaAPI(title="FX API")
class ConvertOut(Schema):
amount: Decimal
from_: str
to: str
rate: Decimal
result: Decimal
@api.get("/convert", response=ConvertOut)
def convert(request, amount: Decimal, from_: str, to: str):
service = ExchangeRateService()
try:
rates = service.latest(from_)
rate = rates[to.upper()]
result = service.convert(amount, from_, to)
except (ExchangeRateError, KeyError) as exc:
raise HttpError(502, f"FX unavailable: {exc}") from exc
return ConvertOut(amount=amount, from_=from_.upper(), to=to.upper(), rate=rate, result=result)Adicione em site/urls.py:
from fx.api import api
urlpatterns = [
path("", include("fx.urls")),
path("api/", api.urls),
]Agora GET /api/convert?amount=100&from_=USD&to=EUR devolve um payload JSON fortemente tipado. O Django Ninja gera schema OpenAPI de graça em /api/docs — útil quando o time mobile começar a perguntar.
Passo 8: refresh agendado via management command
Para a maioria de apps, o padrão inteligente é um refresh periódico em background que aquece o cache antes do tráfego chegar. Crie fx/management/commands/refresh_rates.py:
from django.core.management.base import BaseCommand
from fx.services import ExchangeRateError, ExchangeRateService
class Command(BaseCommand):
help = "Refresh exchange rates from Finexly for the configured base currencies."
def add_arguments(self, parser):
parser.add_argument("--bases", nargs="+", default=["USD", "EUR", "GBP"])
def handle(self, *args, **options):
service = ExchangeRateService()
for base in options["bases"]:
try:
rates = service.latest(base)
self.stdout.write(self.style.SUCCESS(
f"{base}: refreshed {len(rates)} rates"
))
except ExchangeRateError as exc:
self.stderr.write(self.style.ERROR(f"{base}: {exc}"))Você vai precisar de um fx/management/__init__.py e um fx/management/commands/__init__.py vazios para o Django achar o comando.
Agende com cron (ou Celery beat se você já roda Celery):
*/15 * * * * cd /app && /app/.venv/bin/python manage.py refresh_rates --bases USD EUR GBP JPYA cada 15 minutos, quatro bases são atualizadas. Cada refresh é uma chamada ao upstream — 4 chamadas × 96 (intervalos por dia) = 384 chamadas/dia, folgadamente dentro do tier grátis de 1.000/mês da Finexly se você cair para de hora em hora. Veja a página de planos de preços para quotas de tiers mais altos se precisar de frescor abaixo de 15 minutos.
Passo 9: testes que nunca tocam a rede
A razão de existir da camada de serviço é poder mocká-la. Instale responses e pytest-django:
pip install pytest pytest-django responsesCrie pytest.ini:
[pytest]
DJANGO_SETTINGS_MODULE = site.settings
python_files = tests.py test_*.py *_tests.pyE fx/tests/test_services.py:
from decimal import Decimal
import pytest
import responses
from django.core.cache import cache
from fx.services import ExchangeRateError, ExchangeRateService
@pytest.fixture(autouse=True)
def _clear_cache():
cache.clear()
yield
cache.clear()
@responses.activate
def test_latest_returns_rates_from_upstream(settings):
responses.add(
responses.GET,
f"{settings.FINEXLY_BASE_URL}/latest",
json={"base": "USD", "rates": {"EUR": 0.92, "GBP": 0.79}},
status=200,
)
service = ExchangeRateService()
rates = service.latest("USD")
assert rates["EUR"] == Decimal("0.92")
assert rates["GBP"] == Decimal("0.79")
@responses.activate
def test_convert_quantizes_to_cents(settings):
responses.add(
responses.GET,
f"{settings.FINEXLY_BASE_URL}/latest",
json={"base": "USD", "rates": {"EUR": Decimal("0.9234")}},
status=200,
)
service = ExchangeRateService()
result = service.convert(Decimal("100"), "USD", "EUR")
assert result == Decimal("92.34")
@responses.activate
def test_upstream_failure_raises_when_no_fallback(settings):
responses.add(
responses.GET,
f"{settings.FINEXLY_BASE_URL}/latest",
status=503,
)
service = ExchangeRateService()
with pytest.raises(ExchangeRateError):
service.latest("USD")
def test_same_currency_returns_input():
service = ExchangeRateService()
assert service.convert(Decimal("100"), "USD", "USD") == Decimal("100")Rode:
pytest -qQuatro testes, zero chamadas de rede, runtime sub-segundo no total. Esse é o ponto.
Notas de produção
Coisas que mordem apps Django em produção quando FX está no ar:
- Persistência em banco é seu fallback. Se você pular o
_persist, uma queda do upstream derruba o checkout. Não pule. - Cache stampede. Se 100 requisições baterem num cache frio simultaneamente, você dispara 100 chamadas ao upstream. Use o
get_or_setdodjango-rediscom lock, ou aceite o trade-off se seu tráfego é baixo. - Drift de moedas no fim de semana. Mercados FX fecham. A maioria das APIs (Finexly inclusa) serve o fechamento da sexta o fim de semana inteiro; suas taxas só vão se mexer na abertura da segunda. Se você liquida transações de fim de semana, documente a política.
- Trilha de auditoria. A tabela
ExchangeRatecresce rápido. Adicione um índicepartialdos últimos 7 dias, ou consolide linhas antigas num resumo mensal se compliance permitir.
Se precisar de aprofundamento, nosso guia de cache e tratamento de erros para API de moedas cobre proteção contra stampede, circuit breakers e degradação graciosa em detalhes.
Perguntas frequentes
Qual API de taxas de câmbio funciona melhor com Django?
Qualquer API REST funciona, mas a ergonomia muda. A Finexly devolve um formato plano {rates: {...}} que mapeia limpinho para dict Python, suporta 170+ moedas e tem tier grátis de 1.000 chamadas/mês — basta para MVP. Alternativas como Fixer e Open Exchange Rates têm estruturas de quota diferentes; nosso post comparativo detalha os trade-offs.
Devo usar Decimal ou Float para taxas de câmbio em Django?
Sempre DecimalField para armazenar e Decimal na lógica de negócio. Float traz erros de arredondamento que compõem ao longo de milhares de conversões; em código financeiro esses erros viram bugs visíveis para o usuário. O custo de performance é desprezível.
Com que frequência atualizar as taxas? Depende do seu caso. Para apps voltadas ao usuário mostrando preços localizados, a cada 15 minutos é suficiente. Para relatórios contábeis, de hora em hora basta. Para trading ou hedge em tempo real você quer feeds WebSocket sub-segundo — veja nosso post REST vs WebSocket para esse padrão.
Posso usar isso com Django REST Framework em vez de Django Ninja?
Sim. A camada de serviço é agnóstica a framework. Troque fx/api.py por uma APIView do DRF que chama ExchangeRateService().convert(...) e serializa o resultado. O model, o service, o management command e os testes ficam idênticos.
Como lidar com moedas que não estão na resposta da API?
Dois padrões. Ou levantar um ExchangeRateError explícito e expor ao usuário (UX mais limpa), ou cair para cross-rate via USD (mais permissivo mas mais difícil de debugar). Para a maioria das apps, falhar cedo é o caminho certo.
Teste a Finexly grátis
Pronto para integrar taxas de câmbio em tempo real ao seu projeto Django? Pegue sua chave de API da Finexly grátis — sem cartão de crédito. Comece com 1.000 requisições grátis por mês, taxas em tempo real para 170+ moedas e um contrato JSON que mapeia limpinho para Python. Suba quando precisar nos nossos planos de preços.
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 →