ContactLintAPIMCPPricingFree toolsCompareGet a key

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

  1. Get a free secret key at /signup and set it in your server environment as CONTACTLINT_KEY.
  2. 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
  3. 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
  4. 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

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

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.