vatverify home
All guides

Validate VAT numbers in Elixir and Phoenix: Req, Cachex, and Oban for re-validation

Validate EU and UK VAT numbers from an Elixir or Phoenix application. Uses Req for HTTP, Cachex for in-process caching, and Oban for monthly customer-list re-validation jobs.

TL;DR

  • Req is the modern Elixir HTTP client; one function call covers most vatverify integration needs.
  • Cachex handles the in-process cache; for distributed caching across nodes, swap to Redix.
  • Oban is the standard background-job library; one recurring job re-validates B2B customers monthly.

Why Elixir for VAT validation

Elixir / Phoenix is a niche but growing stack for B2B SaaS. The BEAM's concurrency model handles batch validation across thousands of VAT numbers without breaking a sweat, and Phoenix LiveView makes interactive validation forms (with live error feedback) trivial to build. The patterns below assume Elixir 1.16+ and Phoenix 1.7+.

A small client module

lib/my_app/vatverify.ex
defmodule MyApp.Vatverify do
  @api_base "https://api.vatverify.dev"

  @type result :: %{
          valid: boolean(),
          country: String.t() | nil,
          company_name: String.t() | nil,
          consultation_number: String.t() | nil
        }

  @spec validate(String.t()) :: {:ok, result()} | {:error, term()}
  def validate(vat_number) when is_binary(vat_number) do
    case Req.post(
           url: "#{@api_base}/v1/validate",
           headers: [
             {"authorization", "Bearer #{api_key()}"},
             {"accept", "application/json"}
           ],
           json: %{vat_number: vat_number},
           connect_options: [timeout: 3_000],
           receive_timeout: 10_000
         ) do
      {:ok, %Req.Response{status: 200, body: %{"data" => data}}} ->
        {:ok, normalize(data)}

      {:ok, %Req.Response{status: status, body: %{"error" => err}}} ->
        {:error, {:vatverify, status, err["code"]}}

      {:error, reason} ->
        {:error, {:transport, reason}}
    end
  end

  defp normalize(data) do
    %{
      valid: data["valid"] == true,
      country: data["country"],
      company_name: data |> Map.get("company") |> get_in(["name"]),
      consultation_number: data["consultation_number"]
    }
  end

  defp api_key do
    Application.fetch_env!(:my_app, MyApp.Vatverify)[:api_key]
  end
end

Req is the recommended HTTP client for new Elixir code; it ships with retries, JSON encoding, and good defaults. The pattern above uses Req.post/1 directly; for higher reuse, build a Req.new/1 request struct in start/0 and reuse it.

Configuration

config/runtime.exs
config :my_app, MyApp.Vatverify,
  api_key: System.fetch_env!("VATVERIFY_API_KEY")

Reading the API key at runtime (runtime.exs) is the right place; it lets you switch between live and test keys per environment without recompiling.

Caching with Cachex

For in-process caching, Cachex is the standard choice. Add to mix:

{:cachex, "~> 3.6"}

Start a cache instance in your application supervisor:

lib/my_app/application.ex
def start(_type, _args) do
  children = [
    {Cachex, name: :vatverify_cache}
    # ... other children
  ]
  Supervisor.start_link(children, strategy: :one_for_one)
end

Wrap the validate function:

lib/my_app/vatverify_cached.ex
defmodule MyApp.VatverifyCached do
  alias MyApp.Vatverify

  def validate(vat_number) do
    Cachex.fetch(:vatverify_cache, vat_number, fn _key ->
      case Vatverify.validate(vat_number) do
        {:ok, result} ->
          ttl = if result.valid, do: :timer.hours(24), else: :timer.hours(1)
          {:commit, result, ttl: ttl}

        {:error, _} = err ->
          {:ignore, err}
      end
    end)
    |> case do
      {:ok, result} -> {:ok, result}
      {:commit, result} -> {:ok, result}
      {:ignore, err} -> err
    end
  end
end

Cachex.fetch/3 with the :commit / :ignore return tuple gives you per-call TTLs without splitting into separate caches. For multi-node deployments, swap Cachex for Redix and store the same shape of payload.

Phoenix controller integration

