Email Verification in Elixir: Concurrent Validation with OTP and GenServer

Key Takeaways
  • Elixir's BEAM virtual machine and OTP framework provide lightweight processes ideal for high-concurrency email verification workloads.
  • The EmailVerifierAPI v2 endpoint returns structured JSON that maps cleanly to Elixir maps and can be pattern-matched for response routing.
  • Task.async_stream provides a simple, bounded-concurrency batch processing pattern that handles thousands of verifications without overwhelming system resources.
  • Production deployments should use a supervision tree to manage verification workers, ensuring automatic recovery from transient API failures.

Why Elixir Fits Email Verification

Elixir runs on the BEAM virtual machine, the same runtime that powers Erlang's telecom-grade systems. The BEAM's lightweight process model (processes cost roughly 2KB of memory each) makes it possible to spawn thousands of concurrent verification requests without the overhead of OS threads or the complexity of callback-based async patterns. For email verification at scale, this is a significant advantage. Each verification request can run in its own process, supervised by OTP, with automatic restart on failure and built-in timeout handling.

This guide walks through integrating the EmailVerifierAPI v2 endpoint into an Elixir application, from a basic single-request function up to a supervised batch processing pipeline.

Prerequisites

You will need an Elixir project with HTTPoison (or any HTTP client) and Jason for JSON parsing. Add these to your mix.exs dependencies:

defp deps do
  [
    {:httpoison, "~> 2.0"},
    {:jason, "~> 1.4"}
  ]
end

Testing with cURL

Before writing Elixir code, confirm your API key works with a direct request:

curl -X GET "https://www.emailverifierapi.com/v2/verify?email=test@example.com&apikey=YOUR_API_KEY"

The response returns a JSON object with the verification result:

{
  "status": "passed",
  "isDisposable": false,
  "isFreeService": true,
  "isOffensive": false,
  "isRoleAccount": false,
  "isGibberish": false,
  "smtp_check": "success",
  "sub_status": "mailboxExists"
}

The Core Verification Module

Elixir's pattern matching makes it natural to route API responses into clear success and error paths. Here is a module that wraps the EmailVerifierAPI endpoint with proper error handling:

defmodule EmailVerifier do
  @base_url "https://www.emailverifierapi.com/v2/verify"
  @api_key System.get_env("EMAIL_VERIFIER_API_KEY")

  def verify(email) do
    url = "#{@base_url}?email=#{URI.encode(email)}&apikey=#{@api_key}"

    case HTTPoison.get(url, [], recv_timeout: 15_000) do
      {:ok, %HTTPoison.Response{status_code: 200, body: body}} ->
        parse_response(email, body)

      {:ok, %HTTPoison.Response{status_code: code}} ->
        {:error, email, "HTTP #{code}"}

      {:error, %HTTPoison.Error{reason: reason}} ->
        {:error, email, reason}
    end
  end

  defp parse_response(email, body) do
    case Jason.decode(body) do
      {:ok, %{"status" => status} = result} ->
        {:ok, email, %{
          status: status,
          disposable: result["isDisposable"],
          free: result["isFreeService"],
          role_account: result["isRoleAccount"],
          gibberish: result["isGibberish"],
          smtp_check: result["smtp_check"],
          sub_status: result["sub_status"]
        }}

      {:error, _} ->
        {:error, email, :json_parse_failed}
    end
  end
end

Batch Processing with Task.async_stream

For processing lists of emails with bounded concurrency, Elixir's Task.async_stream provides an elegant solution. It processes items concurrently up to a configurable limit and returns results in order:

defmodule EmailVerifier.Batch do
  @max_concurrency 20
  @timeout 30_000

  def verify_list(emails) when is_list(emails) do
    emails
    |> Task.async_stream(
      &EmailVerifier.verify/1,
      max_concurrency: @max_concurrency,
      timeout: @timeout,
      on_timeout: :kill_task
    )
    |> Enum.reduce(%{passed: [], failed: [], unknown: []}, fn
      {:ok, {:ok, email, %{status: "passed"} = data}}, acc ->
        %{acc | passed: [{email, data} | acc.passed]}

      {:ok, {:ok, email, %{status: "failed"} = data}}, acc ->
        %{acc | failed: [{email, data} | acc.failed]}

      {:ok, {:ok, email, data}}, acc ->
        %{acc | unknown: [{email, data} | acc.unknown]}

      {:ok, {:error, email, reason}}, acc ->
        %{acc | unknown: [{email, %{error: reason}} | acc.unknown]}

      {:exit, _reason}, acc ->
        acc
    end)
  end
end

# Usage:
# results = EmailVerifier.Batch.verify_list([
#   "user@example.com",
#   "fake@tempmail.org",
#   "info@company.com"
# ])
# IO.inspect(results.passed)
# IO.inspect(results.failed)

Understanding the Response Fields

The API's primary "status" field drives your routing logic. A "passed" status confirms the mailbox exists and can receive mail. "Failed" means the address is definitively undeliverable. "Unknown" indicates the check was inconclusive, often due to a catch-all domain or a server that did not respond to the probe. "Transient" signals a temporary server error, and the address should be re-checked later.

The boolean flags add depth to each result. The "isDisposable" flag identifies throwaway email services. "isRoleAccount" flags generic addresses like info@, sales@, or support@ that represent departments rather than individuals. "isGibberish" catches randomly generated strings that pass syntax validation but clearly do not belong to a real person. Use these flags to build layered acceptance policies: an address might be valid (status "passed") but still risky (disposable or role-based).

The "sub_status" field provides the most specific information. "mailboxExists" and "mailboxDoesNotExist" are definitive. "isCatchall" tells you the domain accepts all addresses, making mailbox-level confirmation impossible. "isGreylisting" means the server temporarily rejected the probe, and a retry after a short delay is appropriate.

Production Supervision

For production systems, wrap verification workers inside an OTP supervision tree. This ensures that if a worker process crashes due to a network timeout or unexpected response, the supervisor automatically restarts it without affecting other in-flight verifications. Elixir's "let it crash" philosophy aligns perfectly with the reality of network-dependent workloads where transient failures are inevitable.

Set your max_concurrency based on your API plan's rate limits. Start with 20 concurrent tasks and adjust based on observed throughput and error rates. The Task.async_stream pattern handles backpressure naturally: if downstream processing is slow, it will not spawn additional tasks beyond the concurrency limit.

Frequently Asked Questions

Why use Elixir for email verification instead of Python or Node.js?

Elixir's BEAM virtual machine handles massive concurrency natively with lightweight processes (approximately 2KB each). Where Python requires asyncio or threading workarounds and Node.js relies on a single event loop, Elixir can spawn thousands of concurrent verification processes with built-in fault tolerance via OTP supervision. This makes it ideal for high-throughput verification pipelines.

How do I handle API rate limits in Elixir?

Set the max_concurrency parameter in Task.async_stream to match your API plan's rate allowance. For more sophisticated rate limiting, consider using a GenServer-based token bucket that controls how many requests are dispatched per second. Elixir's process model makes implementing these patterns straightforward compared to callback-heavy alternatives.

What happens if a verification request times out?

The Task.async_stream configuration shown above uses on_timeout: :kill_task, which terminates any task that exceeds the timeout threshold. Timed-out emails end up in the "unknown" bucket and should be re-verified in a subsequent batch. Under OTP supervision, the killed task does not affect other running verifications.