iplogs.com
Guide · Last updated 2026-05-24

VPN, proxy, and Tor detection in Go

End-to-end Go integration of the IPLogs public API using only the standard library: typed verdict, net/http with context.WithTimeout, per-IP cache, middleware, batch scoring via /v1/bulk-check, and tests against an httptest.Server. No SDK, no API key.

Type the verdict

package iplogs

type Verdict string

const (
    VerdictClean       Verdict = "clean"
    VerdictSuspicious  Verdict = "suspicious"
    VerdictVPNLikely   Verdict = "vpn_likely"
    VerdictVPNDetected Verdict = "vpn_detected"
)

type IPInfo struct {
    IP                 string   `json:"ip"`
    ASN                string   `json:"asn,omitempty"`
    Org                string   `json:"org,omitempty"`
    Country            string   `json:"country,omitempty"`
    CountryCode        string   `json:"country_code,omitempty"`
    IsVPN              bool     `json:"is_vpn,omitempty"`
    VPNProvider        string   `json:"vpn_provider,omitempty"`
    VPNProviderSources []string `json:"vpn_provider_sources,omitempty"`
}

type Signal struct {
    Type    string  `json:"type"`
    Weight  float64 `json:"weight"`
    Matched bool    `json:"matched"`
    Detail  string  `json:"detail"`
}

type CheckResponse struct {
    Verdict    Verdict  `json:"verdict"`
    Score      float64  `json:"score"`
    IsVPN      bool     `json:"is_vpn"`
    Confidence float64  `json:"confidence"`
    Signals    []Signal `json:"signals"`
    IPInfo     IPInfo   `json:"ip_info"`
    RequestID  string   `json:"request_id"`
}

Quick start

package iplogs

import (
    "bytes"
    "context"
    "encoding/json"
    "fmt"
    "net/http"
    "time"
)

type Client struct {
    BaseURL    string
    HTTPClient *http.Client
}

func New() *Client {
    return &Client{
        BaseURL: "https://iplogs.com",
        HTTPClient: &http.Client{
            Timeout: 8 * time.Second, // server cap is ~8s; client gives one extra
        },
    }
}

func (c *Client) Check(ctx context.Context, ip string) (*CheckResponse, error) {
    body, _ := json.Marshal(map[string]string{"ip": ip})
    ctx, cancel := context.WithTimeout(ctx, 5*time.Second)
    defer cancel()

    req, err := http.NewRequestWithContext(ctx, http.MethodPost,
        c.BaseURL+"/v1/check", bytes.NewReader(body))
    if err != nil {
        return nil, err
    }
    req.Header.Set("Content-Type", "application/json")

    resp, err := c.HTTPClient.Do(req)
    if err != nil {
        return nil, fmt.Errorf("iplogs: %w", err)
    }
    defer resp.Body.Close()
    if resp.StatusCode >= 400 {
        return nil, fmt.Errorf("iplogs: HTTP %d", resp.StatusCode)
    }
    var out CheckResponse
    if err := json.NewDecoder(resp.Body).Decode(&out); err != nil {
        return nil, fmt.Errorf("iplogs decode: %w", err)
    }
    return &out, nil
}

One *http.Client per process is the right shape. Always pass a context.Context so the call cancels if the caller goes away.

Read the visitor IP from a trusted proxy

package iplogs

import (
    "net"
    "net/http"
    "strings"
)

// trustedProxies are the immediate-peer addresses whose proxy headers
// we trust. Caddy and Nginx running on the same host are 127.0.0.1 or ::1.
var trustedProxies = map[string]struct{}{
    "127.0.0.1": {},
    "::1":       {},
}

func ClientIP(r *http.Request) string {
    peer, _, err := net.SplitHostPort(r.RemoteAddr)
    if err != nil {
        peer = r.RemoteAddr
    }
    if _, ok := trustedProxies[peer]; !ok {
        return peer // direct request: ignore any proxy headers
    }
    if cf := r.Header.Get("CF-Connecting-IP"); cf != "" {
        return strings.TrimSpace(cf)
    }
    // Caddy / Nginx append the real peer at the end. Read the LAST entry.
    if xff := r.Header.Get("X-Forwarded-For"); xff != "" {
        parts := strings.Split(xff, ",")
        return strings.TrimSpace(parts[len(parts)-1])
    }
    if xri := r.Header.Get("X-Real-IP"); xri != "" {
        return strings.TrimSpace(xri)
    }
    return peer
}

HTTP middleware

package iplogs

import (
    "context"
    "net/http"
)

type ctxKey struct{}

// Middleware attaches the IPLogs verdict to the request context. Per-handler
// code reads it with iplogs.From(ctx) and decides policy.
func (c *Client) Middleware(next http.Handler) http.Handler {
    return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
        ip := ClientIP(r)
        if ip != "" {
            if v, err := c.Check(r.Context(), ip); err == nil {
                r = r.WithContext(context.WithValue(r.Context(), ctxKey{}, v))
            }
            // Fail open: missing verdict means per-handler default policy.
        }
        next.ServeHTTP(w, r)
    })
}

