How to Verify Email Addresses in Ruby with CheckMail

Updated · April 2026

This guide walks you through verifying email addresses in Ruby with the CheckMail API. You'll see single and batch verification, error handling, a reusable retry helper, and a complete working script. No gems required - everything runs on Ruby's standard library net/http and json.

Rails devs often reach for validates :email, format: {...}, but regex only catches syntax errors. Real verification - "does this mailbox actually exist?" - requires DNS lookups, SMTP handshakes, catch-all probes, and a live disposable-domain list. The API handles all of that in one call.

§01Prerequisites

You need Ruby 3.0 or newer (earlier versions work, but 3+ gets you modern syntax and keyword arg semantics). Sign up for CheckMail and you'll get 100 free credits instantly. Keys look like cm_live_... for production and cm_test_... for sandboxing.

ruby --version  # 3.0+
export CHECKMAIL_KEY=cm_live_xxxxxxxxxxxxxxxx

Read the key from ENV['CHECKMAIL_KEY']. In Rails, use Rails.application.credentials or dotenv-rails. Never commit keys to git.

§02Your first verification

A single verification is a GET to /v1/verify with the email as a query parameter and a bearer token in the Authorization header:

# verify.rb
require 'net/http'
require 'json'
require 'uri'

key = ENV.fetch('CHECKMAIL_KEY')
email = 'john@example.com'

uri = URI('https://api.checkmail.dev/v1/verify')
uri.query = URI.encode_www_form(email: email)

req = Net::HTTP::Get.new(uri)
req['Authorization'] = "Bearer #{key}"

res = Net::HTTP.start(uri.hostname, uri.port, use_ssl: true, read_timeout: 15) do |http|
  http.request(req)
end

raise "CheckMail returned #{res.code}: #{res.body}" unless res.is_a?(Net::HTTPSuccess)

data = JSON.parse(res.body)
pp data

Run ruby verify.rb. You'll get back a hash describing the address. One credit is deducted per definitive result; unknown verdicts are free. Always pass a read_timeout - Net::HTTP will hang indefinitely otherwise.

§03Understanding the response

Here's the full JSON shape of a successful response:

{
  "email": "john@example.com",
  "status": "valid",
  "checks": {
    "syntax": true,
    "mx_found": true,
    "smtp_valid": true,
    "catch_all": false,
    "disposable": false,
    "role_based": false,
    "free_provider": false
  },
  "suggestion": null,
  "mx_host": "aspmx.l.google.com",
  "cached": false,
  "ms": 1243
}

The status field is what you'll branch on most of the time:

The suggestion field catches typos - enter john@gnail.con and you'll get "john@gmail.com" back. The full schema is in the docs.

§04Batch verification

For verifying lists - newsletter imports, CRM cleanup, Sidekiq cron jobs - use the batch endpoint. Up to 500 addresses in one request:

def verify_batch(emails, key)
  uri = URI('https://api.checkmail.dev/v1/verify/batch')

  req = Net::HTTP::Post.new(uri)
  req['Authorization'] = "Bearer #{key}"
  req['Content-Type'] = 'application/json'
  req.body = { emails: emails }.to_json

  res = Net::HTTP.start(uri.hostname, uri.port, use_ssl: true, read_timeout: 60) do |http|
    http.request(req)
  end

  raise "Batch failed: #{res.code} #{res.body}" unless res.is_a?(Net::HTTPSuccess)

  JSON.parse(res.body)
end

payload = verify_batch(
  ['alice@acme.com', 'bob@bouncy.test', 'carol@example.org'],
  ENV.fetch('CHECKMAIL_KEY')
)

payload['results'].each do |r|
  puts "#{r['email']} → #{r['status']}"
end
puts "Charged: #{payload['charged']} credits"

Each result matches the single-verify schema. The charged field reports the real deduction - unknown results are free, so a batch of 500 can legitimately bill at 487. Submitting more than 500 returns 400 batch_too_large, so chunk your list with each_slice(500) in the caller. Raise the read timeout for batches since they take longer than a single request.

