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
Reqis the modern Elixir HTTP client; one function call covers most vatverify integration needs.Cachexhandles the in-process cache; for distributed caching across nodes, swap toRedix.Obanis 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
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
endReq 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 :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:
def start(_type, _args) do
children = [
{Cachex, name: :vatverify_cache}
# ... other children
]
Supervisor.start_link(children, strategy: :one_for_one)
endWrap the validate function:
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
endCachex.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
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
endThe 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 :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:
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
endThe per-customer worker:
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
endReturning {: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]plusreceive_timeout: 10_000in 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_idfrom 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.LiveViewform can validate VAT numbers on the fly withphx-change; that's a UX pattern on top of the cached client above and not vatverify-specific. - Distributed caching. For multi-node clusters,
:pgorRedixwork as drop-in replacements for the Cachex layer.