Validate VAT numbers in Ruby on Rails: HTTP, ActiveJob, and idempotent caching
Validate EU and UK VAT numbers from a Rails application. Includes a service object, an ActiveJob worker for re-validation cadence, and a Rails.cache layer suitable for production.
TL;DR
- Use Ruby's standard
Net::HTTPor thehttpartygem to call vatverify from a Rails service object. - Cache valid responses with
Rails.cachefor 24 hours; vatverify also caches server-side for 30 days. - An ActiveJob worker can re-validate B2B customer lists on a monthly cadence and react to deregistrations.
Choosing an HTTP client
Three reasonable choices in the Ruby ecosystem:
Net::HTTPin the standard library. Zero dependencies. Verbose but reliable.httpartyas a small wrapper aroundNet::HTTPwith a friendlier API.faradaywith adapters, useful when you already use it for other APIs in the same Rails app.
The examples below use Net::HTTP because it works without any gem additions. The patterns translate directly to httparty or faraday if you prefer.
A small service object
require 'net/http'
require 'uri'
require 'json'
class Vatverify
class Error < StandardError; end
API = URI('https://api.vatverify.dev/v1/validate')
def initialize(api_key:)
@api_key = api_key
end
def validate(vat_number)
request = Net::HTTP::Post.new(API)
request['Authorization'] = "Bearer #{@api_key}"
request['Content-Type'] = 'application/json'
request['Accept'] = 'application/json'
request.body = { vat_number: vat_number }.to_json
response = Net::HTTP.start(API.host, API.port, use_ssl: true,
open_timeout: 3, read_timeout: 10) do |http|
http.request(request)
end
body = JSON.parse(response.body)
if response.code.to_i >= 400
raise Error, "vatverify returned #{response.code}: #{body.dig('error', 'code')}"
end
Result.from_payload(body['data'])
end
Result = Struct.new(:valid, :country, :company_name, :consultation_number, keyword_init: true) do
def self.from_payload(data)
new(
valid: data['valid'] == true,
country: data['country'],
company_name: data.dig('company', 'name'),
consultation_number: data['consultation_number'],
)
end
end
endThe service object holds the API key explicitly; do not pull it from ENV inside the class. Inject it from Rails.application.credentials or your DI container so the class is testable in isolation.
Wiring it up in Rails
Register a singleton in an initializer so application code can call Vatverify.client.validate(...) without re-instantiating:
class Vatverify
cattr_accessor :client
self.client = Vatverify.new(
api_key: Rails.application.credentials.dig(:vatverify, :api_key),
)
endOr use a proper DI gem like dry-system if your application already does. The point is to keep the API key out of business logic.
Caching with Rails.cache
class VatverifyCached
TTL_VALID = 24.hours
TTL_INVALID = 1.hour
def self.validate(vat_number)
Rails.cache.fetch(cache_key(vat_number), expires_in: ttl_for(vat_number)) do
result = Vatverify.client.validate(vat_number)
result.to_h
end
end
def self.cache_key(vat_number)
"vatverify:#{Digest::SHA256.hexdigest(vat_number)}"
end
def self.ttl_for(vat_number)
cached = Rails.cache.read(cache_key(vat_number))
cached&.dig(:valid) ? TTL_VALID : TTL_INVALID
end
endTwo TTLs:
- 24 hours for valid matches the typical registration-change cadence at the upstream registries.
- 1 hour for invalid lets a customer fix a typo without waiting a day.
The to_h call is intentional: serializing a Struct to a Hash before storing keeps the cache backend (Redis, Memcached, file) language-agnostic and avoids deserialization quirks.
Live validation in a controller
class Api::CustomersController < ApplicationController
def update
customer = Customer.find(params[:id])
new_vat = params[:vat_number]
if customer.vat_number != new_vat && new_vat.present?
result = VatverifyCached.validate(new_vat)
if result[:valid]
customer.update!(
vat_number: new_vat,
tax_exempt: cross_border_b2b?(result),
vat_validation_payload: result.to_json,
vat_checked_at: Time.current,
)
else
return render json: { error: 'invalid_vat' }, status: :unprocessable_entity
end
end
render json: customer
end
private
def cross_border_b2b?(result)
result[:valid] && result[:country] && result[:country] != ENV['SELLER_COUNTRY']
end
endThe vat_validation_payload column is the audit trail. Persisting the full result alongside the timestamp gives you a reconstructible record without a separate logging system.
ActiveJob worker for re-validation
class RevalidateB2bCustomerJob < ApplicationJob
queue_as :default
def perform(customer_id)
customer = Customer.find(customer_id)
return unless customer.vat_number.present?
fresh = Vatverify.client.validate(customer.vat_number)
if !fresh.valid && customer.tax_exempt
customer.update!(
tax_exempt: false,
vat_deregistered_at: Time.current,
)
Notifier.deregistration_detected(customer).deliver_later
end
rescue Vatverify::Error => e
Rails.logger.warn("vatverify revalidation failed for #{customer_id}: #{e.message}")
raise
end
endSchedule it from a cron-style worker (sidekiq-cron, solid-queue with cron support, or Rails' built-in recurring jobs in Rails 8):
# config/recurring.yml (Rails 8)
production:
revalidate_b2b_customers:
class: RevalidateAllB2bCustomersJob
schedule: every monthThe fan-out worker:
class RevalidateAllB2bCustomersJob < ApplicationJob
def perform
Customer.where(tax_exempt: true).find_each do |c|
RevalidateB2bCustomerJob.perform_later(c.id)
end
end
endProduction patterns
- Set explicit timeouts.
open_timeout: 3, read_timeout: 10covers the typical VIES round-trip plus margin. Without timeouts a slow upstream registry can hang a Rails request indefinitely. - Retry only on 5xx. A
Vatverify::Errorraised from a 5xx response is worth one retry after a short delay; a 4xx means the request is malformed and retrying will not help. ActiveJob'sretry_oncan encode this:retry_on Vatverify::Error, wait: 30.seconds, attempts: 2 - Don't log full payloads at
info. The validation response can include the buyer's company name and address. Treat it as PII; log only atdebugor behind a flag. - Surface
request_idto logs. Every vatverify response includesmeta.request_id. Logging it next to the Rails request ID makes support escalation trivial.
What this guide does not cover
- OSS and IOSS for cross-border B2C. Different scheme, different evidence requirements. See OSS and IOSS implementation guide.
- Domestic-reverse-charge sectors in the EU member states that operate one. The cross-border check above does not handle the construction-sector domestic rule.
- Stripe Tax integration. See Stripe VAT validation for the wiring between vatverify and Stripe Tax / Stripe Customer Tax IDs.