vatverify home
All guides

Validate VAT numbers in Java and Spring Boot: HttpClient, RestClient, and resilient retry

Validate EU and UK VAT numbers from a Spring Boot or plain Java service. Includes Java 21 HttpClient, Spring's RestClient, Resilience4j retry, and Caffeine caching patterns.

TL;DR

  • Use Java 21's built-in HttpClient for plain Java, or Spring's RestClient (Spring 6.1+) for Spring Boot integration.
  • Caffeine handles the in-process cache; Spring Cache abstracts it cleanly behind annotations.
  • Resilience4j adds circuit-breaker and retry policies for the upstream-registry transient failure cases.

Plain Java with HttpClient

The Java 21 standard library is enough for a clean vatverify client. No third-party HTTP library required.

src/main/java/com/example/vatverify/Vatverify.java
package com.example.vatverify;

import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.time.Duration;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;

public final class Vatverify {

    private static final String API_URL = "https://api.vatverify.dev/v1/validate";

    private final HttpClient http;
    private final ObjectMapper mapper;
    private final String apiKey;

    public Vatverify(String apiKey) {
        this.apiKey = apiKey;
        this.http = HttpClient.newBuilder()
                .connectTimeout(Duration.ofSeconds(3))
                .build();
        this.mapper = new ObjectMapper();
    }

    public ValidationResult validate(String vatNumber) throws Exception {
        var body = mapper.writeValueAsString(java.util.Map.of("vat_number", vatNumber));
        var request = HttpRequest.newBuilder()
                .uri(URI.create(API_URL))
                .timeout(Duration.ofSeconds(10))
                .header("Authorization", "Bearer " + apiKey)
                .header("Content-Type", "application/json")
                .header("Accept", "application/json")
                .POST(HttpRequest.BodyPublishers.ofString(body))
                .build();

        var response = http.send(request, HttpResponse.BodyHandlers.ofString());
        var json = mapper.readTree(response.body());

        if (response.statusCode() >= 400) {
            var code = json.path("error").path("code").asText("unknown");
            throw new VatverifyException("vatverify " + response.statusCode() + ": " + code);
        }

        return ValidationResult.fromJson(json.path("data"));
    }

    public record ValidationResult(
            boolean valid,
            String country,
            String companyName,
            String consultationNumber
    ) {
        static ValidationResult fromJson(JsonNode data) {
            return new ValidationResult(
                    data.path("valid").asBoolean(false),
                    data.path("country").asText(null),
                    data.path("company").path("name").asText(null),
                    data.path("consultation_number").asText(null)
            );
        }
    }

    public static final class VatverifyException extends RuntimeException {
        public VatverifyException(String message) { super(message); }
    }
}

Records and var keep the boilerplate minimal. The class has no Spring dependency; it works in any Java 21+ project.

Spring Boot integration with RestClient

For Spring Boot 3.2+ with Spring 6.1+, the new RestClient is the modern idiom. Configuration:

VatverifyConfig.java
@Configuration
public class VatverifyConfig {

    @Bean
    public RestClient vatverifyClient(@Value("${vatverify.api-key}") String apiKey) {
        return RestClient.builder()
                .baseUrl("https://api.vatverify.dev")
                .defaultHeader("Authorization", "Bearer " + apiKey)
                .defaultHeader("Accept", "application/json")
                .build();
    }
}

The service:

VatverifyService.java
@Service
public class VatverifyService {

    private final RestClient http;

    public VatverifyService(RestClient vatverifyClient) {
        this.http = vatverifyClient;
    }

    public ValidationResult validate(String vatNumber) {
        return http.post()
                .uri("/v1/validate")
                .contentType(MediaType.APPLICATION_JSON)
                .body(Map.of("vat_number", vatNumber))
                .retrieve()
                .onStatus(HttpStatusCode::is4xxClientError, (req, resp) -> {
                    throw new IllegalArgumentException("vatverify rejected: " + resp.getStatusCode());
                })
                .body(Envelope.class)
                .data();
    }

