Validate VAT numbers in Go: net/http, context cancellation, and structured concurrency
Validate EU and UK VAT numbers from a Go service. Standard library net/http, context-aware cancellation, errgroup-based batch validation, and the patterns that hold up under concurrent traffic.
TL;DR
- Go's standard library is enough; no third-party HTTP client needed for vatverify integration.
- Context-aware cancellation is what makes Go validation behave well under load. Pass
context.Contextthrough every call. - For batch flows,
golang.org/x/sync/errgroupplus a bounded semaphore is the standard concurrency pattern.
Why Go for VAT validation
Go is common in compliance-adjacent infrastructure: gateways, ETL workers, monitoring services, and webhook receivers. A VAT validation client written in Go fits naturally into those settings. The standard library covers everything you need; the binary size and cold-start characteristics are favorable for serverless or edge deployments where Node.js or Ruby would be heavier.
A small client
package vatverify
import (
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"time"
)
const apiBase = "https://api.vatverify.dev"
type Client struct {
apiKey string
http *http.Client
}
func New(apiKey string) *Client {
return &Client{
apiKey: apiKey,
http: &http.Client{
Timeout: 10 * time.Second,
},
}
}
type ValidationResult struct {
Valid bool `json:"valid"`
Country *string `json:"country,omitempty"`
CompanyName *string `json:"-"`
ConsultationNumber *string `json:"consultation_number,omitempty"`
}
type apiEnvelope struct {
Data struct {
Valid bool `json:"valid"`
Country *string `json:"country,omitempty"`
Company *struct {
Name *string `json:"name,omitempty"`
} `json:"company,omitempty"`
ConsultationNumber *string `json:"consultation_number,omitempty"`
} `json:"data"`
Error *struct {
Code string `json:"code"`
Message string `json:"message"`
} `json:"error,omitempty"`
Meta struct {
RequestID string `json:"request_id"`
LatencyMS int `json:"latency_ms"`
} `json:"meta"`
}
func (c *Client) Validate(ctx context.Context, vatNumber string) (*ValidationResult, error) {
body, err := json.Marshal(map[string]string{"vat_number": vatNumber})
if err != nil {
return nil, fmt.Errorf("marshal: %w", err)
}
req, err := http.NewRequestWithContext(ctx, http.MethodPost,
apiBase+"/v1/validate", bytes.NewReader(body))
if err != nil {
return nil, fmt.Errorf("new request: %w", err)
}
req.Header.Set("Authorization", "Bearer "+c.apiKey)
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Accept", "application/json")
resp, err := c.http.Do(req)
if err != nil {
return nil, fmt.Errorf("http: %w", err)
}
defer resp.Body.Close()
respBody, err := io.ReadAll(resp.Body)
if err != nil {
return nil, fmt.Errorf("read: %w", err)
}
var env apiEnvelope
if err := json.Unmarshal(respBody, &env); err != nil {
return nil, fmt.Errorf("unmarshal: %w", err)
}
if resp.StatusCode >= 400 {
code := "unknown"
if env.Error != nil {
code = env.Error.Code
}
return nil, fmt.Errorf("vatverify %d: %s", resp.StatusCode, code)
}
result := &ValidationResult{
Valid: env.Data.Valid,
Country: env.Data.Country,
ConsultationNumber: env.Data.ConsultationNumber,
}
if env.Data.Company != nil {
result.CompanyName = env.Data.Company.Name
}
return result, nil
}The client is a thin wrapper around net/http. The apiEnvelope type matches the response shape one-to-one; mapping into a domain ValidationResult keeps caller code simple.
Context cancellation
Always pass ctx from the caller. In an HTTP handler, that ctx already carries the deadline and the cancellation propagated by the Go HTTP server when the client disconnects. Without it, a slow vatverify response will outlive a cancelled request and waste resources:
func handleCheckout(w http.ResponseWriter, r *http.Request) {
ctx, cancel := context.WithTimeout(r.Context(), 5*time.Second)
defer cancel()
result, err := client.Validate(ctx, r.URL.Query().Get("vat"))
if err != nil {
http.Error(w, "validation failed", http.StatusBadGateway)
return
}
json.NewEncoder(w).Encode(result)
}The 5-second timeout is shorter than the client's default 10 seconds; the tighter ceiling matters in checkout where waiting too long degrades the user experience.
Concurrency for batch flows
For batch validation across a list of VAT numbers, a bounded errgroup is the standard pattern:
import "golang.org/x/sync/errgroup"
func ValidateMany(ctx context.Context, c *Client, numbers []string) (map[string]*ValidationResult, error) {
const concurrency = 8
results := make(map[string]*ValidationResult, len(numbers))
var mu sync.Mutex
g, ctx := errgroup.WithContext(ctx)
sem := make(chan struct{}, concurrency)
for _, n := range numbers {
n := n
g.Go(func() error {
select {
case sem <- struct{}{}:
case <-ctx.Done():
return ctx.Err()
}
defer func() { <-sem }()
result, err := c.Validate(ctx, n)
if err != nil {
return err
}
mu.Lock()
results[n] = result
mu.Unlock()
return nil
})
}
if err := g.Wait(); err != nil {
return results, err
}
return results, nil
}The semaphore caps in-flight requests at 8. That stays well below the per-key rate limit (90 req/min on Pro, 180 on Business) and avoids hammering the upstream registries. For lists above ~50 numbers, prefer the dedicated batch endpoint; see the batch validation tutorial.
Caching with groupcache or golang-lru
For high-traffic services, an in-process LRU on top of vatverify's server-side cache is worth adding:
import lru "github.com/hashicorp/golang-lru/v2"
type CachedClient struct {
inner *Client
cache *lru.Cache[string, ValidationResult]
}
func NewCached(inner *Client, size int) *CachedClient {
cache, _ := lru.New[string, ValidationResult](size)
return &CachedClient{inner: inner, cache: cache}
}
func (c *CachedClient) Validate(ctx context.Context, n string) (*ValidationResult, error) {
if hit, ok := c.cache.Get(n); ok {
return &hit, nil
}
result, err := c.inner.Validate(ctx, n)
if err != nil {
return nil, err
}
c.cache.Add(n, *result)
return result, nil
}A single-process LRU is fine for small services. For multi-replica deployments, swap for Redis with go-redis and the same TTL pattern (24h for valid, 1h for invalid).
Production patterns
- Reuse the
*http.Client. Creating a new client per request leaks connections and defeats the keep-alive pool. TheNewconstructor returns a shared client; treat it as a singleton. - Tune
MaxIdleConnsandMaxIdleConnsPerHost. The defaulthttp.Transportkeeps 2 idle connections per host. For a service with sustained vatverify traffic, raise both:transport := &http.Transport{ MaxIdleConns: 100, MaxIdleConnsPerHost: 16, IdleConnTimeout: 90 * time.Second, } c.http = &http.Client{Transport: transport, Timeout: 10 * time.Second} - Retry only on 5xx. A 4xx response means the request is malformed; retrying does not help. A 5xx is usually a transient upstream-registry issue; one retry after 30 seconds clears most of these.
- Log
meta.request_id. Including it in your structured logs makes support escalation trivial.
What this guide does not cover
- gRPC. vatverify exposes REST only; if your service is gRPC-internal, a small REST adapter is the right wrapping layer.
- Generated SDKs. vatverify does not currently ship a generated Go SDK. The OpenAPI spec at
/docs/apiis suitable foroapi-codegenif you prefer generated types.