Most "email validation in Node.js" guides stop at a regular expression. A regex can tell you that jane@acme.com looks like an email address. It can't tell you whether acme.com accepts mail, whether Jane's mailbox exists, or whether the address came from a throwaway provider that will be gone in ten minutes.
Using Better Auth for email/password signup? The separate MailRambo Better Auth plugin adds a server-side hook with free fast screening and runtime fail-open behavior. The general SDK examples below remain useful for mailbox verification and batches.
This guide builds verification in three layers:
- A cheap syntax check that runs locally and costs nothing.
- A mailbox-level check through an API that answers one question: can I send here without bouncing?
- Production handling: timeouts, rate limits, out-of-credit errors and tests that don't spend money.
All examples use Node 18+ (built-in fetch) and the MailRambo API. You can create a free key with 100 verifications a month, and free test keys that return canned answers.
Layer 1: a sane syntax check
Don't write a 400-character RFC 5322 regex. Real-world addresses are messier than the spec and stricter than most regexes. A short pattern that rejects obvious garbage is enough, because the API does the real work:
// Rejects obvious garbage only. The API is the source of truth.
const looksLikeEmail = (value) =>
typeof value === "string" &&
value.length <= 320 &&
/^[^\s@]+@[^\s@]+\.[^\s@]{2,}$/.test(value.trim());
Anything that fails here can be rejected immediately with "Please enter a valid email address". Anything that passes goes to layer 2.
Layer 2: verify the mailbox
One POST request with your key in the Authorization header:
// lib/verify-email.js
const API = "https://www.mailrambo.com/v1/verify";
export async function verifyEmail(email) {
const res = await fetch(API, {
method: "POST",
headers: {
Authorization: `Bearer ${process.env.MAILRAMBO_KEY}`,
"Content-Type": "application/json",
},
body: JSON.stringify({ email }),
signal: AbortSignal.timeout(30_000),
});
const body = await res.json();
if (!res.ok) {
const err = new Error(body.message);
err.code = body.error; // e.g. "insufficient_credits"
err.status = res.status; // 402, 429, 503...
throw err;
}
return body; // { email, deliverable, reason, credits_remaining }
}
The response is intentionally small:
{
"email": "jane@acme.com",
"deliverable": true,
"reason": "mailbox_exists",
"credits_remaining": 49
}
deliverable is true only when the mailbox is confirmed. Everything uncertain is false, and reason tells you why:
| reason | deliverable | What it means |
|---|---|---|
mailbox_exists |
true | Mailbox confirmed |
role_account |
true | Deliverable, but a team inbox like info@ |
mailbox_not_found |
false | Would hard-bounce |
disposable |
false | Temporary / throwaway provider |
catch_all |
false | Domain accepts everything; this mailbox can't be confirmed |
unverifiable |
false | Server didn't give a clear answer |
invalid_syntax |
false | Not an email address (free, no credit used) |
Other values exist (spamtrap, inbox_full, mailbox_disabled); the full list is in the docs. Because the answer is already a boolean, your code doesn't need to decide what "risky" means.
Using it in an Express signup route
import express from "express";
import { verifyEmail } from "./lib/verify-email.js";
const app = express();
app.use(express.json());
const MESSAGES = {
mailbox_not_found: "We couldn't find that mailbox. Check for typos?",
disposable: "Please use a permanent email address.",
catch_all: "We couldn't confirm that address. Try a different one?",
invalid_syntax: "Please enter a valid email address.",
};
app.post("/signup", async (req, res) => {
const { email, password } = req.body;
if (!looksLikeEmail(email)) {
return res.status(400).json({ error: MESSAGES.invalid_syntax });
}
try {
const { deliverable, reason } = await verifyEmail(email);
if (!deliverable) {
return res.status(400).json({ error: MESSAGES[reason] ?? "Please use a different email address." });
}
} catch (err) {
// Fail open: never block real users because a third party is down.
console.warn("email verification skipped:", err.code ?? err.message);
}
// ...create the account
res.status(201).json({ ok: true });
});
Tip for busy forms: add
?mode=fastfor a free, sub-second check (syntax, typos, MX, disposable, role) that never contacts the mailbox. It returnsdeliverable: falsefor bad addresses andnullotherwise, plus asuggestionfor typos likegmial.com. Run the full check afterwards, or only when the fast check passes.
Two decisions worth copying:
- Fail open on errors. If verification times out or you run out of credits, let the signup through and log it. Blocking real users is worse than letting one bad address in.
- Friendly, specific messages. "Check for typos?" recovers far more signups than a generic "invalid email".
Next.js route handler
Keep the key on the server. Never call the API from the browser, because the key would be visible to anyone.
// app/api/check-email/route.ts
import { NextResponse } from "next/server";
export async function POST(req: Request) {
const { email } = await req.json();
const res = await fetch("https://www.mailrambo.com/v1/verify", {
method: "POST",
headers: {
Authorization: `Bearer ${process.env.MAILRAMBO_KEY}`,
"Content-Type": "application/json",
},
body: JSON.stringify({ email }),
});
if (!res.ok) return NextResponse.json({ deliverable: true, skipped: true }); // fail open
const { deliverable, reason } = await res.json();
return NextResponse.json({ deliverable, reason });
}
Handling errors properly
All errors share one shape, { "error": "<code>", "message": "..." }:
| Status | error | What to do |
|---|---|---|
| 401 | invalid_api_key |
Check MAILRAMBO_KEY |
| 402 | insufficient_credits |
Fail open and alert yourself; top up |
| 429 | rate_limited |
Back off and retry (600 requests/min per key; full checks are fair-use at ~5/s) |
| 503 | provider_unavailable |
Retry once. The credit is refunded automatically |
You can check your balance from code before a big job:
const account = await fetch("https://www.mailrambo.com/v1/account", {
headers: { Authorization: `Bearer ${process.env.MAILRAMBO_KEY}` },
}).then((r) => r.json());
console.log(account.credits_remaining);
Testing without spending credits
Create a test key (mr_test_...) on the API Keys page. Test keys never contact mail servers and never use credits. The answer depends on the part before the @:
await verifyEmail("deliverable@example.com"); // { deliverable: true, reason: "mailbox_exists" }
await verifyEmail("disposable@example.com"); // { deliverable: false, reason: "disposable" }
await verifyEmail("catch_all@example.com"); // { deliverable: false, reason: "catch_all" }
Use a test key in CI and local development, and a live key (mr_live_...) in production.
Verifying a whole list
For imports and CRM clean-ups, send up to 200 addresses per request to /v1/verify/batch, then poll for results. Add an Idempotency-Key header so a retried request never charges twice:
import { randomUUID } from "node:crypto";
const start = await fetch("https://www.mailrambo.com/v1/verify/batch", {
method: "POST",
headers: {
Authorization: `Bearer ${process.env.MAILRAMBO_KEY}`,
"Content-Type": "application/json",
"Idempotency-Key": randomUUID(),
},
body: JSON.stringify({ emails }),
}).then((r) => r.json());
// then GET /v1/verify/batch/{start.batch_id} every few seconds until status === "completed"
Summary
- A regex only filters garbage; it can't tell you if a mailbox exists.
- Verify on the server, treat
deliverableas the single decision, and usereasonfor the message. - Fail open on errors, use test keys in CI, and add an
Idempotency-Keyto batches.
Want to try an address first? Use the free email verifier, or get a free API key for 100 verifications a month.