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:
curl -X POST https://nobounce.dev/demo/check \
-H 'Content-Type: application/json' \
-d '{"email":"user@gmai.com"}'
{
"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:
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.
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:
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.
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.
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:
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.
The full ordered integration procedure, written for an agent to execute, is at /integrate.md.