If you need a currency exchange rate API in Go, the standard library is almost all you need. Go's net/http, encoding/json and context packages are enough to build a fast, dependency-free client. The hard part is not the HTTP call. It is everything around it: timeouts, error handling, caching, money arithmetic and tests. Most tutorials stop after http.Get and fmt.Println, which is fine for a demo and risky for a payment flow.
In this guide you will build a small but production-minded Golang client for the Finexly API documentation endpoints, wrap it in a concurrency-safe cache that survives upstream outages, add a currency conversion function that works in integer minor units, and ship it as an HTTP service with tests. Everything below compiles with Go 1.22 or newer and uses only the standard library.
Why Go Is a Good Fit for Exchange Rate Data
Exchange rates are read-heavy, small and slow-changing at the scale of a web request. That profile suits Go well. Goroutines make it cheap to serve thousands of concurrent requests from one in-memory snapshot, a single static binary is easy to deploy, and context.Context gives you first-class cancellation and deadlines for every outbound call.
The design goal is simple: your request handlers should never call the rates API directly. They read from a cache, and a single background-safe refresh path talks to the API. That keeps latency flat, protects your quota, and means a provider hiccup does not become your outage. If you want the theory behind this, our guide to currency API caching and error handling covers the strategy; here we implement it.
Step 1: Call the API with cURL First
Before writing Go, confirm your key works. Sign up for free, copy your key, and run:
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.
The response has three fields: base (the currency all rates are relative to), timestamp (Unix seconds) and rates (a map of ISO 4217 code to rate). Each value in rates tells you how many units of that currency equal one unit of the base. Store the key in an environment variable, never in source control:
export FINEXLY_API_KEY="your_key_here"Step 2: A Typed Client with Timeouts and Safe Errors
Create a module with go mod init fxdemo and put the following in main.go. It defines the response struct, a custom APIError, and a Client whose Latest method accepts a context.Context:
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
}A few details here are worth calling out, because they are the difference between a demo and production code:
- A hard timeout. The zero-value
http.Clienthas no timeout, so one stalled connection can hold a goroutine forever. We set5 * time.Secondand also pass the caller's context, so a cancelled user request cancels the upstream call. - No key leakage in logs. Because the API key travels as the
apikeyquery parameter, Go's*url.Errorincludes the full URL, key and all, in its message. We unwrap it witherrors.Asand keep only the underlying error. Logging that error is now safe. - A typed
APIError. Callers can useerrors.Asto branch onStatus, for example to back off on429or alert on403. The body is read throughio.LimitReaderso a misbehaving upstream cannot make you buffer megabytes. - A configurable
baseURL. This costs nothing and makes the client trivially testable, as you will see in Step 5.
Step 3: A Concurrency-Safe Cache with Stale Fallback
Calling the API on every request would burn through your quota and add latency to every response. Rates do not change meaningfully within a few minutes for most applications, so cache them. The cache below has three properties that simple map plus Mutex examples miss:
- Readers are never blocked by a refresh. The network call happens outside the read/write lock.
- Only one goroutine refreshes at a time. A second mutex, plus a re-check after acquiring it, prevents a thundering herd when the TTL expires under load.
- Stale data beats no data. If the refresh fails and we hold an older snapshot, we return it and flag
stale=trueso the caller can decide what to do.
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
}Note that we fetch all rates for the base in one request (symbols is omitted). One call per hour then serves every currency pair your service needs, which is about 720 calls a month for a single always-on instance. That fits inside the 1,000-request free plan. A ten-minute TTL would mean about 4,300 calls a month, so shorter refresh intervals need a paid tier; see the pricing plans.
Step 4: Convert Money Without Floating-Point Surprises
Never store money in a float64. Store integer minor units (cents, pence, yen) and apply the rate once, at the end, with explicit rounding:
// 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
}Because rates are quoted against a base, converting EUR to GBP goes through that base: divide by the source rate, multiply by the target rate. The function also treats the base itself as a rate of exactly 1.
There is one real-world trap. The function above assumes both currencies have the same number of decimal places. JPY has 0, USD has 2 and KWD has 3, so a production system must scale by each currency's exponent. Our ISO 4217 currency codes guide lists the minor-unit exponent for every code. For a quick interactive check of your numbers, use the currency converter.
Step 5: Serve It over HTTP
Now wire everything into an HTTP handler. The handler parses the request, reads from the cache using the request context, and reports whether the rates were stale:
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))
}Run it and try a conversion of 100.00 USD to EUR (10,000 cents):
go run . &
curl "http://localhost:8080/convert?amount=10000&from=USD&to=EUR"The response includes rates_timestamp and a stale flag, so downstream systems can display "rates as of..." or refuse to quote when data is too old. That single field is often what auditors and finance teams ask for first.
Step 6: Test Without Touching the Network
Because the base URL is injectable, you can test the full client and cache logic against httptest.NewServer. This test proves the most important behavior: when the API starts failing, the cache serves stale data instead of an error.
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)
}
}Run it with go test ./.... Tests like this run in milliseconds, need no API key, and can be part of CI. You can add cases for 429 responses, malformed JSON and slow servers (to verify the timeout) in the same style.
Production Checklist
Before you ship, run through these points:
- Pick a sensible TTL. One hour suits invoicing and e-commerce display prices, and keeps one instance within the free plan. Quoting or trading features may need minutes or seconds. Match the TTL to the business risk, then choose the plan to fit.
- Expose the rate timestamp anywhere a customer sees a converted amount, and store the rate you used with every transaction so it can be reproduced later.
- Alert on staleness. Emit a metric when
stale=truepersists beyond a threshold. A cache that quietly serves day-old data is worse than an error. - Warm the cache at startup and fail readiness checks if the first fetch fails, so a bad key is caught at deploy time.
- Handle
429with backoff rather than retrying immediately. If you see it often, your TTL is too short or you have multiple instances each refreshing independently. A shared cache such as Redis solves the second case. - Compare providers occasionally. If you are evaluating data sources, our compare currency APIs page and the free currency API overview show what to look for.
If you work in more than one language, the same patterns appear in our Rust tutorial and Python tutorial.
Frequently Asked Questions
Is there a free currency exchange rate API for Go?
Yes. Finexly has a free plan with 1,000 requests per month, and the client above needs no third-party packages. With a one-hour cache, a single always-on instance uses about 720 requests a month, which leaves headroom. Remember that every extra instance refreshes on its own. Check the pricing plans for current limits.
Should I use float64 for exchange rates in Go?
Using float64 for the rate is generally acceptable, since rates are approximations with a handful of significant digits. Using it for money amounts is not. Keep amounts as integer minor units, or use a decimal library, and round once at the end.
How often should I refresh exchange rates?
It depends on your risk tolerance. Display prices and reporting are usually fine with 15 to 60 minutes. Invoicing often locks a rate per document. Anything that moves money in real time should store the exact rate and timestamp used, whatever the refresh interval is.
How do I stop my API key from leaking in Go logs?
When a key is sent as a query parameter, errors returned by http.Client embed the full URL. Unwrap *url.Error with errors.As and log only its Err field, as the client in this guide does. Also avoid logging full request URLs in middleware.
Can I fetch several base currencies concurrently?
Yes. Use golang.org/x/sync/errgroup with SetLimit to bound parallelism and share the same Client, which is safe for concurrent use. In most cases one base currency plus cross-rate math, as shown in Step 4, is simpler and cheaper.
Next Steps
Ready to integrate real-time exchange rates into your Go service? Get your free Finexly API key: no credit card required. Start with 1,000 free requests per month and upgrade as you grow.
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 →