Volver al blog

API de tipos de cambio en Go: tutorial de Golang listo para producción (2026)

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

Si necesitas una API de tipos de cambio en Go, la biblioteca estándar casi lo resuelve todo. Los paquetes net/http, encoding/json y context bastan para construir un cliente rápido y sin dependencias. Lo difícil no es la llamada HTTP, sino todo lo que la rodea: timeouts, gestión de errores, caché, aritmética monetaria y pruebas. La mayoría de los tutoriales se detienen en http.Get y fmt.Println, lo cual sirve para una demo pero es arriesgado en un flujo de pagos.

En esta guía construirás un cliente de Golang pequeño pero pensado para producción para los endpoints de la documentación de la API de Finexly, lo envolverás en una caché segura para concurrencia que sobrevive a caídas del proveedor, añadirás una función de conversión que trabaja con unidades menores enteras y lo publicarás como servicio HTTP con pruebas. Todo compila con Go 1.22 o superior y usa solo la biblioteca estándar.

Por qué Go encaja bien con los datos de tipos de cambio

Los tipos de cambio se leen mucho, ocupan poco y cambian despacio a la escala de una petición web. Ese perfil le va muy bien a Go. Las goroutines permiten atender miles de peticiones concurrentes desde una misma instantánea en memoria, un único binario estático es fácil de desplegar y context.Context ofrece cancelación y plazos de primera clase en cada llamada saliente.

El objetivo de diseño es simple: tus handlers nunca deben llamar directamente a la API de tipos de cambio. Leen de una caché, y una única ruta de actualización segura habla con la API. Así la latencia se mantiene estable, proteges tu cuota y un fallo del proveedor no se convierte en tu caída. Si quieres la teoría, nuestra guía de caché y gestión de errores en APIs de divisas cubre la estrategia; aquí la implementamos.

Paso 1: prueba primero con cURL

Antes de escribir Go, confirma que tu clave funciona. Regístrate gratis, copia tu clave y ejecuta:

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.

La respuesta tiene tres campos: base (la moneda respecto a la cual se expresan las tasas), timestamp (segundos Unix) y rates (un mapa de código ISO 4217 a tasa). Cada valor de rates indica cuántas unidades de esa moneda equivalen a una unidad de la base. Guarda la clave en una variable de entorno, nunca en el control de versiones:

export FINEXLY_API_KEY="your_key_here"

Paso 2: un cliente tipado con timeouts y errores seguros

Crea un módulo con go mod init fxdemo y coloca lo siguiente en main.go. Define la estructura de respuesta, un APIError propio y un Client cuyo método Latest acepta un 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
}

Algunos detalles marcan la diferencia entre una demo y código de producción:

  • Un timeout estricto. El http.Client con valores por defecto no tiene timeout, así que una conexión colgada puede retener una goroutine para siempre. Fijamos 5 * time.Second y además pasamos el contexto del llamador, de modo que una petición cancelada por el usuario cancela la llamada aguas arriba.
  • Sin filtrar la clave en los logs. Como la clave viaja en el parámetro apikey, el *url.Error de Go incluye la URL completa, clave incluida, en su mensaje. Lo desenvolvemos con errors.As y conservamos solo el error subyacente. Registrar ese error ya es seguro.
  • Un APIError tipado. Quien llama puede usar errors.As para decidir según Status, por ejemplo esperar ante un 429 o alertar ante un 403. El cuerpo se lee con io.LimitReader para que un servidor defectuoso no te haga almacenar megabytes.
  • Un baseURL configurable. No cuesta nada y hace que el cliente sea trivial de probar, como verás en el paso 6.

Paso 3: una caché segura para concurrencia con respaldo obsoleto

Llamar a la API en cada petición agotaría tu cuota y añadiría latencia. En la mayoría de aplicaciones los tipos no cambian de forma relevante en unos pocos minutos, así que se cachean. La caché siguiente tiene tres propiedades que los ejemplos simples de map más Mutex pasan por alto:

  1. Los lectores nunca se bloquean por una actualización. La llamada de red se hace fuera del bloqueo de lectura/escritura.
  2. Solo una goroutine actualiza a la vez. Un segundo mutex, más una nueva comprobación tras adquirirlo, evita la estampida de peticiones cuando expira el TTL bajo carga.
  3. Datos obsoletos mejor que ningún dato. Si la actualización falla y tenemos una instantánea anterior, la devolvemos con stale=true para que quien llama decida qué hacer.
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
}

Fíjate en que pedimos todas las tasas de la base en una sola petición (se omite symbols). Una llamada por hora sirve entonces cada par de divisas que necesite tu servicio, unas 720 llamadas al mes para una instancia siempre activa. Eso cabe en el plan gratuito de 1.000 peticiones. Un TTL de diez minutos supondría unas 4.300 llamadas al mes, así que intervalos más cortos requieren un plan de pago; consulta los planes de precios.

Paso 4: convierte dinero sin sorpresas de coma flotante

Nunca guardes dinero en un float64. Guarda unidades menores enteras (céntimos, peniques, yenes) y aplica la tasa una sola vez, al final, con redondeo explícito:

// 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
}

