vatverify home
All guides

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.Context through every call.
  • For batch flows, golang.org/x/sync/errgroup plus 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

vatverify/client.go
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. The New constructor returns a shared client; treat it as a singleton.
  • Tune MaxIdleConns and MaxIdleConnsPerHost. The default http.Transport keeps 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/api is suitable for oapi-codegen if you prefer generated types.

Validate VAT in three lines.

Free up to 500 requests per month. No credit card.

Start free