Terug naar Blog

Wisselkoers-API in Go: productieklare Golang-tutorial (2026)

V
Vlado Grigirov
November 10, 2026
Currency API Golang Tutorial Exchange Rates Finexly Backend

Heb je een wisselkoers-API in Go nodig, dan is de standaardbibliotheek bijna voldoende. De pakketten net/http, encoding/json en context volstaan om een snelle client zonder afhankelijkheden te bouwen. Het lastige is niet de HTTP-aanroep zelf, maar alles eromheen: time-outs, foutafhandeling, caching, rekenen met geld en tests. De meeste tutorials stoppen bij http.Get en fmt.Println. Voor een demo is dat prima, in een betaalproces is het riskant.

In deze gids bouw je een kleine, maar productiegerichte Golang-client voor de endpoints uit de Finexly API-documentatie, verpak je die in een concurrency-veilige cache die storingen bij de aanbieder overleeft, voeg je een conversiefunctie toe die met gehele kleinste eenheden werkt en lever je alles op als HTTP-service met tests. Alle code compileert met Go 1.22 of nieuwer en gebruikt alleen de standaardbibliotheek.

Waarom Go goed past bij wisselkoersdata

Wisselkoersen worden heel vaak gelezen, zijn klein en veranderen op de schaal van een webverzoek langzaam. Dat profiel past uitstekend bij Go. Met goroutines bedien je goedkoop duizenden gelijktijdige verzoeken vanuit één snapshot in het geheugen, één statische binary is eenvoudig uit te rollen en context.Context biedt annulering en deadlines van het eerste niveau voor elke uitgaande aanroep.

Het ontwerpdoel is eenvoudig: je request handlers mogen de koers-API nooit rechtstreeks aanroepen. Ze lezen uit een cache, en één veilig verversingspad praat met de API. Zo blijft de latency vlak, wordt je quotum beschermd en groeit een haperende aanbieder niet uit tot jouw storing. Wil je de theorie, dan behandelt onze gids over caching en foutafhandeling bij valuta-API's de strategie; hier voeren we haar uit.

Stap 1: probeer eerst cURL

Controleer voordat je Go schrijft of je sleutel werkt. Meld je gratis aan, kopieer je sleutel en voer uit:

curl "https://api.finexly.com/v1/latest?base=USD&symbols=EUR,GBP,JPY&apikey=$FINEXLY_API_KEY"
{
  "base": "USD",
  "timestamp": 1762761600,
  "rates": { "EUR": 0.92, "GBP": 0.80, "JPY": 151.2 }
}

The numbers above are illustrative.

Het antwoord heeft drie velden: base (de valuta waartegen alle koersen zijn uitgedrukt), timestamp (Unix-seconden) en rates (een map van ISO 4217-code naar koers). Elke waarde in rates geeft aan hoeveel eenheden van die valuta gelijk zijn aan één eenheid van de basis. Bewaar de sleutel in een omgevingsvariabele, nooit in versiebeheer:

export FINEXLY_API_KEY="your_key_here"

Stap 2: een getypeerde client met time-outs en veilige fouten

Maak een module met go mod init fxdemo en zet de volgende code in main.go. Hij definieert de responsstruct, een eigen APIError en een Client waarvan de methode Latest een context.Context accepteert:

package main

import (
	"context"
	"encoding/json"
	"errors"
	"fmt"
	"io"
	"log"
	"math"
	"net/http"
	"net/url"
	"os"
	"strconv"
	"strings"
	"sync"
	"time"
)

type RatesResponse struct {
	Base      string             `json:"base"`
	Timestamp int64              `json:"timestamp"`
	Rates     map[string]float64 `json:"rates"`
}

type APIError struct {
	Status int
	Body   string
}

func (e *APIError) Error() string {
	return fmt.Sprintf("finexly: HTTP %d: %s", e.Status, e.Body)
}

type Client struct {
	apiKey  string
	baseURL string
	http    *http.Client
}

func NewClient(apiKey string) *Client {
	return &Client{
		apiKey:  apiKey,
		baseURL: "https://api.finexly.com/v1",
		http:    &http.Client{Timeout: 5 * time.Second},
	}
}

