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
endStore 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
nilresult (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
endKick 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.email→deliverableundeliverable@test.verifyany.email→undeliverablerisky@test.verifyany.email→riskyunknown@test.verifyany.email→unknown
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) andread_timeout(10s) in the service mean a slow probe never hangs a request; the rescue turns them intonilso callers fail open. - Fail open on timeouts,
5xxand429at sign-up — let the user through rather than block on your side’s hiccup. - Retry only transient failures in the job with
retry_onand backoff; never retry a successful200, since a realundeliverablewon’t change and a retry just wastes a credit. - Handle
402loudly — 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.