Terug naar Blog

Een valutaconverter bouwen in Django: complete tutorial wisselkoers-API (2026)

V
Vlado Grigirov
May 12, 2026
Django Python Currency API Exchange Rates Tutorial Finexly

De meeste Django-apps die geld raken hebben vroeg of laat wisselkoersen nodig — of je nu factureert in drie valuta's, Stripe-uitbetalingen terugrekent naar een thuisvaluta of gelokaliseerde prijzen op de checkout toont. Het juiste antwoord is een wisselkoers-API voor Django inpluggen en FX als een opgelost probleem behandelen. De verkeerde antwoorden zijn koersen hardcoden in settings.py of de upstream bij elke request raken.

Deze gids loopt een production-grade integratie van begin tot eind door. We bouwen een kleine valutaconverter in Django 5.x met de Finexly API, het Django cache-framework voor hot reads, een model voor persistence, een management command voor geplande refresh, Decimal-precisie zodat we geen centen verliezen aan floating-point fouten, een REST-endpoint via Django Ninja, een htmx-aangedreven UI en pytest met responses voor tests die nooit het netwerk raken. Aan het einde heb je een service-class-architectuur die in elk Django-project past — SaaS-billing, e-commerce, boekhouding, overal waar geld een grens oversteekt.

Waarom een dedicated valuta-API hardcoded koersen verslaat

Koersen hardcoden in settings.py is het eerste verkeerde antwoord. Het tweede is de upstream bij elke view aanroepen. Een correct gebruikte dedicated valuta-API geeft je vier dingen die hardcoded waarden niet kunnen:

  • Versheid op afroep. Koersen bewegen voortdurend tijdens markturen. Zelfs een 24 uur oude koers kan op volatiele paren als USD/JPY of EUR/TRY 1–2% bewegen — genoeg om de marge van een SaaS-plan uit te wissen.
  • Brede dekking. Finexly dekt 170+ valuta's inclusief opkomende markten en CBDC-referentiekoersen. De ECB-feed die met veel open Django-packages meekomt dekt zo'n 32 majors. Als één gebruiker betaalt in Argentijnse peso's of Turkse lira's, telt dat gat.
  • Eén contract. Eén JSON-vorm over de endpoints latest, historical en convert, in plaats van drie verschillende upstream-feeds aan elkaar geplakt.
  • Voorspelbaar quotum. Een gedocumenteerde limiet waar je over kunt redeneren, in plaats van "de ECB heeft ons IP geblokkeerd omdat we te vaak pollden".

Wil je nog opties afwegen, dan lopen onze vergelijking gratis vs betaalde valuta-API voor 2026 en de analyse ExchangeRate-API vs CurrencyLayer vs Finexly de alternatieven gedetailleerd door.

Wat we bouwen

Een kleine, eigenwijze Django-module fx die vijf dingen goed doet:

  1. Haalt de nieuwste koersen voor een willekeurige basisvaluta op uit Finexly via een getypeerde serviceklasse.
  2. Cachet hot reads in de Django-cache (Redis in productie, locmem in dev).
  3. Persisteert elke fetch in een ExchangeRate-model zodat checkout niet valt als de upstream hapert.
  4. Vernieuwt koersen op schema via management command + cron (of Celery beat).
  5. Stelt een JSON-conversie-endpoint en een htmx-aangedreven HTML-formulier beschikbaar.

Plug het in als herbruikbare app in elk bestaand Django-project.

Vereisten

Je hebt Python 3.11+, Django 5.0+ en een Finexly-API-key nodig. Pak een gratis op de Finexly-registratiepagina — de gratis tier geeft 1.000 requests per maand, meer dan genoeg voor een MVP als je goed cachet.

Stap 1: zet het Django-project op

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 fx

Voeg de nieuwe app toe aan INSTALLED_APPS in site/settings.py:

INSTALLED_APPS = [
    "django.contrib.admin",
    "django.contrib.auth",
    "django.contrib.contenttypes",
    "django.contrib.sessions",
    "django.contrib.messages",
    "django.contrib.staticfiles",
    "fx",
]

We gebruiken httpx in plaats van requests omdat het één client geeft met timeouts, retries via httpx.HTTPTransport(retries=...) en een async client die we later kunnen inruilen. We gebruiken python-decouple voor omgevingsvariabelen en django-ninja voor de REST-endpoint — beide minimaal en niet in de weg.

