How to Verify Email Addresses in Node.js with CheckMail
Updated · April 2026This guide shows you how to verify email addresses in Node.js using the CheckMail API. You'll cover single and batch verification, error handling, retries with exponential backoff, and a complete working example you can drop into any backend. Everything uses Node's built-in fetch, so there are no dependencies to install beyond Node 18+.
Rolling your own verifier means wrestling with port 25 blocks, IP reputation, catch-all detection, and a disposable domain list that goes stale every week. (We wrote about that the long way here.) The API route skips all of that - one HTTP call, a single JSON response.
§01Prerequisites
You need Node.js 18 or newer (for the built-in fetch) and a CheckMail API key. Sign up and you'll get 100 free credits immediately - no card required. Keys look like cm_live_... for production and cm_test_... for sandboxing.
node --version # should be v18 or higher
mkdir email-verify && cd email-verify
npm init -y
export CHECKMAIL_KEY=cm_live_xxxxxxxxxxxxxxxx
Never hardcode the key. Read it from an environment variable or secrets manager. If a key leaks into a git repo, rotate it from the dashboard and the old one stops working.
§02Your first verification
The simplest possible verification is a GET to /v1/verify with the email as a query parameter and a bearer token in the header:
// verify.mjs
const email = 'john@example.com';
const res = await fetch(
`https://api.checkmail.dev/v1/verify?email=${encodeURIComponent(email)}`,
{ headers: { Authorization: `Bearer ${process.env.CHECKMAIL_KEY}` } }
);
const data = await res.json();
console.log(data);
Run it with node verify.mjs. If everything is wired up right, you'll get back a JSON object describing the address. One credit is deducted per definitive result.
If you're stuck on CommonJS, the same thing works with node --experimental-fetch on Node 16 or with the undici package installed manually. We strongly recommend upgrading to Node 18+ instead.
§03Understanding the response
Here's the full shape of a successful response:
{
"email": "john@example.com",
"status": "valid",
"checks": {
"syntax": true,
"mx_found": true,
"smtp_valid": true,
"catch_all": false,
"disposable": false,
"role_based": false,
"free_provider": false
},
"suggestion": null,
"mx_host": "aspmx.l.google.com",
"cached": false,
"ms": 1243
}
The field you'll branch on 99% of the time is status. It's one of:
valid- mailbox exists and will accept mail. Safe to send.invalid- bad syntax, no MX records, or the mail server explicitly rejected the address.catch_all- the domain accepts all addresses, so we can't tell for sure. Treat as lower confidence.disposable- it's a throwaway address (Mailinator, Guerrilla, etc.). Usually you want to reject these at signup.unknown- temporary failure, greylisting, or rate-limited upstream. Retry later. Not charged.
The suggestion field catches common typos - if someone enters john@gnail.con, you'll get "john@gmail.com" back and can prompt the user to confirm. For the full schema, see the docs.
§04Batch verification
If you have a list of addresses to verify - a newsletter import, a lead list, a periodic cleanup job - use the batch endpoint. One request, up to 500 emails, one round trip:
async function verifyBatch(emails) {
const res = await fetch('https://api.checkmail.dev/v1/verify/batch', {
method: 'POST',
headers: {
'Authorization': `Bearer ${process.env.CHECKMAIL_KEY}`,
'Content-Type': 'application/json'
},
body: JSON.stringify({ emails })
});
if (!res.ok) {
throw new Error(`Batch failed: ${res.status} ${await res.text()}`);
}
return res.json();
}
const { results, charged } = await verifyBatch([
'alice@acme.com',
'bob@bouncy.test',
'carol@example.org'
]);
for (const r of results) {
console.log(`${r.email} → ${r.status}`);
}
console.log(`Charged: ${charged} credits`);
Each result matches the single-verify schema. The charged field reports the real credit deduction - unknown results are free, so a batch of 500 can legitimately be billed at 487. If you try to submit more than 500 addresses in one request you'll get back 400 batch_too_large; chunk your list in the caller.
§05Error handling and retries
HTTP errors fall into two buckets. Client errors (4xx) are your problem - bad key, malformed request, over quota - and retrying them won't help. Server errors (5xx) and rate limits (429) are transient and should be retried with exponential backoff:
async function verifyWithRetry(email, maxAttempts = 4) {
for (let attempt = 1; attempt <= maxAttempts; attempt++) {
const res = await fetch(
`https://api.checkmail.dev/v1/verify?email=${encodeURIComponent(email)}`,
{ headers: { Authorization: `Bearer ${process.env.CHECKMAIL_KEY}` } }
);
if (res.ok) return res.json();
// Permanent failure - don't retry
if (res.status >= 400 && res.status < 500 && res.status !== 429) {
throw new Error(`Client error ${res.status}: ${await res.text()}`);
}
// Transient - back off and try again
const delay = Math.min(1000 * 2 ** (attempt - 1), 10000);
await new Promise(r => setTimeout(r, delay));
}
throw new Error(`Gave up after ${maxAttempts} attempts`);
}
The backoff is 1s, 2s, 4s, 8s. Cap it at 10 seconds so a single stuck request doesn't hold up your whole pipeline. If you're running at scale, add jitter (delay + Math.random() * 500) to avoid thundering-herd retries when the API briefly hiccups.
§06Best practices
- Verify at signup. Checking an address the moment it's entered stops bad data from ever entering your database. Do it server-side right after form submission, before you create the user record.
- Don't block the UI. A verification call takes ~1–2 seconds on average. Fire it after the form submits and render the result inline, rather than blocking the form from even submitting. For signup, accept the registration optimistically and mark it as pending-verification.
- Cache results. We cache internally and mark
cached: trueon the response, but you should still store verdicts in your own database. Never re-verify the same address twice in the same day. - Keep the key server-side. Never ship your API key to the browser. If you need client-side verification (e.g., inline form hints), proxy through your backend and rate-limit per session.
- Handle
unknownresults gracefully. Anunknownverdict isn't a failure - it means the upstream mail server was temporarily unreachable. Retry it in 10 minutes. Don't reject the signup. - Use the suggestion. When
suggestionis non-null, show the user "Did you mean john@gmail.com?" It recovers a surprising number of typos.
§07Complete working example
Here's a drop-in module that takes a list of addresses, verifies them in batches, retries transient failures, and logs a summary. Save as verify-list.mjs and run with node verify-list.mjs:
// verify-list.mjs
const API = 'https://api.checkmail.dev/v1';
const KEY = process.env.CHECKMAIL_KEY;
const BATCH_SIZE = 500;
if (!KEY) {
console.error('Set CHECKMAIL_KEY in the environment');
process.exit(1);
}
async function verifyChunk(emails, attempt = 1) {
const res = await fetch(`${API}/verify/batch`, {
method: 'POST',
headers: {
'Authorization': `Bearer ${KEY}`,
'Content-Type': 'application/json'
},
body: JSON.stringify({ emails })
});
if (res.ok) return res.json();
if ((res.status === 429 || res.status >= 500) && attempt < 4) {
const delay = 1000 * 2 ** (attempt - 1);
await new Promise(r => setTimeout(r, delay));
return verifyChunk(emails, attempt + 1);
}
throw new Error(`${res.status}: ${await res.text()}`);
}
async function verifyAll(emails) {
const results = [];
let charged = 0;
for (let i = 0; i < emails.length; i += BATCH_SIZE) {
const chunk = emails.slice(i, i + BATCH_SIZE);
const page = await verifyChunk(chunk);
results.push(...page.results);
charged += page.charged;
console.log(`Processed ${results.length} / ${emails.length}`);
}
return { results, charged };
}
const emails = [
'alice@acme.com',
'bob@example.org',
'typo@gnail.con',
'throwaway@mailinator.com'
];
const { results, charged } = await verifyAll(emails);
const summary = { valid: 0, invalid: 0, catch_all: 0, disposable: 0, unknown: 0 };
for (const r of results) {
summary[r.status]++;
if (r.suggestion) console.log(`${r.email} → did you mean ${r.suggestion}?`);
}
console.log(summary);
console.log(`Credits charged: ${charged}`);
That's the whole integration. Thirty lines of boilerplate and you've got a production-grade verification pipeline.
§08Next steps
Grab an API key and your first 100 checks are free. $2 per 1,000 after that. Credits never expire. For the full API reference - including domain intelligence, account endpoints, and auto-topup - see the docs.
Questions? mail@checkmail.dev