How to process large email verification batches

Verifying a big list isn't a single request — it's a small pipeline. You submit the addresses once, the job runs asynchronously, and you collect the results when it finishes. Do it well and a million-address list processes reliably and cheaply. Do it carelessly and you burn credits on duplicates, hammer the API with retries, or miss results because you paged wrong. This is the pattern: dedupe and submit, wait for completion (polling or webhook), then page through every result.

Written & reviewed by the VerifyAnyEmail team · Last reviewed July 2026

Single-address verification is synchronous — you call POST /v1/verify and get an answer in one response. That doesn’t scale to tens of thousands of addresses: the request would take too long and time out. So bulk verification is a three-step asynchronous flow instead.

  1. Submit the list to POST /v1/verify/batch. It returns immediately with a batch id and a status of queued.
  2. Wait for completion — either poll GET /v1/verify/batch/{id} until status is completed, or receive a signed batch.completed webhook.
  3. Fetch the results from GET /v1/verify/batch/{id}/results, paging through them.

Everything below is about doing those three steps reliably at scale.

1. Dedupe and normalise before you submit (idempotency)

Every address in a batch costs a credit, so the cheapest optimisation is to never verify the same address twice. Before submitting, lowercase and trim each address, drop exact duplicates, and remove anything you’ve verified recently and already have a fresh result for. On a real-world list this routinely cuts 10–30% of the volume — and therefore the cost — before a single credit is spent.

Idempotency also matters for the submit call itself. Network hiccups happen: you send the batch, the connection drops before you read the response, and now you don’t know whether it was created. The safe pattern is to generate your own client-side batch key (store it before you send), pass a recognisable name, and if you must retry, first check whether a batch with that key already exists rather than blindly re-submitting. That prevents a dropped response from turning into two paid jobs. Treat the result the same way on the receiving end — key any downstream processing off the batch id so a re-delivered webhook or a repeated poll never double-processes.

2. Submit the batch

Send the addresses (and optionally a name and a webhookUrl). You get back { ok, batch: { id, status, total } } with a 202 Accepted. Very large lists are best split into chunks — submit several batches rather than one enormous request — which also keeps each job’s memory and retry surface manageable.

async function submitBatch(emails, name) {
  // Dedupe + normalise first — you pay per address.
  const clean = [...new Set(emails.map((e) => e.trim().toLowerCase()))];

  const res = await fetch("https://api.verifyany.email/v1/verify/batch", {
    method: "POST",
    headers: {
      Authorization: "Bearer vae_your_api_key",
      "Content-Type": "application/json",
    },
    body: JSON.stringify({ emails: clean, name }),
  });

  if (res.status === 402) throw new Error("Insufficient credits");
  if (res.status === 413) throw new Error("Batch too large for your plan — split it");
  if (!res.ok) throw new Error("Submit failed: " + res.status);

  const { batch } = await res.json();
  return batch; // { id, status: "queued", total }
}

3a. Poll for completion

Polling is the simplest way to wait: call the status endpoint on an interval until status becomes completed (or failed). Two rules keep it well-behaved. First, back off — don’t poll every second for a job that takes minutes; a few seconds between polls, growing over time, is plenty and avoids rate limits. Second, set a ceiling so a stuck job doesn’t loop forever. The status response also carries a running processed count and a summary, so you can show progress.

async function waitForBatch(id, { intervalMs = 5000, maxMs = 30 * 60 * 1000 } = {}) {
  const started = Date.now();
  let wait = intervalMs;

  while (true) {
    const res = await fetchWithRetry(
      "https://api.verifyany.email/v1/verify/batch/" + id
    );
    const { batch } = await res.json();

    if (batch.status === "completed") return batch;
    if (batch.status === "failed") throw new Error("Batch failed");
    if (Date.now() - started > maxMs) throw new Error("Timed out waiting");

    await new Promise((r) => setTimeout(r, wait));
    wait = Math.min(wait * 1.5, 30000); // gentle backoff, capped at 30s
  }
}

3b. Or use a webhook instead of polling

For large lists, a webhook is more efficient than polling: pass a webhookUrl when you submit, and VerifyAnyEmail POSTs a signed batch.completed event the moment the job finishes — no repeated status calls, no wasted requests, and you react instantly. The trade-off is that you need a publicly reachable HTTPS endpoint, and you must verify the signature on every callback. Do not skip that step; an unverified webhook is an open door. The full verification code (raw body, HMAC-SHA256, constant-time compare) is in how to verify webhook signatures correctly.