Stap 2: configureer omgevingsvariabelen

Maak een .env in de projectroot:

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=USD

Bedraad het dan in 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"),
    }
}

In dev zonder Redis vervang je RedisCache door LocMemCache — de rest van de code merkt het niet.

Een TTL van 15 minuten (900 seconden) is een redelijke default voor de meeste consumer-apps. Voor high-frequency trading wil je sub-second; voor accounting-rapporten is een TTL van 6 uur prima. Onze post over caching- en error-handling best practices bespreekt hoe je het juiste getal voor je use case kiest.

Stap 3: het ExchangeRate-model

Persistentie is belangrijk omdat het een fallback geeft als de upstream onbereikbaar is, en een audit-trail als finance vraagt "welke koers hebben we gebruikt op die factuur van 3 april?". Maak 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}"

Twee ontwerpkeuzes verdienen aandacht. Ten eerste, DecimalField, niet FloatField — floating-point fouten stapelen op over duizenden conversies, en je wilt niet de engineer zijn die finance moet uitleggen waarom de boeken per factuur 0,03 $ afwijken. Tien decimalen is overdreven voor weergave maar standaard voor FX-opslag. Ten tweede maakt de samengestelde index (base, quote, -fetched_at) van "geef me de meest recente USD/EUR-koers" één B-tree-seek in plaats van een sort.

Voer de migratie uit:

python manage.py makemigrations fx
python manage.py migrate

Stap 4: de service-laag

De serviceklasse is waar alle logica leeft die met de upstream praat. Views roepen nooit direct httpx aan. Maak 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,
        )

Veel om uit te pakken. latest() is het enige publieke leespad: probeer cache, val terug op upstream, persisteer op de terugweg. convert() handelt de edge-case zelfde valuta af (vermenigvuldig niet onnodig met 1 en riskeer afronding) en quantiseert het resultaat naar twee decimalen — een weergavekeuze; voor opslag meer precisie behouden. _fallback_from_db is het productie-vangnet: als Finexly 503 teruggeeft, serveren we uit onze eigen DB de meest recente koers per quote. Checkout valt niet door een hapering van een derde partij.

De retries=2 op httpx.HTTPTransport vangt het overgrote deel van voorbijgaande 502/503 automatisch. De 8-seconde timeout is conservatief — onder 5s is te krap als je continenten kruist, boven 10s vernielt een wankele upstream je latency-budget.

Stap 5: een conversie-view

Nu de views. Maak 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)

De controle op de HX-Request-header is het htmx-patroon: dezelfde view, twee templates. Een normale POST geeft de hele pagina terug; een htmx-gedreven POST geeft alleen het resultaat-fragment terug, dat htmx in het DOM zet. De gebruiker ziet het geconverteerde bedrag op zijn plek verschijnen met nul JavaScript dat jij schreef.

Stap 6: htmx-templates

Maak 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>

En de 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 %}

Bedraad in fx/urls.py:

from django.urls import path
from . import views

app_name = "fx"
urlpatterns = [
    path("convert/", views.convert_view, name="convert"),
]

En in site/urls.py:

from django.urls import include, path

urlpatterns = [
    path("", include("fx.urls")),
]

Stap 7: een REST-endpoint met Django Ninja

Voor de JSON-consumenten — je mobiele app, je background worker, je frontend-SPA — bied een getypeerde endpoint aan. Maak 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)

Voeg toe aan site/urls.py:

from fx.api import api

urlpatterns = [
    path("", include("fx.urls")),
    path("api/", api.urls),
]

Nu geeft GET /api/convert?amount=100&from_=USD&to=EUR een strikt getypeerd JSON-payload terug. Django Ninja genereert gratis een OpenAPI-schema op /api/docs — handig als het mobiele team vragen begint te stellen.

Stap 8: geplande refresh via management command

Voor de meeste apps is het slimme patroon een periodieke achtergrond-refresh die de cache opwarmt voordat gebruikers binnenkomen. Maak 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}"))

Je hebt ook lege fx/management/__init__.py en fx/management/commands/__init__.py nodig zodat Django het commando vindt.

