Validate VAT numbers in C# / .NET: HttpClient, IMemoryCache, and Polly retry
Validate EU and UK VAT numbers from a .NET service. IHttpClientFactory wiring, IMemoryCache or Redis cache, Polly retry policies, and the patterns that scale to ASP.NET checkout flows.
TL;DR
- Use
IHttpClientFactoryand a typed client to call vatverify; nevernew HttpClient()directly. IMemoryCacheor Redis (viaIDistributedCache) handles the cache layer on top of vatverify's 30-day server-side cache.- Polly handles retry and circuit-breaker policies; configure it through the HTTP client factory.
Typed client with IHttpClientFactory
In .NET 8 and later, IHttpClientFactory is the right way to manage HttpClient lifetimes. Direct new HttpClient() leaks DNS state and exhausts sockets under load.
public sealed class VatverifyClient(HttpClient http)
{
public async Task<ValidationResult> ValidateAsync(
string vatNumber,
CancellationToken ct = default)
{
var response = await http.PostAsJsonAsync(
"/v1/validate",
new { vat_number = vatNumber },
ct);
if (!response.IsSuccessStatusCode)
{
var error = await response.Content.ReadFromJsonAsync<ErrorEnvelope>(ct);
throw new VatverifyException(
$"vatverify {(int)response.StatusCode}: {error?.Error.Code ?? "unknown"}");
}
var envelope = await response.Content.ReadFromJsonAsync<DataEnvelope<ValidationPayload>>(ct);
var data = envelope?.Data
?? throw new VatverifyException("vatverify returned empty data envelope");
return new ValidationResult(
Valid: data.Valid,
Country: data.Country,
CompanyName: data.Company?.Name,
ConsultationNumber: data.ConsultationNumber);
}
private sealed record DataEnvelope<T>(T Data);
private sealed record ValidationPayload(
bool Valid,
string? Country,
CompanyPayload? Company,
string? ConsultationNumber);
private sealed record CompanyPayload(string? Name);
private sealed record ErrorEnvelope(ErrorPayload Error);
private sealed record ErrorPayload(string Code, string Message);
}
public sealed record ValidationResult(
bool Valid,
string? Country,
string? CompanyName,
string? ConsultationNumber);
public sealed class VatverifyException(string message) : Exception(message);The class uses C# 12 primary constructors and records. Drop in older syntax for .NET 6/7 if your service hasn't moved yet.
Wiring the HTTP client
Register the typed client in Program.cs:
builder.Services.AddHttpClient<VatverifyClient>(client =>
{
client.BaseAddress = new Uri("https://api.vatverify.dev");
client.Timeout = TimeSpan.FromSeconds(10);
client.DefaultRequestHeaders.Authorization =
new AuthenticationHeaderValue("Bearer", builder.Configuration["Vatverify:ApiKey"]);
client.DefaultRequestHeaders.Accept.Add(
new MediaTypeWithQualityHeaderValue("application/json"));
});The factory recycles HttpClient instances every 2 minutes by default, which protects against stale DNS resolution. Inject VatverifyClient directly into controllers or services; never inject HttpClient.
Caching with IMemoryCache
For single-instance services, IMemoryCache is the simplest cache. Wrap the typed client:
public sealed class CachedVatverifyClient(VatverifyClient inner, IMemoryCache cache)
{
public Task<ValidationResult> ValidateAsync(string vatNumber, CancellationToken ct)
{
var key = $"vatverify:{vatNumber}";
return cache.GetOrCreateAsync(key, async entry =>
{
var result = await inner.ValidateAsync(vatNumber, ct);
entry.AbsoluteExpirationRelativeToNow = result.Valid
? TimeSpan.FromHours(24)
: TimeSpan.FromHours(1);
return result;
})!;
}
}For multi-instance services, swap to IDistributedCache backed by Redis using Microsoft.Extensions.Caching.StackExchangeRedis. The TTL pattern stays the same.
Retry policies with Polly
Add Microsoft.Extensions.Http.Polly for HTTP-client-level retries:
builder.Services.AddHttpClient<VatverifyClient>(/* config as above */)
.AddTransientHttpErrorPolicy(builder => builder.WaitAndRetryAsync(
retryCount: 2,
sleepDurationProvider: attempt =>
TimeSpan.FromSeconds(Math.Pow(2, attempt)) +
TimeSpan.FromMilliseconds(Random.Shared.Next(0, 1000))))
.AddTransientHttpErrorPolicy(builder => builder.CircuitBreakerAsync(
handledEventsAllowedBeforeBreaking: 5,
durationOfBreak: TimeSpan.FromSeconds(30)));AddTransientHttpErrorPolicy triggers on HTTP 5xx and on HttpRequestException, which is exactly the set we want to retry. The circuit breaker opens after five consecutive failures, preventing your service from hammering vatverify (and its upstream registries) during an outage.
For Polly v8 with the new ResiliencePipeline API, the configuration shape is similar but uses the builder pattern under AddResilienceHandler.
ASP.NET Core checkout integration
In an ASP.NET Core controller or Minimal API endpoint:
app.MapPost("/api/customers/{id:int}/vat", async (
int id,
UpdateVatRequest request,
CachedVatverifyClient vatverify,
AppDbContext db,
CancellationToken ct) =>
{
var customer = await db.Customers.FindAsync(id, ct);
if (customer is null) return Results.NotFound();
var result = await vatverify.ValidateAsync(request.VatNumber, ct);
if (!result.Valid)
{
return Results.BadRequest(new { error = "invalid_vat" });
}
var sellerCountry = builder.Configuration["Shop:HomeCountry"];
customer.VatNumber = request.VatNumber;
customer.TaxExempt = result.Country is not null && result.Country != sellerCountry;
customer.VatPayload = JsonSerializer.Serialize(result);
customer.VatCheckedAt = DateTime.UtcNow;
await db.SaveChangesAsync(ct);
return Results.Ok(customer);
});Persisting the full VatPayload plus VatCheckedAt gives you a reconstructible audit trail without a separate logging system.
Reverse-charge decision
For the full B2B decision, call /v1/decide:
public sealed record Decision(
bool ChargeVat,
string Mechanism,
string LegalBasis,
string InvoiceNote);
public async Task<Decision> DecideAsync(
string sellerVat,
string buyerVat,
CancellationToken ct)
{
var response = await http.PostAsJsonAsync(
"/v1/decide",
new { seller_vat = sellerVat, buyer_vat = buyerVat },
ct);
response.EnsureSuccessStatusCode();
var envelope = await response.Content.ReadFromJsonAsync<DataEnvelope<Decision>>(ct);
return envelope!.Data;
}/v1/decide is on the Business plan.
Production patterns
- Use
CancellationTokeneverywhere. Pass the request'sCancellationTokenfrom the controller through the cache wrapper into the typed client. ASP.NET Core cancels the token when the client disconnects; without it, a slow vatverify response keeps the worker thread busy after the user has gone. - Configure the timeout once on the typed client. Don't sprinkle
Timeoutconfiguration in multiple places. - Surface
meta.request_idviaILoggerscopes. Add it to the scope on every call so it appears on every log line emitted under the validation flow. - Don't serialize the full payload at Information level. The validation response can include the buyer's company name and address. Treat it as PII; log it only at Debug or behind a feature flag.
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 .NET SDK. The OpenAPI spec at
/docs/apiis suitable for NSwag or Kiota if you prefer generated types.