GoGolangTutorialGeolocationAPI

IP Geolocation in Go: Detect Country and Currency with One API Call

ApogeoAPI5 min read

Go developers often reach for MaxMind's GeoLite2 database for IP lookups. It works, but it needs weekly updates, adds ~60MB to your binary, and still doesn't give you live exchange rates. A REST API call is often simpler — especially with Go's excellent net/http client.

A geo package with in-memory caching

// geo/geo.go
package geo

import (
	"encoding/json"
	"fmt"
	"io"
	"net"
	"net/http"
	"os"
	"strings"
	"sync"
	"time"
)

const baseURL = "https://api.apogeoapi.com/v1"

type GeoData struct {
	Country struct {
		Name         string  "json:"name""
		ISO2         string  "json:"iso2""
		Currency     string  "json:"currency""
		CurrencyRate float64 "json:"currencyRate""
		FlagURL      string  "json:"flagUrl""
	} "json:"country""
	City struct {
		Name string "json:"name""
	} "json:"city""
	Timezone string "json:"timezone""
}

type cacheEntry struct {
	data    GeoData
	expires time.Time
}

var (
	cache sync.Map
	ttl   = 1 * time.Hour
	key   = os.Getenv("APOGEOAPI_KEY")
)

// LookupIP returns geo data for an IP. Results are cached per /24 subnet for 1 hour.
func LookupIP(ip string) (GeoData, error) {
	if isPrivate(ip) {
		return GeoData{}, nil
	}

	subnet := toSubnet(ip)
	if v, ok := cache.Load(subnet); ok {
		entry := v.(cacheEntry)
		if time.Now().Before(entry.expires) {
			return entry.data, nil
		}
	}

	req, _ := http.NewRequest("GET", fmt.Sprintf("%s/ip/%s", baseURL, ip), nil)
	req.Header.Set("X-API-Key", key)

	client := &http.Client{Timeout: 3 * time.Second}
	resp, err := client.Do(req)
	if err != nil {
		return GeoData{}, err
	}
	defer resp.Body.Close()

	body, _ := io.ReadAll(resp.Body)
	var data GeoData
	if err := json.Unmarshal(body, &data); err != nil {
		return GeoData{}, err
	}

	cache.Store(subnet, cacheEntry{data: data, expires: time.Now().Add(ttl)})
	return data, nil
}

// RealIP extracts the client IP from a request, handling reverse proxies.
func RealIP(r *http.Request) string {
	if cf := r.Header.Get("CF-Connecting-IP"); cf != "" {
		return cf
	}
	if xff := r.Header.Get("X-Forwarded-For"); xff != "" {
		return strings.Split(xff, ",")[0]
	}
	host, _, err := net.SplitHostPort(r.RemoteAddr)
	if err != nil {
		return r.RemoteAddr
	}
	return host
}

func toSubnet(ip string) string {
	parts := strings.Split(ip, ".")
	if len(parts) == 4 {
		return strings.Join(parts[:3], ".") + ".0"
	}
	return ip // IPv6: cache by full address
}

func isPrivate(ip string) bool {
	parsed := net.ParseIP(ip)
	if parsed == nil {
		return true
	}
	private := []string{"10.0.0.0/8", "172.16.0.0/12", "192.168.0.0/16", "127.0.0.0/8", "::1/128"}
	for _, cidr := range private {
		_, network, _ := net.ParseCIDR(cidr)
		if network.Contains(parsed) {
			return true
		}
	}
	return false
}

net/http middleware

// middleware/geo.go
package middleware

import (
	"context"
	"net/http"

	"your-module/geo"
)

type contextKey string

const GeoKey contextKey = "geo"

// GeoMiddleware resolves IP geolocation once per request and stores it in context.
func GeoMiddleware(next http.Handler) http.Handler {
	return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		ip := geo.RealIP(r)
		data, _ := geo.LookupIP(ip) // errors produce zero-value GeoData; never panic
		ctx := context.WithValue(r.Context(), GeoKey, data)
		next.ServeHTTP(w, r.WithContext(ctx))
	})
}

// FromContext retrieves geo data stored by GeoMiddleware.
func FromContext(ctx context.Context) geo.GeoData {
	if v, ok := ctx.Value(GeoKey).(geo.GeoData); ok {
		return v
	}
	return geo.GeoData{}
}

Using it in handlers

// main.go
package main

import (
	"encoding/json"
	"net/http"

	"your-module/middleware"
)

func pricingHandler(w http.ResponseWriter, r *http.Request) {
	geoData := middleware.FromContext(r.Context())

	usdPrice := 19.0
	rate := geoData.Country.CurrencyRate
	currency := geoData.Country.Currency

	localPrice := usdPrice
	if rate > 0 {
		localPrice = usdPrice * rate
	}
	if currency == "" {
		currency = "USD"
	}

	json.NewEncoder(w).Encode(map[string]any{
		"price_usd":    usdPrice,
		"price_local":  localPrice,
		"currency":     currency,
		"country":      geoData.Country.Name,
		"country_code": geoData.Country.ISO2,
	})
}

func main() {
	mux := http.NewServeMux()
	mux.HandleFunc("/pricing", pricingHandler)

	// Wrap the mux with geo middleware
	handler := middleware.GeoMiddleware(mux)
	http.ListenAndServe(":8080", handler)
}

With Gin or Chi

The middleware pattern works with any Go router. With Gin:

func GinGeoMiddleware() gin.HandlerFunc {
	return func(c *gin.Context) {
		ip := geo.RealIP(c.Request)
		data, _ := geo.LookupIP(ip)
		c.Set("geo", data)
		c.Next()
	}
}

// In a handler:
func pricingHandler(c *gin.Context) {
	geoRaw, _ := c.Get("geo")
	geoData := geoRaw.(geo.GeoData)
	c.JSON(200, gin.H{"country": geoData.Country.Name})
}

With Chi:

r := chi.NewRouter()
r.Use(func(next http.Handler) http.Handler {
	return middleware.GeoMiddleware(next)
})

Getting just an exchange rate

func GetRate(currency string) (float64, error) {
	req, _ := http.NewRequest("GET",
		fmt.Sprintf("%s/exchange-rates/%s", baseURL, currency), nil)
	req.Header.Set("X-API-Key", os.Getenv("APOGEOAPI_KEY"))

	client := &http.Client{Timeout: 5 * time.Second}
	resp, err := client.Do(req)
	if err != nil {
		return 0, err
	}
	defer resp.Body.Close()

	var result struct {
		USDRate float64 "json:"usdRate""
	}
	json.NewDecoder(resp.Body).Decode(&result)
	return result.USDRate, nil
}

Quota math with subnet caching

With sync.Map caching per /24 subnet at 1-hour TTL:

  • A service with 10K daily users distributed across 500 unique /24 subnets → 500 API calls/day → 15K calls/month → fits the Basic tier ($19/mo)
  • Most internal/office traffic shares a subnet — real call volume is 5–20x lower than raw user count
  • Free tier (1K req/month) comfortably handles ~2K DAU

Free API key at apogeoapi.com — 1,000 req/month, no credit card required.

Try ApogeoAPI free

1,000 requests/month forever. 14-day full-access trial. No credit card.

Get your free API key