func (c *Client) Latest(ctx context.Context, base string, symbols []string) (*RatesResponse, error) {
	q := url.Values{}
	q.Set("base", base)
	if len(symbols) > 0 {
		q.Set("symbols", strings.Join(symbols, ","))
	}
	q.Set("apikey", c.apiKey)

	req, err := http.NewRequestWithContext(ctx, http.MethodGet, c.baseURL+"/latest?"+q.Encode(), nil)
	if err != nil {
		return nil, errors.New("finexly: build request failed")
	}
	resp, err := c.http.Do(req)
	if err != nil {
		var ue *url.Error
		if errors.As(err, &ue) {
			err = ue.Err // drop the URL, it contains the API key
		}
		return nil, fmt.Errorf("finexly: request failed: %w", err)
	}
	defer resp.Body.Close()

	if resp.StatusCode != http.StatusOK {
		body, _ := io.ReadAll(io.LimitReader(resp.Body, 512))
		return nil, &APIError{Status: resp.StatusCode, Body: strings.TrimSpace(string(body))}
	}
	var out RatesResponse
	if err := json.NewDecoder(resp.Body).Decode(&out); err != nil {
		return nil, fmt.Errorf("finexly: decode response: %w", err)
	}
	return &out, nil
}

Een paar details maken het verschil tussen een demo en productiecode:

  • Een harde time-out. De http.Client met nulwaarden heeft geen time-out, dus één vastgelopen verbinding kan een goroutine eeuwig vasthouden. We stellen 5 * time.Second in en geven ook de context van de aanroeper door, zodat een door de gebruiker geannuleerd verzoek ook de upstream-aanroep annuleert.
  • Geen sleutel in de logs. Omdat de sleutel als queryparameter apikey meereist, bevat Go's *url.Error de volledige URL, sleutel inbegrepen, in zijn bericht. We pakken die uit met errors.As en houden alleen de onderliggende fout over. Het loggen van die fout is daarmee veilig.
  • Een getypeerde APIError. Aanroepers kunnen met errors.As vertakken op Status, bijvoorbeeld terugschakelen bij 429 of alarm slaan bij 403. De body wordt gelezen via io.LimitReader, zodat een falende upstream je geen megabytes laat bufferen.
  • Een configureerbare baseURL. Het kost niets en maakt de client triviaal testbaar, zoals je in stap 6 ziet.

Stap 3: een concurrency-veilige cache met verouderde data als terugval

Bij elk verzoek de API aanroepen put je quotum uit en voegt latency toe. In de meeste toepassingen veranderen koersen binnen enkele minuten niet noemenswaardig, dus cache ze. De onderstaande cache heeft drie eigenschappen die eenvoudige voorbeelden met map en Mutex missen:

  1. Lezers worden nooit geblokkeerd door een verversing. De netwerkaanroep gebeurt buiten het lees-/schrijfslot.
  2. Slechts één goroutine ververst tegelijk. Een tweede mutex plus een nieuwe controle na het verkrijgen ervan voorkomt een stormloop wanneer de TTL onder belasting verloopt.
  3. Verouderde data is beter dan geen data. Mislukt de verversing en hebben we een oudere snapshot, dan geven we die terug met stale=true, zodat de aanroeper kan beslissen wat te doen.
type Cache struct {
	client *Client
	base   string
	ttl    time.Duration

	mu        sync.RWMutex
	data      *RatesResponse
	fetchedAt time.Time

	refreshMu sync.Mutex
}

func NewCache(c *Client, base string, ttl time.Duration) *Cache {
	return &Cache{client: c, base: base, ttl: ttl}
}

func (c *Cache) fresh() (*RatesResponse, bool) {
	c.mu.RLock()
	defer c.mu.RUnlock()
	if c.data != nil && time.Since(c.fetchedAt) < c.ttl {
		return c.data, true
	}
	return nil, false
}

// Get returns rates, and stale=true if it had to fall back to old data.
func (c *Cache) Get(ctx context.Context) (rates *RatesResponse, stale bool, err error) {
	if d, ok := c.fresh(); ok {
		return d, false, nil
	}
	c.refreshMu.Lock() // only one goroutine hits the API
	defer c.refreshMu.Unlock()
	if d, ok := c.fresh(); ok { // refreshed while we waited
		return d, false, nil
	}
	d, err := c.client.Latest(ctx, c.base, nil)
	if err != nil {
		c.mu.RLock()
		old := c.data
		c.mu.RUnlock()
		if old != nil {
			return old, true, nil
		}
		return nil, false, err
	}
	c.mu.Lock()
	c.data, c.fetchedAt = d, time.Now()
	c.mu.Unlock()
	return d, false, nil
}

Merk op dat we alle koersen van de basis in één verzoek ophalen (symbols ontbreekt). Eén aanroep per uur bedient dan elk valutapaar dat je service nodig heeft, ongeveer 720 aanroepen per maand voor een permanent draaiende instantie. Dat past binnen het gratis plan van 1.000 verzoeken. Een TTL van tien minuten zou ongeveer 4.300 aanroepen per maand betekenen, dus kortere intervallen vragen een betaald plan; zie de prijsplannen.