§05Error handling and retries

Client errors (4xx) are permanent - bad key, malformed request, quota exhausted - and retrying won't help. Server errors (5xx) and rate limits (429) are transient. Retry with exponential backoff:

def verify_with_retry(email, key, max_attempts: 4)
  uri = URI('https://api.checkmail.dev/v1/verify')
  uri.query = URI.encode_www_form(email: email)

  (1..max_attempts).each do |attempt|
    req = Net::HTTP::Get.new(uri)
    req['Authorization'] = "Bearer #{key}"

    res = Net::HTTP.start(uri.hostname, uri.port, use_ssl: true, read_timeout: 15) do |http|
      http.request(req)
    end

    return JSON.parse(res.body) if res.is_a?(Net::HTTPSuccess)

    # Permanent failure - don't retry
    code = res.code.to_i
    if code.between?(400, 499) && code != 429
      raise "Client error #{code}: #{res.body}"
    end

    # Transient - back off and retry
    delay = [2**(attempt - 1), 10].min
    sleep delay
  end

  raise "Gave up after #{max_attempts} attempts"
end

The backoff is 1s, 2s, 4s, 8s - capped at 10. Don't do this inline inside a web request; push it into a Sidekiq job, ActiveJob, or rails runner task. Sleep inside a controller action is a straight path to request timeouts.

§06Best practices

§07Complete working example

A drop-in class that handles batching, retries, and summarizing results. Save as check_mail_client.rb:

# check_mail_client.rb
require 'net/http'
require 'json'
require 'uri'

class CheckMailClient
  BASE = URI('https://api.checkmail.dev/v1/').freeze
  BATCH_SIZE = 500

  def initialize(key)
    @key = key
  end

  def verify(email)
    uri = BASE + 'verify'
    uri.query = URI.encode_www_form(email: email)
    request(Net::HTTP::Get.new(uri))
  end

  def verify_many(emails)
    results = []
    charged = 0

    emails.each_slice(BATCH_SIZE) do |chunk|
      req = Net::HTTP::Post.new(BASE + 'verify/batch')
      req['Content-Type'] = 'application/json'
      req.body = { emails: chunk }.to_json

      page = request(req)
      results.concat(page['results'])
      charged += page['charged']
      puts "Processed #{results.length} / #{emails.length}"
    end

    { 'results' => results, 'charged' => charged }
  end

  private

  def request(req, attempt: 1)
    req['Authorization'] = "Bearer #{@key}"

    res = Net::HTTP.start(BASE.hostname, BASE.port, use_ssl: true, read_timeout: 60) do |http|
      http.request(req)
    end

    return JSON.parse(res.body) if res.is_a?(Net::HTTPSuccess)

    code = res.code.to_i
    if (code == 429 || code >= 500) && attempt < 4
      sleep [2**(attempt - 1), 10].min
      return request(req, attempt: attempt + 1)
    end

    raise "CheckMail error #{code}: #{res.body}"
  end
end

# Usage
client = CheckMailClient.new(ENV.fetch('CHECKMAIL_KEY'))
payload = client.verify_many([
  'alice@acme.com',
  'bob@example.org',
  'typo@gnail.con',
  'throwaway@mailinator.com',
])

summary = payload['results'].group_by { |r| r['status'] }.transform_values(&:count)

payload['results'].each do |r|
  puts "#{r['email']} → did you mean #{r['suggestion']}?" if r['suggestion']
end

pp summary
puts "Credits charged: #{payload['charged']}"

That's a production-ready verification client. Drop it into a Rails app as app/services/check_mail_client.rb, call it from a Sidekiq worker, and you're done.

§08Next steps

Grab an API key and start with 100 free credits. $2 per 1,000 after that. Credits never expire. For the full API reference - domain intelligence, account endpoints, auto-topup - see the docs.

Questions? mail@checkmail.dev