Home / Integrations / Ruby on Rails
Validate Signup Emails in Rails With a Custom Validator
Published 2026-10-18
This guide adds a custom Active Model validator that checks each signup email with the ContactLint API using Net::HTTP from the standard library. It catches typos like gmial.com, domains that cannot receive mail, and disposable addresses. If ContactLint is slow or unreachable, the validator lets the record save.
Where the check goes in Rails
Put the check in a model validator. It runs on the server every time the record is validated, so a person cannot skip it by editing the page or posting to your controller directly. Your secret key stays in the server environment.
The validator sends one request to POST https://contactlint.com/v1/email/validate and adds a normal error to record.errors. Your existing form and error display keep working. It only runs when the email changes and passes the cheaper checks first, so you do not pay for it on every save.
Set up the validator
- Get a free secret key at /signup and set it in your server environment as
CONTACTLINT_KEY. - Create
app/validators/contactlint_email_validator.rb:require "net/http" require "json" class ContactlintEmailValidator < ActiveModel::EachValidator ENDPOINT = URI("https://contactlint.com/v1/email/validate") def validate_each(record, attribute, value) return if value.blank? result = lookup(value) return if result.nil? # no answer: fail open if result["verdict"] == "invalid" record.errors.add(attribute, :contactlint_invalid, message: "cannot receive mail") elsif result["did_you_mean"].present? && !typo_confirmed?(record) # Checked before disposable: gmial.com is both. record.errors.add(attribute, :contactlint_typo, message: "looks like a typo. Did you mean #{result["did_you_mean"]}?") elsif result["disposable"] record.errors.add(attribute, :contactlint_disposable, message: "must be a permanent address") end end private def typo_confirmed?(record) record.respond_to?(:email_typo_ok) && ActiveModel::Type::Boolean.new.cast(record.email_typo_ok) end def lookup(email) http = Net::HTTP.new(ENDPOINT.host, ENDPOINT.port) http.use_ssl = true http.open_timeout = 1 http.read_timeout = 2 # 3 seconds in total request = Net::HTTP::Post.new(ENDPOINT) request["Authorization"] = "Bearer #{ENV["CONTACTLINT_KEY"]}" request["Content-Type"] = "application/json" request.body = { email: email }.to_json response = http.request(request) unless response.is_a?(Net::HTTPOK) Rails.logger.warn("ContactLint returned HTTP #{response.code}, accepting email") return nil # 401, 402, 429 or other error: fail open end JSON.parse(response.body)["result"] rescue StandardError => e Rails.logger.warn("ContactLint unreachable (#{e.class}), accepting email") nil # timeout or network error: fail open end end - Use it in your model:
class User < ApplicationRecord attr_accessor :email_typo_ok validates :email, presence: true, format: { with: URI::MailTo::EMAIL_REGEXP } validates :email, contactlint_email: true, if: -> { will_save_change_to_email? && errors[:email].empty? } end - Permit the checkbox in your controller:
params.require(:user).permit(:email, :password, :email_typo_ok).
Rails autoloads app/validators, so contactlint_email: true finds the class by name. Restart the server after adding the folder.
Handle each verdict and did_you_mean in your views
- invalid: malformed, or the domain has no MX or A record. The form shows "Email cannot receive mail".
- risky with
did_you_meanset: the form shows "Email looks like a typo. Did you mean jane@gmail.com?" - risky with reason
disposable: rejected. Remove that branch if you do not need a permanent address. - risky with reason
role_address, valid and unknown: the record saves.
Let people keep an address that only looks like a typo by showing a checkbox when that error is present:
<%= form.email_field :email, required: true %>
<% @user.errors.full_messages_for(:email).each do |msg| %>
<p class="error"><%= msg %></p>
<% end %>
<% if @user.errors.details[:email].any? { |d| d[:error] == :contactlint_typo } %>
<label>
<%= form.check_box :email_typo_ok %>
My address is correct as typed
</label>
<% end %>A checked box skips only the typo branch. A typo domain that is also on the disposable list, such as gmial.com, is still rejected as disposable. For a hint before submit, add the drop-in widget with a cl_pub_ publishable key and keep the validator as the backstop.
Test it
In bin/rails console, with CONTACTLINT_KEY set. Each call uses one check:
u = User.new(email: "jane@gmial.com") u.valid? u.errors.full_messages_for(:email) # ["Email looks like a typo. Did you mean jane@gmail.com?"] u = User.new(email: "x@mailinator.com") u.valid? u.errors.full_messages_for(:email) # ["Email must be a permanent address"]
To confirm it fails open, start the console with CONTACTLINT_KEY=wrong. ContactLint answers 401, the log shows a warning, and no ContactLint error is added. In your test suite, stub the HTTP call with the stubbing library you already use so tests spend no checks.
Limits and failing open
- ContactLint does not connect to mail servers. It cannot confirm that a mailbox exists or detect catch-all domains. It checks syntax, MX and A records, known disposable domains, role addresses and typos of major providers.
- The validator fails open on timeouts, network errors,
401(missing or invalid key),402(no free checks or credits left) and429(more than 60 requests per minute on the key). Watch for the log warnings so a bad key does not go unnoticed. - Each key gets 100 free checks per month, then credit packs from $9 for 5,000. A check that ends as
unknownbecause the DNS lookup failed is not billed. See the docs for the full response.
Questions
Why Net::HTTP instead of a gem?
Net::HTTP ships with Ruby, so there is nothing to add to your Gemfile. If you already use an HTTP gem, the request is the same: a POST with a Bearer header, a JSON body and a 3 second timeout.
Does it run on every save?
No. The if: condition runs it only when the email is about to change and the presence and format checks passed. Updating a name or password does not use a check.
Can I use it on a form object or a model without a database?
Yes. It is an ActiveModel::EachValidator, so it works on any class that includes ActiveModel::Validations. Replace will_save_change_to_email? with a condition that fits that class.
Other integrations
Get a free API key. 100 checks per month, no card required. Or try a live check first.