Stap 4: reken geld om zonder floating-point verrassingen

Sla geld nooit op in een float64. Sla gehele kleinste eenheden op (centen, pence, yen) en pas de koers slechts één keer toe, aan het eind, met expliciete afronding:

// Convert works in minor units (cents). It assumes both currencies use the
// same number of decimal places; see the ISO 4217 note in the article.
func Convert(amountMinor int64, from, to string, r *RatesResponse) (int64, error) {
	rate := func(code string) (float64, error) {
		if code == r.Base {
			return 1, nil
		}
		v, ok := r.Rates[code]
		if !ok || v <= 0 {
			return 0, fmt.Errorf("no rate for %q", code)
		}
		return v, nil
	}
	f, err := rate(from)
	if err != nil {
		return 0, err
	}
	t, err := rate(to)
	if err != nil {
		return 0, err
	}
	return int64(math.Round(float64(amountMinor) * t / f)), nil
}

Omdat koersen ten opzichte van een basis worden genoteerd, loopt het omrekenen van EUR naar GBP via die basis: delen door de bronkoers, vermenigvuldigen met de doelkoers. De functie behandelt de basis zelf ook als een koers van precies 1.

Er is een echte valkuil. De functie hierboven gaat ervan uit dat beide valuta evenveel decimalen hebben. JPY heeft er 0, USD 2 en KWD 3, dus een productiesysteem moet schalen met de exponent van elke valuta. Onze gids voor ISO 4217-valutacodes noemt de exponent van de kleinste eenheid voor elke code. Voor een snelle interactieve controle van je getallen gebruik je de valutaomrekenaar.

Stap 5: bied het aan via HTTP

Koppel nu alles aan een HTTP-handler. De handler parseert het verzoek, leest uit de cache met de requestcontext en meldt of de koersen verouderd waren:

func convertHandler(cache *Cache) http.HandlerFunc {
	return func(w http.ResponseWriter, r *http.Request) {
		q := r.URL.Query()
		amount, err := strconv.ParseInt(q.Get("amount"), 10, 64)
		if err != nil {
			http.Error(w, "amount must be an integer in minor units", http.StatusBadRequest)
			return
		}
		from, to := strings.ToUpper(q.Get("from")), strings.ToUpper(q.Get("to"))

		rates, stale, err := cache.Get(r.Context())
		if err != nil {
			log.Printf("rates unavailable: %v", err)
			http.Error(w, "rates unavailable", http.StatusBadGateway)
			return
		}
		out, err := Convert(amount, from, to, rates)
		if err != nil {
			http.Error(w, err.Error(), http.StatusBadRequest)
			return
		}
		w.Header().Set("Content-Type", "application/json")
		json.NewEncoder(w).Encode(map[string]any{
			"from": from, "to": to,
			"amount_minor": amount, "converted_minor": out,
			"rates_timestamp": rates.Timestamp, "stale": stale,
		})
	}
}

func main() {
	apiKey := os.Getenv("FINEXLY_API_KEY")
	if apiKey == "" {
		log.Fatal("FINEXLY_API_KEY is not set")
	}
	cache := NewCache(NewClient(apiKey), "USD", time.Hour)
	http.Handle("/convert", convertHandler(cache))
	log.Println("listening on :8080")
	log.Fatal(http.ListenAndServe(":8080", nil))
}

Start hem en probeer 100,00 USD om te rekenen naar EUR (10.000 cent):

go run . &
curl "http://localhost:8080/convert?amount=10000&from=USD&to=EUR"

Het antwoord bevat rates_timestamp en een stale-vlag, zodat vervolgsystemen "koersen van..." kunnen tonen of weigeren een prijs te noemen als de data te oud is. Dat ene veld is vaak het eerste waar auditors en financiële teams om vragen.

Stap 6: test zonder het netwerk aan te raken

Omdat de basis-URL injecteerbaar is, kun je de volledige client- en cachelogica testen met httptest.NewServer. Deze test bewijst het belangrijkste gedrag: als de API begint te falen, levert de cache verouderde data in plaats van een fout.

func TestLatestAndStaleFallback(t *testing.T) {
	healthy := true
	srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		if !healthy {
			http.Error(w, `{"error":"boom"}`, http.StatusInternalServerError)
			return
		}
		w.Write([]byte(`{"base":"USD","timestamp":1,"rates":{"EUR":0.92}}`))
	}))
	defer srv.Close()

	c := NewClient("test-key")
	c.baseURL = srv.URL
	cache := NewCache(c, "USD", time.Millisecond)

	if _, stale, err := cache.Get(context.Background()); err != nil || stale {
		t.Fatalf("first call: err=%v stale=%v", err, stale)
	}
	healthy = false
	time.Sleep(5 * time.Millisecond)
	r, stale, err := cache.Get(context.Background())
	if err != nil || !stale || r.Rates["EUR"] != 0.92 {
		t.Fatalf("expected stale fallback, got err=%v stale=%v", err, stale)
	}
}

