How to Verify Email Addresses in Ruby with CheckMail
Updated · April 2026This 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:
valid- mailbox exists and will accept mail. Safe to send.invalid- bad syntax, no MX records, or the mail server rejected the address.catch_all- the domain accepts every address, so we can't tell for sure. Lower confidence.disposable- a throwaway service like Mailinator. Reject at signup.unknown- temporary failure or greylisting. Retry later. Not charged.
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
- Verify at signup. Check the address on form submission, server-side, before you persist the user. Catch bad data at the front door.
- Verify from a job, not a controller. Enqueue a Sidekiq / ActiveJob worker to call the API. That way a slow verification doesn't hold up the HTTP response, and you can retry transient failures with job-level backoff.
- Cache results. Store the verdict on the
UserorContactmodel. Don't re-verify the same address twice in one day - we cache internally and markcached: true, but your own DB is faster and free. - Keep the key server-side. Rails credentials, env vars, or a secret manager. Never check it into git.
- Handle
unknowngracefully. It's not a failure - it's "ask again later." Reschedule the job for 10 minutes out. - Use
suggestion. Show the user "Did you mean john@gmail.com?" when it's set. It catches a ton of typos. - Reuse connections. In a script that verifies many addresses, open one
Net::HTTPsession and send all requests over it rather than reconnecting each time.
§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