A robust setup often uses both: rely on the webhook for prompt notification, but keep a slow safety-net poll (say, once a minute) in case a callback is ever missed. Because both paths key off the same batch id and your processing is idempotent, handling the completion twice is harmless.

4. Page through every result

Results are paginated. GET /v1/verify/batch/{id}/results?page=1&pageSize=1000 returns a page of results plus a total count. Loop, incrementing page, until you’ve collected total rows (or a page comes back empty). Use a large pageSize (up to 1000) to minimise round-trips.

async function fetchAllResults(id, pageSize = 1000) {
  const all = [];
  for (let page = 1; ; page++) {
    const url =
      "https://api.verifyany.email/v1/verify/batch/" + id +
      "/results?page=" + page + "&pageSize=" + pageSize;
    const res = await fetchWithRetry(url);
    const { results, total } = await res.json();

    all.push(...results);
    if (all.length >= total || results.length === 0) break;
  }
  return all; // each: { email, normalized, status, subStatus, score, suggestion }
}

Each result carries a status (deliverable, undeliverable, risky, unknown), a score, and sometimes a suggestion for likely typos. How to act on each status — what to keep, suppress, or segment — is covered in the results-handling guides; the short version is: mail the deliverables, drop the undeliverables, and segment risky and catch-all results separately.

5. Retry transient failures with backoff

At scale you will hit the occasional 429 (rate limit or concurrent-batch limit) or 5xx. These are transient — the correct response is to wait and retry, not to give up. Retry only those status codes, use exponential backoff with a little jitter so parallel workers don’t retry in lockstep, honour a Retry-After header if one is present, and cap the number of attempts. Never retry a 4xx other than 429 (a 400 or 402 won’t fix itself).

async function fetchWithRetry(url, opts = {}, tries = 5) {
  for (let attempt = 0; ; attempt++) {
    const res = await fetch(url, {
      ...opts,
      headers: { Authorization: "Bearer vae_your_api_key", ...(opts.headers || {}) },
    });

    if (res.ok) return res;

    // Only 429 and 5xx are worth retrying.
    const retryable = res.status === 429 || res.status >= 500;
    if (!retryable || attempt >= tries - 1) {
      throw new Error("Request failed: " + res.status);
    }

    const retryAfter = Number(res.headers.get("retry-after")) || 0;
    const backoff = retryAfter * 1000 || Math.min(1000 * 2 ** attempt, 30000);
    await new Promise((r) => setTimeout(r, backoff + Math.random() * 250));
  }
}

6. Stay credit-aware

Submitting a batch consumes credits up front, and the API returns 402 Insufficient credits if you don’t have enough — so check your balance before dispatching a huge job, and handle 402 rather than treating it as a generic error. Deduplicating first (step 1) is the single biggest lever on cost. If your plan caps batch size or concurrent batches, you’ll see 413 or 429 respectively; the fix is to split the list and limit how many batches you run at once.

Common mistakes

  • Not deduping. You pay for every address, including the duplicates you could have removed for free.
  • Tight polling loops. Polling every second wastes requests and invites 429s. Back off and cap the wait.
  • Retrying non-retryable errors. Retrying a 400 or 402 just repeats a guaranteed failure. Retry only 429 and 5xx.
  • Non-idempotent processing. If a re-delivered webhook or a repeated poll double-imports results, you corrupt your data. Key everything off the batch id.
  • Stopping paging too early. Loop until you’ve collected total rows — don’t assume one page is the whole batch.

Where to go next

The exact request and response schemas for every endpoint are in the API docs. For the completion-callback security layer, see how to verify webhook signatures correctly, and keep the credential that authorises all of this safe with how to protect your API keys. To validate a single address while you’re building, the Email Verifier is the quickest way, and verifying email in Node.js & Express shows the surrounding application code.

Sources

The 429 Too Many Requests status and the Retry-After header are defined in RFC 6585 and RFC 9110. Batch limits, credit costs and plan-specific caps are described in the VerifyAnyEmail API docs.

Check your domain in seconds

Run the free diagnostics referenced in this guide — no sign-up needed.