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
/24subnets → 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