    record Envelope(ValidationResult data) {}
    public record ValidationResult(
            boolean valid,
            String country,
            Company company,
            String consultationNumber
    ) {}
    public record Company(String name) {}
}

RestClient is synchronous; for async use cases, swap for WebClient from spring-webflux.

Caching with Caffeine

For an in-process cache, Caffeine is the standard choice in Spring Boot:

<dependency>
    <groupId>com.github.ben-manes.caffeine</groupId>
    <artifactId>caffeine</artifactId>
</dependency>
CacheConfig.java
@Configuration
@EnableCaching
public class CacheConfig {

    @Bean
    public CacheManager cacheManager() {
        var cacheManager = new CaffeineCacheManager("vatverify");
        cacheManager.setCaffeine(Caffeine.newBuilder()
                .expireAfterWrite(Duration.ofHours(24))
                .maximumSize(50_000));
        return cacheManager;
    }
}

Annotate the service method:

@Cacheable(value = "vatverify", key = "#vatNumber")
public ValidationResult validate(String vatNumber) {
    // ... actual call as above
}

The cache returns the same ValidationResult for identical input within the TTL. For invalid responses, a 1-hour TTL makes more sense than 24 hours; a @CacheEvict after invalid lookups handles that without splitting the cache.

Resilient retry with Resilience4j

Upstream registry transient failures (MS_UNAVAILABLE from VIES, intermittent HMRC outages) are the right place for retry logic. Resilience4j has a clean Spring Boot integration:

<dependency>
    <groupId>io.github.resilience4j</groupId>
    <artifactId>resilience4j-spring-boot3</artifactId>
</dependency>
application.yml
resilience4j:
  retry:
    instances:
      vatverify:
        max-attempts: 3
        wait-duration: 5s
        retry-exceptions:
          - org.springframework.web.client.HttpServerErrorException
          - java.net.ConnectException
          - java.net.SocketTimeoutException
        ignore-exceptions:
          - org.springframework.web.client.HttpClientErrorException
@Retry(name = "vatverify")
@Cacheable(value = "vatverify", key = "#vatNumber")
public ValidationResult validate(String vatNumber) { /* ... */ }

The configuration retries on 5xx and connection-level failures, and explicitly ignores 4xx (request was malformed; retrying will not help).

Reverse-charge decision

For the full B2B reverse-charge decision, call /v1/decide:

public record Decision(
        boolean chargeVat,
        String mechanism,
        String legalBasis,
        String invoiceNote
) {}

public Decision decide(String sellerVat, String buyerVat) {
    return http.post()
            .uri("/v1/decide")
            .contentType(MediaType.APPLICATION_JSON)
            .body(Map.of("seller_vat", sellerVat, "buyer_vat", buyerVat))
            .retrieve()
            .body(new ParameterizedTypeReference<Map<String, Decision>>() {})
            .get("data");
}

/v1/decide is on the Business plan; for stores with simpler needs the validate-then-branch pattern works on every plan.

Production patterns

  • Configure the HttpClient once. Whether you use plain Java's HttpClient or Spring's RestClient, instantiate it as a singleton bean. Re-creating per request defeats the connection pool.
  • Set both connect and read timeouts. Plain HttpClient.newBuilder().connectTimeout(...) covers the connect side; the read timeout goes on the request via .timeout(...). Spring's RestClient accepts both via ClientHttpRequestFactory.
  • Return typed results, not maps. ValidationResult and Decision records are easier to refactor than Map<String, Object> and protect downstream code from API-shape changes.
  • Surface request_id to MDC. Every vatverify response includes meta.request_id; logging it next to your own request ID makes support escalation trivial. SLF4J's MDC.put("vatverify_request_id", id) is the typical place.

What this guide does not cover

  • Generated SDKs. vatverify does not currently ship a generated Java SDK; the OpenAPI spec at /docs/api is suitable for openapi-generator-maven-plugin if you prefer generated types.
  • Reactive (Mono/Flux) flows. Swap RestClient for WebClient and the same patterns apply. The synchronous version above is enough for most service-to-service calls.

Validate VAT in three lines.

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

Start free