vatverify home
All guides

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::HTTP or the httparty gem to call vatverify from a Rails service object.
  • Cache valid responses with Rails.cache for 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::HTTP in the standard library. Zero dependencies. Verbose but reliable.
  • httparty as a small wrapper around Net::HTTP with a friendlier API.
  • faraday with 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

app/services/vatverify.rb
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
end

The 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:

config/initializers/vatverify.rb
class Vatverify
  cattr_accessor :client

  self.client = Vatverify.new(
    api_key: Rails.application.credentials.dig(:vatverify, :api_key),
  )
end

Or 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

app/services/vatverify_cached.rb
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
end

Two 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

app/controllers/api/customers_controller.rb
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
end

The 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

app/jobs/revalidate_b2b_customer_job.rb
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
end

Schedule 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 month

The fan-out worker:

class RevalidateAllB2bCustomersJob < ApplicationJob
  def perform
    Customer.where(tax_exempt: true).find_each do |c|
      RevalidateB2bCustomerJob.perform_later(c.id)
    end
  end
end

Production patterns

  • Set explicit timeouts. open_timeout: 3, read_timeout: 10 covers 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::Error raised 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's retry_on can 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 at debug or behind a flag.
  • Surface request_id to logs. Every vatverify response includes meta.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.

Validate VAT in three lines.

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

Start free