---
title: "How to Validate an Email Address in Ruby"
description: "A working Ruby client for an email validation API: Net::HTTP, a two-second timeout, and branching on all four verdicts — including the typo suggestion that recovers the signup."
slug: "validate-email-in-ruby"
date: 2026-09-28
updated: 2026-09-28
last_tested: 2026-09-28
summary: "Skip the regex and the inbox-probing gems: call /v1/check with Net::HTTP, fail open on timeouts, and offer the suggested correction instead of rejecting."
cluster: "Signup flows"
intent: how-to
sources:
  - title: "Ruby Net::HTTP"
    url: "https://docs.ruby-lang.org/en/master/Net/HTTP.html"
  - title: "Ruby Digest::SHA256"
    url: "https://docs.ruby-lang.org/en/master/Digest/SHA256.html"
  - title: "Ruby ENV"
    url: "https://docs.ruby-lang.org/en/master/ENV.html"
  - title: "RFC 9457 — Problem Details for HTTP APIs"
    url: "https://www.rfc-editor.org/rfc/rfc9457.html"
  - title: "nobounce.dev OpenAPI specification"
    url: "https://nobounce.dev/openapi.json"
  - title: "nobounce.dev verdict taxonomy"
    url: "https://nobounce.dev/v1/config"
---

Ruby developers validating an email usually reach for one of two wrong answers. The first is a regex (or `URI::MailTo::EMAIL_REGEXP`), which at best approximates RFC 5322 as practised and says nothing about whether the domain accepts mail. The second is a gem that promises to confirm the mailbox exists — a claim that depends on SMTP probing, permanently out of scope here, because it needs sender-IP reputation and returns weak signals at catch-all providers.

The useful answer is an HTTP call that returns a structured verdict plus, when the domain was mistyped, the correction. This is the whole integration in Ruby, using only the standard library: `Net::HTTP`, `JSON`, and `Digest`.

## Step 1: see the response shape with no account

`POST /demo/check` resolves a frozen fixture corpus — no key, no signup, rate-limited per client:

```bash
curl -X POST https://nobounce.dev/demo/check \
  -H 'Content-Type: application/json' \
  -d '{"email":"user@gmai.com"}'
```

```json
{
  "verdict": "undeliverable",
  "reason": "typosquat_mx",
  "suggestion": "user@gmail.com",
  "confidence": 0.94,
  "checked": { "syntax": true, "mx": true, "typo": true, "disposable": true },
  "cached": false
}
```

It is fixture-only: every verdict and reason in the frozen taxonomy is reachable from it, so you can write your branching code against real shapes, but it cannot validate arbitrary real addresses. The full fixture list and the frozen enums are at `GET /v1/config`.

## Step 2: get a key into the environment

There is no free tier. Live DNS checks require a paid key, entry $1/mo for 1,000 checks. You either redeem an operator-minted access code or pay self-serve through the card or stablecoin rail — both are a single `POST /v1/keys` call. The key is shown once; put it in an environment variable (or Rails credentials) and never in a URL or a query parameter:

```bash
export NOBOUNCE_KEY="..."
```

## Step 3: the check method

Two decisions matter more than the HTTP mechanics: a short timeout, and treating an unanswered call as *no opinion* rather than a rejection.

```ruby
require "json"
require "net/http"
require "uri"

CHECK_URL = URI("https://nobounce.dev/v1/check")

def check_email(email)
  request = Net::HTTP::Post.new(CHECK_URL)
  request["Content-Type"] = "application/json"
  request["Authorization"] = "Bearer #{ENV.fetch("NOBOUNCE_KEY")}"
  request.body = JSON.generate({ email: email })

  response = Net::HTTP.start(
    CHECK_URL.host,
    CHECK_URL.port,
    use_ssl: true,
    open_timeout: 2,
    read_timeout: 2
  ) { |http| http.request(request) }

  body = JSON.parse(response.body)
  return nil if response.code.to_i >= 400 # RFC 9457 problem+json; log body["fix"]
  body
rescue Net::OpenTimeout, Net::ReadTimeout, Errno::ECONNREFUSED, SocketError, JSON::ParserError
  nil # timeout, DNS failure, transport, or malformed body — no opinion
end
```