func From(ctx context.Context) *CheckResponse {
    v, _ := ctx.Value(ctxKey{}).(*CheckResponse)
    return v
}

Per-IP cache

package iplogs

import (
    "context"
    "sync"
    "time"
)

type cachedVerdict struct {
    resp *CheckResponse
    at   time.Time
}

type CachedClient struct {
    *Client
    cache sync.Map // ip -> cachedVerdict
    ttl   time.Duration
}

func NewCached(c *Client, ttl time.Duration) *CachedClient {
    return &CachedClient{Client: c, ttl: ttl}
}

func (c *CachedClient) Check(ctx context.Context, ip string) (*CheckResponse, error) {
    if v, ok := c.cache.Load(ip); ok {
        cv := v.(cachedVerdict)
        if time.Since(cv.at) < c.ttl {
            return cv.resp, nil
        }
    }
    resp, err := c.Client.Check(ctx, ip)
    if err != nil {
        return nil, err
    }
    c.cache.Store(ip, cachedVerdict{resp: resp, at: time.Now()})
    return resp, nil
}

A 5-minute TTL is a good default. For a fleet, swap sync.Map for a Redis client with SET key value EX 300 so workers share the cache. The single-process variant above costs effectively nothing in memory at typical IP cardinality.

Batch scoring with /v1/bulk-check

type BulkRow struct {
    IP          string  `json:"ip"`
    Verdict     Verdict `json:"verdict,omitempty"`
    Score       float64 `json:"score"`
    IsVPN       bool    `json:"is_vpn"`
    VPNProvider string  `json:"vpn_provider,omitempty"`
    CountryCode string  `json:"country_code,omitempty"`
    ASN         string  `json:"asn,omitempty"`
    Org         string  `json:"org,omitempty"`
    Error       string  `json:"error,omitempty"`
}

type bulkResp struct {
    Total      int       `json:"total"`
    DurationMs int64     `json:"duration_ms"`
    Results    []BulkRow `json:"results"`
}

func (c *Client) BulkCheck(ctx context.Context, ips []string) ([]BulkRow, error) {
    body, _ := json.Marshal(map[string][]string{"ips": ips})
    ctx, cancel := context.WithTimeout(ctx, 95*time.Second)
    defer cancel()

    req, _ := http.NewRequestWithContext(ctx, http.MethodPost,
        c.BaseURL+"/v1/bulk-check", bytes.NewReader(body))
    req.Header.Set("Content-Type", "application/json")
    resp, err := c.HTTPClient.Do(req)
    if err != nil {
        return nil, err
    }
    defer resp.Body.Close()
    var out bulkResp
    if err := json.NewDecoder(resp.Body).Decode(&out); err != nil {
        return nil, err
    }
    return out.Results, nil
}

/v1/bulk-check accepts up to 100 IPs per request. Chunk larger lists yourself and iterate; the per-row response is intentionally compact so a 100-IP batch returns ~10 KB rather than ~30 KB.

Testing without hitting the network

package iplogs

import (
    "context"
    "net/http"
    "net/http/httptest"
    "testing"
)

func TestCheckClean(t *testing.T) {
    srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
        w.Header().Set("Content-Type", "application/json")
        w.Write([]byte(`{"verdict":"clean","score":0,"is_vpn":false,
            "confidence":0.15,"signals":[],"ip_info":{"ip":"1.1.1.1"},
            "request_id":"req_test"}`))
    }))
    defer srv.Close()

    c := &Client{BaseURL: srv.URL, HTTPClient: srv.Client()}
    out, err := c.Check(context.Background(), "1.1.1.1")
    if err != nil { t.Fatal(err) }
    if out.Verdict != VerdictClean {
        t.Errorf("got %q, want clean", out.Verdict)
    }
}

FAQ

Do I need an SDK or library to call IPLogs from Go?

No. The stdlib net/http, encoding/json, and context packages cover the integration in around 30 lines. Adding a dependency for one POST to one URL is overkill — keep the surface small and easy to audit.

Should I share an http.Client across requests?

Yes. Always reuse a single http.Client (or fasthttp client) per process. Creating a new client per request leaks connections and disables connection pooling. Set a Transport with sane MaxIdleConns and IdleConnTimeout if you expect bursts.

How should I cache verdicts in Go?

sync.Map with a goroutine that evicts entries past their TTL works for a single binary. For multi-instance services, use Redis with EX 300 keyed by the IP string. The verdict struct is small JSON; memory cost is negligible vs the round-trip latency you save.

Where should the verdict call sit in a Go HTTP server?

Behind a middleware that runs the check, stores the verdict in the request context, and lets per-handler code apply policy. Wrap chi.Router or http.Handler the same way: a middleware that decorates the request and forwards to the next handler.

How do I test the integration without hitting the network?

Stand up an httptest.Server that returns canned JSON and point your client at it via a configurable base URL. The verdict shape is stable: Verdict (one of clean / suspicious / vpn_likely / vpn_detected), Score (0–1), IsVPN, IPInfo, Signals[]. Assert on the URL, method, and body.

See the API docs for the full schema, the Python guide and Node.js guide for the equivalent integrations, and the accuracy benchmark for the published false-positive rate.