How to verify email addresses in Ruby on Rails

In Rails, verify emails in two places: at sign-up, from a controller or model, so bad addresses never enter your database; and across an existing list, with an ActiveJob background job that cleans in bulk without blocking a request. Both call the verification API from your server with the key kept in Rails credentials — never in the browser. This guide gives you the complete service object, controller and job.

Written & reviewed by the VerifyAnyEmail team · Last reviewed July 2026

There are two moments where Rails apps verify email: at sign-up, where you check the single address a user just typed, and on an existing list, where you clean thousands of stored records in the background. Both go through one small service object that calls POST /v1/verify from your server, with the API key read from Rails.application.credentials or an environment variable. The rule that comes before all others: keep the API key server-side. A Rails server is server-side by nature — just never leak the key into a JavaScript pack, a Turbo stream, or a view that renders to the browser.

The service object

Wrap the HTTP call in a plain Ruby object so controllers, models and jobs all share one implementation. This uses the standard-library Net::HTTP — no extra gems — with an explicit read timeout, because a live SMTP probe can occasionally be slow and you don’t want it stalling a web request.

# app/services/email_verifier.rb
require "net/http"
require "json"

class EmailVerifier
  API_URL = URI("https://api.verifyany.email/v1/verify")

  Result = Struct.new(:status, :sub_status, :score, :suggestion, keyword_init: true)

  # Returns a Result, or nil when the check could not run (caller fails open).
  def self.call(email)
    http = Net::HTTP.new(API_URL.host, API_URL.port)
    http.use_ssl = true
    http.open_timeout = 5
    http.read_timeout = 10

    request = Net::HTTP::Post.new(API_URL)
    request["Content-Type"]  = "application/json"
    # Key comes from credentials / ENV — never hard-coded, never sent to a browser.
    request["Authorization"] = "Bearer #{api_key}"
    request["X-VAE-Source"]  = "rails-app"
    request.body = { email: email }.to_json

    response = http.request(request)
    return nil unless response.code.to_i == 200   # 4xx/5xx (incl. 402, 429) → fail open

    result = JSON.parse(response.body).dig("result") || {}
    Result.new(
      status:     result["status"],
      sub_status: result["subStatus"],
      score:      result["score"],
      suggestion: result["suggestion"]
    )
  rescue Net::OpenTimeout, Net::ReadTimeout, SocketError, JSON::ParserError
    nil   # timeout / network / bad payload → nil → caller fails open
  end

  def self.api_key
    Rails.application.credentials.dig(:verifyanyemail, :api_key) || ENV["VAE_API_KEY"]
  end
end

Store the key with bin/rails credentials:edit (add verifyanyemail: { api_key: vae_... }) or set VAE_API_KEY in the environment. Both keep it out of source control. The broader guidance is in how to protect your API keys.

Verify at sign-up (controller)

Call the verifier before you create the user. The four possible values of status each map to a clear action.

# app/controllers/registrations_controller.rb
class RegistrationsController < ApplicationController
  def create
    result = EmailVerifier.call(user_params[:email])

    # Fail open: if verification couldn't run (nil), don't block a real user.
    if result && result.status == "undeliverable"
      # Mailbox doesn't exist → reject; it would hard-bounce.
      flash.now[:alert] = suggestion_message(result) || "That email looks undeliverable."
      return render :new, status: :unprocessable_entity
    end

    @user = User.new(user_params)
    # "risky" is reachable but lower confidence (catch-all / role / disposable).
    @user.needs_review = result&.status == "risky"

    if @user.save
      redirect_to dashboard_path
    else
      render :new, status: :unprocessable_entity
    end
  end

  private

  def suggestion_message(result)
    "Did you mean @#{result.suggestion}?" if result.suggestion.present?
  end

  def user_params
    params.require(:user).permit(:email, :password)
  end
end
  • undeliverable — mailbox or domain can’t receive mail. Reject it; sending guarantees a hard bounce.
  • deliverable — real, reachable mailbox. Accept it.
  • risky — reachable but catch-all, role-based (info@, support@) or disposable. Accept, but flag for review.
  • unknown — inconclusive (greylisting, timeout, temporary unreachability). Fail open and accept — blocking here punishes real users for a transient condition. A nil result (the check didn’t run) is treated the same way.

Prefer to keep the check in the model? Add an ActiveModel validation that calls EmailVerifier and adds an error only on undeliverable. For the UX reasoning behind failing open and offering typo suggestions instead of hard blocks, see verify emails at signup without hurting conversion.

Clean an existing list with a background job

Never verify thousands of stored addresses inside a web request — it will time out. Use ActiveJob so the work runs on a queue (Sidekiq, GoodJob, Solid Queue, whatever you’ve configured). This job verifies one user and records the outcome; enqueue it once per record.

# app/jobs/verify_email_job.rb
class VerifyEmailJob < ApplicationJob
  queue_as :verification

  # Retry only transient failures, with backoff. Never loop on a real verdict.
  retry_on Net::ReadTimeout, Net::OpenTimeout, wait: :polynomially_longer, attempts: 4

  def perform(user_id)
    user = User.find_by(id: user_id)
    return unless user

    result = EmailVerifier.call(user.email)
    return if result.nil?   # couldn't run now; a retry or a later sweep will catch it

    user.update!(
      verification_status: result.status,
      verification_score:  result.score,
      verified_at:         Time.current
    )

    # Suppress dead addresses so you never mail them again.
    user.update!(mailable: false) if result.status == "undeliverable"
  end
end

Kick off the sweep from a rake task or console, throttling enqueues so you don’t flood the queue or hit rate limits all at once:

# Enqueue verification for every user not checked in the last 90 days
User.where("verified_at IS NULL OR verified_at < ?", 90.days.ago)
    .find_each { |u| VerifyEmailJob.perform_later(u.id) }

For very large lists it is cheaper and faster to use the API’s batch endpoint with its signed batch.completed webhook than to enqueue one job per address — see how to process large verification batches.

Test it without spending credits

Any address at @test.verifyany.email returns a deterministic result and costs 0 credits — no real mailbox is probed. Use them in request specs and job specs to cover every branch:

  • deliverable@test.verifyany.emaildeliverable
  • undeliverable@test.verifyany.emailundeliverable
  • risky@test.verifyany.emailrisky
  • unknown@test.verifyany.emailunknown

You can also check a single address in the browser with the free Email Verifier.

Errors, timeouts and retries

  • Timeouts are explicit. open_timeout (5s) and read_timeout (10s) in the service mean a slow probe never hangs a request; the rescue turns them into nil so callers fail open.
  • Fail open on timeouts, 5xx and 429 at sign-up — let the user through rather than block on your side’s hiccup.
  • Retry only transient failures in the job with retry_on and backoff; never retry a successful 200, since a real undeliverable won’t change and a retry just wastes a credit.
  • Handle 402 loudly — out-of-credits should alert you, not silently fail every verification.

Wrapping up

Rails verification comes down to one service object used in two places: a controller (or model validation) that checks the address at sign-up, and an ActiveJob that cleans your existing list in the background. Block only undeliverable, fail open on unknown and errors, keep the key in Rails credentials, retry only transient job failures, and test every path against @test.verifyany.email for free. The same server-side pattern appears in Node.js & Express and Next.js; the full reference is in the API docs.

Check your domain in seconds

Run the free diagnostics referenced in this guide — no sign-up needed.