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
HttpClientfor plain Java, or Spring'sRestClient(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.
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:
@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:
@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>@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>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
HttpClientonce. Whether you use plain Java'sHttpClientor Spring'sRestClient, 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'sRestClientaccepts both viaClientHttpRequestFactory. - Return typed results, not maps.
ValidationResultandDecisionrecords are easier to refactor thanMap<String, Object>and protect downstream code from API-shape changes. - Surface
request_idto MDC. Every vatverify response includesmeta.request_id; logging it next to your own request ID makes support escalation trivial. SLF4J'sMDC.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/apiis suitable foropenapi-generator-maven-pluginif you prefer generated types. - Reactive (Mono/Flux) flows. Swap
RestClientforWebClientand the same patterns apply. The synchronous version above is enough for most service-to-service calls.