lib/my_app_web/controllers/customer_controller.ex
defmodule MyAppWeb.CustomerController do
  use MyAppWeb, :controller

  alias MyApp.{Customer, VatverifyCached}

  def update_vat(conn, %{"id" => id, "vat_number" => vat_number}) do
    customer = MyApp.Customers.get!(id)

    with {:ok, result} <- VatverifyCached.validate(vat_number),
         true <- result.valid do
      cross_border_b2b? = result.country && result.country != Application.get_env(:my_app, :seller_country)

      {:ok, customer} =
        Customer.changeset(customer, %{
          vat_number: vat_number,
          tax_exempt: cross_border_b2b?,
          vat_payload: Jason.encode!(result),
          vat_checked_at: DateTime.utc_now()
        })
        |> MyApp.Repo.update()

      conn |> json(customer)
    else
      false ->
        conn |> put_status(:unprocessable_entity) |> json(%{error: "invalid_vat"})

      {:error, reason} ->
        conn
        |> put_status(:bad_gateway)
        |> json(%{error: "vatverify_failure", reason: inspect(reason)})
    end
  end
end

The with pipeline keeps the happy path linear and surfaces the two distinct failure modes (invalid VAT vs upstream service failure) as separate else-branches.

Oban worker for re-validation

For monthly re-validation of B2B customers, Oban is the standard background-job library. Add to mix:

{:oban, "~> 2.17"}

Schedule via Oban Cron:

config/config.exs
config :my_app, Oban,
  repo: MyApp.Repo,
  queues: [default: 10, vatverify: 4],
  plugins: [
    {Oban.Plugins.Cron,
     crontab: [
       {"0 3 1 * *", MyApp.Workers.RevalidateAllB2bCustomers}
     ]}
  ]

The fan-out worker:

lib/my_app/workers/revalidate_all_b2b_customers.ex
defmodule MyApp.Workers.RevalidateAllB2bCustomers do
  use Oban.Worker, queue: :vatverify

  alias MyApp.{Customer, Repo}

  @impl true
  def perform(_job) do
    Customer
    |> where([c], c.tax_exempt == true)
    |> Repo.all()
    |> Enum.each(fn c ->
      MyApp.Workers.RevalidateB2bCustomer.new(%{customer_id: c.id})
      |> Oban.insert()
    end)

    :ok
  end
end

The per-customer worker:

lib/my_app/workers/revalidate_b2b_customer.ex
defmodule MyApp.Workers.RevalidateB2bCustomer do
  use Oban.Worker, queue: :vatverify, max_attempts: 3

  alias MyApp.{Customer, Repo, Vatverify}

  @impl true
  def perform(%Oban.Job{args: %{"customer_id" => id}}) do
    customer = Repo.get!(Customer, id)

    case Vatverify.validate(customer.vat_number) do
      {:ok, %{valid: false}} ->
        Customer.changeset(customer, %{
          tax_exempt: false,
          vat_deregistered_at: DateTime.utc_now()
        })
        |> Repo.update()

        :ok

      {:ok, _result} ->
        :ok

      {:error, reason} ->
        {:error, reason}
    end
  end
end

Returning {:error, reason} triggers Oban's retry-with-backoff. The default schedule is reasonable; customize via backoff/1 if needed.

Production patterns

  • Pin connection timeouts. The connect_options: [timeout: 3_000] plus receive_timeout: 10_000 in the Req call covers both phases. Without explicit timeouts a slow VIES round-trip can hold a Phoenix worker process indefinitely.
  • Use Telemetry for observability. Req emits Telemetry events on every request; attach a handler that logs meta.request_id from the response body alongside your own request ID for support escalation.
  • Don't log full payloads in production. The validation response can include the buyer's company name and address. Treat it as PII; log only at debug level or behind a feature flag.

What this guide does not cover

  • LiveView form integration. A Phoenix.LiveView form can validate VAT numbers on the fly with phx-change; that's a UX pattern on top of the cached client above and not vatverify-specific.
  • Distributed caching. For multi-node clusters, :pg or Redix work as drop-in replacements for the Cachex layer.

Validate VAT in three lines.

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

Start free