`ENV.fetch` raises if the key is missing, which is what you want at boot rather than a silent 401 later. Every non-2xx body carries a `fix` field in plain language — missing key, exhausted quota (`429`), malformed request — so log it instead of retrying the identical call blindly. If your stack already uses Faraday, the same contract applies: `Faraday.new(request: { timeout: 2 })` with an `Authorization` header and identical verdict branching.

## Step 4: branch on all four verdicts

Four verdicts, and only one of them stops anything:

```ruby
result = check_email("diego@gmai.com")

case
when result.nil?
  accept(mx_checked: false)                              # outage ≠ bad address
when result["verdict"] == "undeliverable" && result["suggestion"]
  offer_correction(result["suggestion"], result["confidence"])
when result["verdict"] == "undeliverable"
  reject(result["reason"])
when result["verdict"] == "unknown"
  accept(mx_checked: result.dig("checked", "mx"))        # DNS did not complete
else
  accept(mx_checked: result.dig("checked", "mx"))        # deliverable, risky
end
```

| verdict | meaning | your code |
|---|---|---|
| `deliverable` | syntax and MX both passed | accept |
| `risky` | deliverable but disposable or a role account (`reason` says which) | your policy decision |
| `unknown` | the check did not complete; `checked.mx` is `false` | accept and annotate, never reject |
| `undeliverable` | will bounce; `reason` is the cause | reject, or offer `suggestion` |

Two branches deserve emphasis. A suggestion is not an error — `undeliverable` with a `suggestion` means you know which address the user meant to type, and offering it with one-click accept (plus a real "keep what I typed" escape hatch, since confidence is a probability) recovers a signup that a plain rejection loses. And `unknown` proceeds: an API timeout or an upstream DNS failure must never become a hard signup failure — the outage math behind that rule is in [Fail Open: Email Validation Must Never Block a Signup](https://nobounce.dev/blog/fail-open-email-validation-signup/).

## Rails

Call the helper from the registration controller, server-side. Put the key in `Rails.application.credentials` or an environment variable loaded by dotenv — never in a browser pack, a Stimulus controller, or a Hotwire form that posts straight to nobounce. The browser posts to your app; your app posts to `/v1/check`. A local gate with `URI::MailTo::EMAIL_REGEXP` is fine for free rejection of garbage before spending a paid check, but it is not the syntax authority: the engine applies RFC 5322 as practised on its first layer.

## Lists, and what happens to the address

For one address per signup, the method above is complete. For a file of thousands, switch to `POST /v1/check/batch` — up to 1,000 addresses per call, results keyed by `email_sha256`, so responses are safe to log — the chunking loop and hash-join are covered in [How to Batch-Validate a List of Email Addresses](https://nobounce.dev/blog/batch-validate-email-list/).

No plaintext address is stored anywhere in the service — anything persisted is SHA-256, domains in clear. If you later learn an address bounced, report it as a hash:

```ruby
require "digest"

digest = Digest::SHA256.hexdigest("user@gmail.com")
```

`POST /v1/feedback` accepts only that digest with the event; send a raw address and it returns a 400 rather than hashing it for you. `POST /v1/hash` computes the same digest server-side, in memory, if you need the canonical normalisation applied first.

Cache hits still count against the monthly quota — `cached: true` means the shared domain cache was warm, not that the check was skipped billing-wise. `GET /v1/me` reports usage and remaining quota, and why the suggestion engine runs before the MX lookup is covered in [Suggest the Correction Instead of Rejecting the Signup](https://nobounce.dev/blog/suggest-the-correction-not-rejection/).

The full ordered integration procedure, written for an agent to execute, is at [/integrate.md](https://nobounce.dev/integrate.md).
