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.Clientmet nulwaarden heeft geen time-out, dus één vastgelopen verbinding kan een goroutine eeuwig vasthouden. We stellen5 * time.Secondin 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
apikeymeereist, bevat Go's*url.Errorde volledige URL, sleutel inbegrepen, in zijn bericht. We pakken die uit meterrors.Asen houden alleen de onderliggende fout over. Het loggen van die fout is daarmee veilig. - Een getypeerde
APIError. Aanroepers kunnen meterrors.Asvertakken opStatus, bijvoorbeeld terugschakelen bij429of alarm slaan bij403. De body wordt gelezen viaio.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:
- Lezers worden nooit geblokkeerd door een verversing. De netwerkaanroep gebeurt buiten het lees-/schrijfslot.
- 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.
- 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=truelanger 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
429met 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.
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 →