Como las tasas se expresan respecto a una base, convertir EUR a GBP pasa por esa base: se divide por la tasa de origen y se multiplica por la de destino. La función también trata la propia base como una tasa exacta de 1.

Hay una trampa real. La función anterior supone que ambas monedas tienen el mismo número de decimales. El JPY tiene 0, el USD 2 y el KWD 3, así que un sistema de producción debe escalar según el exponente de cada moneda. Nuestra guía de códigos de moneda ISO 4217 lista el exponente de unidad menor de cada código. Para una comprobación interactiva rápida, usa el conversor de divisas.

Paso 5: sírvelo por HTTP

Ahora conecta todo en un handler HTTP. El handler analiza la petición, lee de la caché con el contexto de la petición e indica si las tasas estaban obsoletas:

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))
}

Ejecútalo y prueba la conversión de 100,00 USD a EUR (10.000 céntimos):

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

La respuesta incluye rates_timestamp y un indicador stale, de modo que los sistemas posteriores pueden mostrar "tasas a fecha de..." o negarse a cotizar si los datos son demasiado antiguos. Ese único campo suele ser lo primero que piden auditores y equipos financieros.

Paso 6: pruebas sin tocar la red

Como la URL base es inyectable, puedes probar toda la lógica del cliente y la caché con httptest.NewServer. Esta prueba demuestra el comportamiento más importante: cuando la API empieza a fallar, la caché sirve datos obsoletos en lugar de un 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)
	}
}

Ejecútala con go test ./.... Pruebas como esta tardan milisegundos, no necesitan clave de API y pueden formar parte de tu CI. Puedes añadir casos para respuestas 429, JSON mal formado y servidores lentos (para verificar el timeout) con el mismo estilo.

Lista de comprobación para producción

Antes de desplegar, repasa estos puntos:

  • Elige un TTL razonable. Una hora sirve para facturación y precios mostrados en tiendas online, y mantiene una instancia dentro del plan gratuito. Las funciones de cotización o trading pueden necesitar minutos o segundos. Ajusta el TTL al riesgo del negocio y elige después el plan.
  • Expón la marca de tiempo de la tasa en cualquier lugar donde un cliente vea un importe convertido, y guarda la tasa usada en cada transacción para poder reproducirla después.
  • Alerta por obsolescencia. Emite una métrica cuando stale=true persista más allá de un umbral. Una caché que sirve en silencio datos de hace un día es peor que un error.
  • Calienta la caché al arrancar y haz fallar las comprobaciones de disponibilidad si la primera descarga falla, para detectar una clave incorrecta en el despliegue.
  • Gestiona el 429 con espera progresiva en lugar de reintentar de inmediato. Si lo ves a menudo, tu TTL es demasiado corto o tienes varias instancias que actualizan por separado. Una caché compartida como Redis resuelve el segundo caso.
  • Compara proveedores de vez en cuando. Si evalúas fuentes de datos, nuestra página para comparar APIs de divisas y la guía de API de divisas gratuita muestran en qué fijarte.

Si trabajas con más de un lenguaje, los mismos patrones aparecen en nuestro tutorial de Rust y en el tutorial de Python.

Preguntas frecuentes

¿Existe una API gratuita de tipos de cambio para Go?

Sí. Finexly tiene un plan gratuito con 1.000 peticiones al mes, y el cliente de esta guía no necesita paquetes de terceros. Con una caché de una hora, una instancia siempre activa usa unas 720 peticiones al mes, lo que deja margen. Recuerda que cada instancia adicional se actualiza por su cuenta. Consulta los planes de precios para ver los límites actuales.

¿Debo usar float64 para los tipos de cambio en Go?

Usar float64 para la tasa suele ser aceptable, ya que las tasas son aproximaciones con pocas cifras significativas. Usarlo para los importes no lo es. Guarda los importes como unidades menores enteras, o usa una biblioteca decimal, y redondea una sola vez al final.

¿Con qué frecuencia debo actualizar los tipos de cambio?

Depende de tu tolerancia al riesgo. Para precios mostrados e informes suelen bastar de 15 a 60 minutos. La facturación suele fijar una tasa por documento. Todo lo que mueva dinero en tiempo real debe guardar la tasa exacta y la marca de tiempo usadas, sea cual sea el intervalo de actualización.

¿Cómo evito que mi API key se filtre en los logs de Go?

Cuando la clave se envía como parámetro de consulta, los errores de http.Client incluyen la URL completa. Desenvuelve *url.Error con errors.As y registra solo su campo Err, como hace el cliente de esta guía. Evita además registrar URLs completas en el middleware.

¿Puedo obtener varias monedas base de forma concurrente?

Sí. Usa golang.org/x/sync/errgroup con SetLimit para acotar el paralelismo y comparte el mismo Client, que es seguro para uso concurrente. En la mayoría de casos, una sola moneda base más el cálculo de tipos cruzados del paso 4 es más simple y barato.

Siguientes pasos

¿Listo para integrar tipos de cambio en tiempo real en tu servicio Go? Consigue tu API key gratuita de Finexly: sin tarjeta de crédito. Empieza con 1.000 peticiones gratis al mes y amplía a medida que crezcas.

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 →