A middleware you put in front of the signup route. Bad addresses get a 422 and your handler never runs.
The code
1 Keep the key on the server
MAILVET_KEY=mv_live_your_keySet it wherever your server reads its environment. Never put it in client code.
2 Write the middleware
mailvet.js
// Needs Node 18+ for fetch. Your route must parse the body first.
export async function vetEmail(req, res, next) {
const email = String(req.body?.email ?? "").trim();
if (!email) return next();
let check;
try {
const answer = await fetch(
"https://mailvet.dev/v1/check?email=" + encodeURIComponent(email),
{
headers: { Authorization: `Bearer ${process.env.MAILVET_KEY}` },
signal: AbortSignal.timeout(5000),
},
);
if (!answer.ok) throw new Error(`HTTP ${answer.status}`);
check = await answer.json();
} catch (err) {
console.error("mailvet: no answer:", err.message);
return next(); // a signup shouldn't die because of us
}
if (check.did_you_mean) {
const local = email.slice(0, email.lastIndexOf("@"));
return res.status(422).json({
error: `Did you mean ${local}@${check.did_you_mean}?`,
did_you_mean: check.did_you_mean,
});
}
if (check.verdict === "invalid") {
return res.status(422).json({ error: "That address can't receive mail." });
}
if (check.verdict === "risky" && check.disposable) {
return res.status(422).json({ error: "Please use a permanent address." });
}
next();
}3 Put it on the route
app.js
import express from "express";
import { vetEmail } from "./mailvet.js";
const app = express();
app.post("/signup", express.json(), vetEmail, (req, res) => {
// Create the user here.
res.status(201).json({ ok: true });
});
app.listen(process.env.PORT ?? 3000);What to do with the answer
did_you_mean, invalid and disposable end the request with a 422 and a message your form can show. Everything else reaches your handler, including role addresses and a risky from a DNS failure.
- valid Let it through.
- risky Read
disposable. If it’s true, reject. If false, it’s a role address or DNS couldn’t be reached, so let it through. - invalid Bad format, or the domain has no mail server. Reject.
did_you_mean is empty or holds a domain, such as gmail.com for gamil.com. It doesn’t change the verdict. A known typo can come back valid because squatters give those domains a mail server, so check it first. See the docs for every field.
If MailVet is down, the signup goes through
The try covers timeouts, network errors, non-200 answers and bad JSON. All of them log and call next(), so your handler runs as if the check had passed.
A 401 or a 429 takes the same path, so a bad key or a used-up quota means addresses pass unchecked. Watch your logs for it.
Watch out
- Both files use ES modules, so set
"type": "module"in package.json. - Handling a form post instead of JSON? Swap
express.json()forexpress.urlencoded({ extended: false }).
Sources
Compared with these on November 5, 2026. If they’ve changed since, trust them over this page.