How to Verify Email Addresses in Node.js with CheckMail

Updated · April 2026

This 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:

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

§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