Plannen met cron (of Celery beat als je toch al Celery draait):

*/15 * * * * cd /app && /app/.venv/bin/python manage.py refresh_rates --bases USD EUR GBP JPY

Elke 15 minuten worden vier basen ververst. Elke refresh is één upstream-call — 4 calls × 96 (intervallen per dag) = 384 calls/dag, ruim binnen Finexly's gratis tier van 1.000/maand als je terugschakelt naar uurfrequentie. Zie de pagina prijzen voor hogere-tier-quota's als je versheid onder 15 minuten nodig hebt.

Stap 9: tests die het netwerk nooit raken

Het hele doel van de service-laag is dat je hem kunt mocken. Installeer responses en pytest-django:

pip install pytest pytest-django responses

Maak pytest.ini:

[pytest]
DJANGO_SETTINGS_MODULE = site.settings
python_files = tests.py test_*.py *_tests.py

Dan 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")

Draai het:

pytest -q

Vier tests, nul netwerk-calls, totale runtime sub-second. Daar gaat het om.

Productie-aantekeningen

Een paar dingen die Django-apps in productie bijten zodra FX live is:

  • DB-persistentie is je vangnet. Sla je _persist over, dan legt een upstream-storing checkout plat. Niet overslaan.
  • Cache stampede. Slaan 100 requests tegelijk een koude cache, dan vuur je 100 upstream-calls af. Gebruik django-redis's get_or_set met lock, of accepteer de trade-off bij weinig verkeer.
  • Weekend-koersdrift. FX-markten sluiten. De meeste API's (Finexly incluis) serveren de vrijdagslot het hele weekend; je koersen bewegen pas bij maandagopening. Reken je weekendtransacties af, documenteer het beleid.
  • Audit-trail. De ExchangeRate-tabel groeit snel. Voeg een partial index toe op de laatste 7 dagen, of rol oudere rijen op tot een maandsamenvatting als compliance dat toestaat.

Wil je dieper graven, dan behandelt onze caching- en error-handling-gids voor valuta-API's stampede-bescherming, circuit breakers en graceful degradation in detail.

Veelgestelde vragen

Welke wisselkoers-API werkt het beste met Django? Elke REST-API werkt, maar de ergonomie verschilt. Finexly geeft een platte {rates: {...}}-vorm terug die schoon mapt op een Python-dict, ondersteunt 170+ valuta's en komt met een gratis tier van 1.000 calls/maand — genoeg voor een MVP. Alternatieven als Fixer en Open Exchange Rates hebben andere quota-structuren; onze vergelijkpost loopt de trade-offs door.

Decimal of Float voor wisselkoersen in Django? Altijd DecimalField voor opslag en Decimal in de businesslogica. Float introduceert afrondingsfouten die over duizenden conversies stapelen; in financiële code worden die uiteindelijk zichtbare bugs. De performance-kost is verwaarloosbaar.

Hoe vaak moet ik koersen verversen? Hangt af van je use case. Voor consumer-apps die gelokaliseerde prijzen tonen, is elke 15 minuten meer dan genoeg. Voor accounting-rapporten is per uur prima. Voor trading of realtime hedging wil je sub-second WebSocket-feeds — zie onze REST vs WebSocket-post voor dat patroon.

Kan ik dit gebruiken met Django REST Framework in plaats van Django Ninja? Ja. De service-laag is framework-agnostisch. Vervang fx/api.py door een DRF-APIView die ExchangeRateService().convert(...) aanroept en het resultaat serialiseert. Model, service, management command en tests blijven identiek.

Hoe ga ik om met valuta's die niet in de API-respons zitten? Twee patronen. Of een expliciete ExchangeRateError gooien en aan de gebruiker tonen (schonere UX), of terugvallen op een cross-rate via USD (toleranter maar lastiger te debuggen). Voor de meeste apps is vroeg gooien de juiste keuze.

Probeer Finexly gratis

Klaar om realtime wisselkoersen in je Django-project te integreren? Haal je gratis Finexly-API-key — geen creditcard nodig. Begin met 1.000 gratis requests per maand, realtime koersen voor 170+ valuta's en een JSON-contract dat schoon mapt op Python. Schaal op met onze prijspakketten wanneer je het nodig hebt.

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 →