func TestConvert(t *testing.T) {
	r := &RatesResponse{Base: "USD", Rates: map[string]float64{"EUR": 0.92, "GBP": 0.80}}
	got, err := Convert(10000, "EUR", "GBP", r)
	if err != nil || got != 8696 {
		t.Fatalf("got %d err %v", got, err)
	}
}

Voer hem uit met go test ./.... Zulke tests draaien in milliseconden, hebben geen API-key nodig en passen in je CI. In dezelfde stijl voeg je gevallen toe voor 429-antwoorden, onjuiste JSON en trage servers (om de time-out te verifiëren).

Checklist voor productie

Loop vóór livegang deze punten na:

  • Kies een verstandige TTL. Een uur past bij facturatie en getoonde webshopprijzen en houdt één instantie binnen het gratis plan. Offerte- of tradingfuncties kunnen minuten of seconden vragen. Stem de TTL af op het bedrijfsrisico en kies daarna het plan.
  • Toon de tijdstempel van de koers overal waar een klant een omgerekend bedrag ziet, en bewaar bij elke transactie de gebruikte koers, zodat je die later kunt reproduceren.
  • Alarmeer bij veroudering. Stuur een metriek uit wanneer stale=true langer dan een drempel aanhoudt. Een cache die stilletjes dag-oude data levert, is erger dan een fout.
  • Warm de cache op bij het starten en laat readiness-checks falen als de eerste ophaalactie mislukt, zodat een foute sleutel bij de deploy opvalt.
  • Behandel 429 met backoff in plaats van direct opnieuw te proberen. Zie je het vaak, dan is je TTL te kort of verversen meerdere instanties zelfstandig. Een gedeelde cache zoals Redis lost het tweede geval op.
  • Vergelijk aanbieders af en toe. Evalueer je databronnen, dan laten onze pagina om valuta-API's te vergelijken en het overzicht van de gratis valuta-API zien waar je op moet letten.

Werk je in meer dan één taal, dan zie je dezelfde patronen in onze Rust-tutorial en Python-tutorial.

Veelgestelde vragen

Is er een gratis wisselkoers-API voor Go?

Ja. Finexly heeft een gratis plan met 1.000 verzoeken per maand en de client hierboven heeft geen pakketten van derden nodig. Met een cache van een uur gebruikt een permanent draaiende instantie ongeveer 720 verzoeken per maand, wat ruimte laat. Houd er rekening mee dat elke extra instantie zelfstandig ververst. Zie de prijsplannen voor de actuele limieten.

Moet ik float64 gebruiken voor wisselkoersen in Go?

float64 gebruiken voor de koers is meestal acceptabel, omdat koersen benaderingen zijn met een paar significante cijfers. Voor geldbedragen is het dat niet. Bewaar bedragen als gehele kleinste eenheden of gebruik een decimale bibliotheek, en rond slechts één keer af, aan het eind.

Hoe vaak moet ik wisselkoersen verversen?

Dat hangt af van je risicotolerantie. Voor getoonde prijzen en rapportages is 15 tot 60 minuten meestal prima. Bij facturatie wordt vaak één koers per document vastgelegd. Alles wat in realtime geld verplaatst, moet de exact gebruikte koers en tijdstempel bewaren, ongeacht het verversingsinterval.

Hoe voorkom ik dat mijn API-key in Go-logs lekt?

Wordt een sleutel als queryparameter verstuurd, dan bevatten fouten van http.Client de volledige URL. Pak *url.Error uit met errors.As en log alleen het veld Err, zoals de client in deze gids doet. Vermijd ook het loggen van volledige request-URL's in middleware.

Kan ik meerdere basisvaluta's gelijktijdig ophalen?

Ja. Gebruik golang.org/x/sync/errgroup met SetLimit om parallellisme te begrenzen en deel dezelfde Client, die veilig is voor gelijktijdig gebruik. In de meeste gevallen is één basisvaluta plus de kruiskoersberekening uit stap 4 eenvoudiger en goedkoper.

Volgende stappen

Klaar om realtime wisselkoersen in je Go-service te integreren? Haal je gratis Finexly API-key op: geen creditcard nodig. Begin met 1.000 gratis verzoeken per maand en upgrade naarmate je groeit.

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 →