A custom validator on your User model. It runs on create only and adds a normal error to email.
The code
1 Keep the key on the server
MAILVET_KEY=mv_live_your_keySet it wherever your server reads its environment. Never put it in client code.
2 Write the validator
app/validators/mailvet_validator.rb
require "json"
require "net/http"
class MailvetValidator < ActiveModel::EachValidator
def validate_each(record, attribute, value)
return if value.blank?
check = lookup(value)
return if check.nil? # a signup shouldn't die because of us
hint = check["did_you_mean"]
if hint.present?
local = value.rpartition("@").first
record.errors.add(attribute, "looks mistyped. Did you mean #{local}@#{hint}?")
elsif check["verdict"] == "invalid"
record.errors.add(attribute, "can't receive mail")
elsif check["verdict"] == "risky" && check["disposable"]
record.errors.add(attribute, "is a throwaway address. Please use a permanent one")
end
end
private
def lookup(email)
uri = URI("https://mailvet.dev/v1/check")
uri.query = URI.encode_www_form(email: email)
headers = { "Authorization" => "Bearer #{ENV.fetch("MAILVET_KEY")}" }
res = Net::HTTP.start(uri.host, uri.port, use_ssl: true,
open_timeout: 2, read_timeout: 5) do |http|
http.get(uri.request_uri, headers)
end
return JSON.parse(res.body) if res.is_a?(Net::HTTPSuccess)
Rails.logger.warn("mailvet: HTTP #{res.code}")
nil
rescue StandardError => e
Rails.logger.warn("mailvet: no answer: #{e.message}")
nil
end
end3 Add it to the model
app/models/user.rb
class User < ApplicationRecord
validates :email, presence: true
validates :email, mailvet: true, on: :create
endWhat to do with the answer
did_you_mean, invalid and disposable add an error to email, so save returns false and your form shows it. Role addresses and a risky from a DNS failure add nothing.
- valid Let it through.
- risky Read
disposable. If it’s true, reject. If false, it’s a role address or DNS couldn’t be reached, so let it through. - invalid Bad format, or the domain has no mail server. Reject.
did_you_mean is empty or holds a domain, such as gmail.com for gamil.com. It doesn’t change the verdict. A known typo can come back valid because squatters give those domains a mail server, so check it first. See the docs for every field.
If MailVet is down, the signup goes through
lookup rescues timeouts, network errors, a missing key and bad JSON, and returns nil on non-2xx answers. The validator then returns without adding an error, so the record saves.
A 401 or a 429 takes the same path, so a bad key or a used-up quota means addresses pass unchecked. Watch your logs for it.
Watch out
on: :createkeeps existing users safe. If their domain gets listed later, they can still save their profile.- Rails validates on every save attempt. A signup that fails on another field and retries spends another check.
Sources
Compared with these on November 5, 2026. If they’ve changed since, trust them over this page.