Home / Integrations / Express
Validate Emails in Express with a ContactLint Middleware
Published 2026-10-18
This guide adds a reusable Express middleware that checks the email in the request body with ContactLint before your signup handler runs. It returns a typo suggestion or an error as JSON, and it calls next() whenever ContactLint does not answer within 3 seconds.
Where the check goes in Express
Browser checks are easy to skip, so reject bad addresses on the server, right before you save them. In Express that means a middleware placed in front of the routes that accept an email: signup, email change, invites. Your handler only runs when the address passed the check, or when ContactLint did not answer.
The secret key stays on the server in the CONTACTLINT_KEY environment variable. Never send it to browser code. Get a free key at /signup.
Setup: the middleware and a signup route
This uses the fetch and AbortSignal.timeout built into current Node versions, so there is nothing extra to install besides Express. Save it as contactlint.js:
const CONTACTLINT_URL = 'https://contactlint.com/v1/email/validate';
async function lookup(email) {
try {
const res = await fetch(CONTACTLINT_URL, {
method: 'POST',
headers: {
Authorization: 'Bearer ' + process.env.CONTACTLINT_KEY,
'content-type': 'application/json',
},
body: JSON.stringify({ email }),
signal: AbortSignal.timeout(3000),
});
if (!res.ok) return null; // 401, 402, 429 or other error: fail open
const data = await res.json();
return data.result || null;
} catch {
return null; // timeout or network error: fail open
}
}
function validateEmail({ field = 'email' } = {}) {
return async function contactlintCheck(req, res, next) {
const raw = req.body?.[field];
const email = typeof raw === 'string' ? raw.trim() : '';
if (!email) return res.status(400).json({ field, error: 'Email is required.' });
const result = await lookup(email);
if (result) {
if (result.verdict === 'invalid') {
return res.status(422).json({ field, error: 'That email address cannot receive mail.' });
}
// Check did_you_mean before disposable: gmial.com is a typo and on the disposable list.
if (result.did_you_mean && req.body.confirmed_email !== email) {
return res.status(422).json({ field, suggestion: result.did_you_mean });
}
if (result.disposable) {
return res.status(422).json({ field, error: 'Please use a permanent email address.' });
}
}
req.body[field] = email;
req.emailCheck = result; // null when ContactLint did not answer
next();
};
}
module.exports = { validateEmail };Use it in app.js:
const express = require('express');
const { validateEmail } = require('./contactlint');
const app = express();
app.use(express.json());
app.use(express.urlencoded({ extended: false }));
app.post('/signup', validateEmail(), (req, res) => {
// Save the user here with req.body.email.
res.status(201).json({ ok: true });
});
app.listen(process.env.PORT || 3000);Start it with CONTACTLINT_KEY=your_key node app.js. The middleware never throws, so it works the same whether or not your Express version forwards async errors.
Handling each verdict and did_you_mean in the form
The middleware maps each verdict to a response:
- invalid (malformed, or no MX or A record): 422 with
error. - risky, possible_typo: 422 with
suggestion, unlessconfirmed_emailmatches the address. - risky, disposable: 422 with
error. Remove that branch if you accept temporary addresses. - risky, role_address, valid, unknown: passed to your handler.
On the page, read the 422 body. If it has suggestion, show "Did you mean ...?" with two buttons: one puts the suggestion in the field, the other resubmits with confirmed_email set to the typed address.
async function submitSignup(email, confirmedEmail) {
const res = await fetch('/signup', {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ email, confirmed_email: confirmedEmail }),
});
if (res.ok) return { done: true };
return res.json(); // { field, suggestion } or { field, error }
}A kept typo still goes through the disposable check, so keeping jane@gmial.com returns the permanent address error. For a hint before submit, add the signup form widget with a cl_pub_ publishable key, and keep the middleware as the backstop.
Testing it
- Typo:
curl -i -X POST http://localhost:3000/signup -H "content-type: application/json" -d '{"email":"jane@gmial.com"}'Expect 422 with"suggestion":"jane@gmail.com". - Kept typo: add
"confirmed_email":"jane@gmial.com"to the body. Expect 422 with the permanent address error, because gmial.com is also on the disposable list. - Disposable:
x@mailinator.com. Expect 422 with the permanent address error. - Good address on your own domain: expect 201.
- Fail open: start the app with a wrong key. ContactLint returns 401 and the signup gets 201.
Each test uses one of your 100 free checks for the month.
Limits: no mailbox check, and fail open
- ContactLint never opens an SMTP connection. It cannot tell you that a mailbox exists or that a domain is catch-all. It checks syntax, MX and A records, known disposable domains, role addresses and typos of major providers.
- The middleware fails open on timeouts, network errors and every non-200 response, including 401, 402 (no free checks or credits left) and 429 (more than 60 requests per minute on the key).
- An
unknownresult caused by a failed DNS lookup is not billed.
To clean addresses you already have, see clean an email list CSV.
Questions
How much latency does the middleware add?
One HTTPS request to ContactLint per checked request, capped at 3 seconds by AbortSignal.timeout. If ContactLint is slower than that, the middleware calls next() and the signup continues unchecked.
Can I reject role addresses like info@ too?
Yes. The result includes role_address, and the middleware stores the full result on req.emailCheck. Add a branch that returns 422 when role_address is true. For most signups, accepting them is fine, since they are real shared inboxes.
Should I put the middleware on every route?
No. Add it only to routes that accept a new email address, such as signup, email change and invites. Each call uses one check, and a key is limited to 60 requests per minute.
Other integrations
- Next.js
- Django
- FastAPI
- Laravel
- Ruby on Rails
- Supabase
- Firebase Authentication
- WordPress
- Webflow
- Zapier
- Make
- n8n
Get a free API key. 100 checks per month, no card required. Or try a live check first.