# Validate Emails in Google Sheets

URL: https://email-validation.org/docs/guides/google-sheets/
Updated: 2026-11-03

> Documentation index: https://email-validation.org/llms.txt

A ready-made Apps Script fills a validation status column for a sheet of email addresses. It runs from a menu in
your sheet, keeps your API key in the script's Script properties (never in a cell), and checks addresses in small
batches so a long column finishes quickly without hitting the [rate limits](https://email-validation.org/docs/rate-limits/).

## Set it up

1. Create an API key for the sheet on the [API keys page](https://email-validation.org/app/api-keys/), with a
   [daily credit cap](https://email-validation.org/docs/authentication/#limits) so a run can never spend more than you expect.
2. In your sheet, open **Extensions → Apps Script**. In a new project, replace everything in `Code.gs` with the
   [script below](#script). If the project already holds code of yours, add a new script file instead (**+ →
   Script**) and paste the script there, so nothing of yours is replaced. Save.
3. Reload the sheet. A new **Email validation** menu appears. Choose **Email validation → Set API key** and paste
   your key. The first time, Google asks you to allow the script to see and edit this spreadsheet and to connect to
   an external service (our API). Thanks to the `@OnlyCurrentDoc` line at its top, it never asks for your other
   spreadsheets.
4. Put the header `Email` (or `E-mail`, or `Email address`) above your addresses in row 1, then choose
   **Email validation → Validate all rows**.

The script adds four columns at the right of your data and fills them row by row: **Validation status**,
**Validation reason**, **Validation action** and **Did you mean**. When it is done, a message counts the results by
status.

## Try it for free first

Choose **Email validation → Add test addresses**. The script adds a sheet named *Test addresses* with addresses on
`sandbox.email-validation.org`, then validate that sheet. Each [test address](https://email-validation.org/docs/test-addresses/) returns the result its name
says (`accepted@sandbox.email-validation.org` is `valid`, `no_mailbox@sandbox.email-validation.org` is `invalid`, and so on), costs
nothing and contacts no mail server, so you can see every column filled before you spend a credit.

## What it does

- **Batches.** It sends 20 requests together, at most one batch a second, which stays within the
  per-second limit of an account that has not bought credits yet. Each batch is written to the sheet as soon as its
  answers arrive.
- **Each address once.** An address that appears in several rows is checked once and its result written to every
  row that holds it.
- **Resumes.** Rows that already have a status are skipped, so running it again continues where it stopped. To
  check an address again, clear its status cell. Rows marked `error` are checked again on the next run.
- **Stops when it should.** It stops on errors that every later request would get too (a refused key, no credits
  left, the key's daily cap), and after a few minutes, because Apps Script limits how long one run may take. Run it
  again to continue.
- **Retries only what is free to retry.** `429` and `503` answers, which are never charged, are sent again after
  their `Retry-After` time. When a batch gets no answer at all, its rows are left empty for the next run rather than
  sent again at once. Those requests may already have been checked and charged, so a connection failure can cost up
  to one batch of repeat checks on the next run; a smaller `EV_BATCH_SIZE` limits that.
- **Selected rows.** **Validate selected rows** checks only the rows you selected.
- **Never overwrites your data.** It refuses to run when one of your own columns sits where its result columns go,
  and it writes results as plain text, never as formulas. If you sort the sheet or insert rows or columns while it
  runs, it writes only to rows that still hold their address, then stops so you can run it again.

Each completed check costs 1 credit, as with any [validation request](https://email-validation.org/docs/api/validate/); `unknown` results,
malformed and test addresses are free.

## Settings

Optional settings go in **Project Settings → Script properties** of the Apps Script project:

| Property | Value |
|---|---|
| `EV_API_KEY` | Your API key. Set it from the menu (**Set API key**) or here. |
| `EV_REGION` | `eu` to send every request to the [EU endpoint](https://email-validation.org/docs/eu-data-residency/) (`https://api-eu.email-validation.org`); default `us`. |
| `EV_BATCH_SIZE` | Requests sent together, from 1 to 100 (default 20). Raise it on a paid account for faster runs. |

Script properties belong to the script, so anyone who can edit the sheet, or make a copy of it, may be able to read
the key. Share the sheet only with people you would give the key to, and use a key made for the sheet: you can revoke
it at any time without touching your other integrations.

## Privacy

The script never logs, prints or shows an address: its messages name counts and columns only. Each address is sent
only in the body of a `POST` request to `https://api.email-validation.org` (or `https://api-eu.email-validation.org`), never in a URL, and the script connects to no
other host. With the [manifest](#manifest) installed, Apps Script itself enforces that.

## Messages

| Message | What to do |
|---|---|
| Add your API key first | Choose **Email validation → Set API key**. |
| No address column found | Put the header `Email` above your addresses in row 1. |
| Column … is in the way | Move that column: the four columns from **Validation status** on hold the results. |
| The API key was refused | The key was revoked, rolled or mistyped: set it again. |
| Your account is out of credits | Add credits, then run again. |
| This API key reached its daily credit cap | Run again the next day, or raise the cap on the key. |
| The API asked to slow down | Wait a minute and run again, or lower `EV_BATCH_SIZE`. |
| The API paused checks for this account for a while | Run again later. |
| The sheet changed during the run | Run again: it continues with the rows still to check. |
| … addresses are left | Run again to continue. |

Apps Script also limits how many requests a Google account may send a day. For very long lists, upload the file as
a [bulk job](https://email-validation.org/docs/bulk/) instead: it is made for lists and gives you a results file to download.

## The script

`Code.gs`, the whole script:

```js
/**
 * Email validation for Google Sheets (email-validation.org): fills a status column for a sheet of email addresses.
 * Guide: https://email-validation.org/docs/guides/google-sheets/
 *
 * @OnlyCurrentDoc  (the script asks to edit this spreadsheet only; delete this line if other code in the same project
 *                  opens other spreadsheets)
 *
 * Setup: in your sheet open Extensions → Apps Script. In a new project, replace the contents of Code.gs with this
 * file; in a project that already holds code, add a new script file (+ → Script) and paste it there. Save, reload
 * the sheet, then choose Email validation → Set API key. The key is kept in this script's Script properties
 * (EV_API_KEY), never in a cell.
 *
 * Optional Script properties (Project Settings → Script properties):
 *   EV_REGION      "eu" sends every request to the EU endpoint (EU data residency); default "us".
 *   EV_BATCH_SIZE  requests sent together, at most one batch a second (default 20, at most 100). The default stays
 *                  within the per-second limit of an account without a purchase; raise it on a paid account.
 *
 * Privacy: the script never logs, prints or shows an address; its messages name counts and columns only. An address
 * is sent only in the body of a POST to the API, never in a URL.
 *
 * Every name starts with `ev` so the script can sit next to your own code. If your project already has an onOpen,
 * delete the one below and call evAddMenu() from yours.
 */

const EV_TITLE = 'Email validation';
const EV_HOSTS = Object.freeze({ us: 'https://api.email-validation.org', eu: 'https://api-eu.email-validation.org' });
const EV_KEY_PROPERTY = 'EV_API_KEY';
const EV_KEY_PATTERN = /^ev_live_[0-9A-Za-z]{79}$/;
const EV_EMAIL_HEADER = /^\s*e-?mail(\s+address)?\s*$/i;
const EV_RESULT_HEADERS = Object.freeze([
  'Validation status',
  'Validation reason',
  'Validation action',
  'Did you mean',
]);
const EV_DEFAULT_BATCH = 20;
const EV_MAX_BATCH = 100;
const EV_MAX_RETRIES = 3;
const EV_MAX_RETRY_WAIT_S = 10;
// Apps Script ends a run after 6 minutes. No batch starts after 4, and one batch with every retry takes well under
// 2, so every answer received is written.
const EV_RUN_BUDGET_MS = 4 * 60 * 1000;
const EV_STATUS_ORDER = Object.freeze(['valid', 'invalid', 'catch_all', 'disposable', 'unknown', 'error']);
// Free test addresses: each returns the result its name says, costs nothing and contacts no mail server.
const EV_SANDBOX_DOMAIN = 'sandbox.email-validation.org';
const EV_TEST_REASONS = Object.freeze([
  'accepted',
  'role_account',
  'no_mailbox',
  'likely_typo',
  'mailbox_full',
  'catch_all',
  'disposable',
  'greylisted',
]);
// Errors that every later request would get too: the run stops on them.
const EV_STOP_MESSAGES = Object.freeze({
  unauthorized: 'The API key was refused (revoked, rolled or mistyped). Set it again: Email validation → Set API key.',
  insufficient_credits: 'Your account is out of credits. Add credits in the dashboard, then run again.',
  key_daily_cap: 'This API key reached its daily credit cap. Run again tomorrow, or raise the cap in the dashboard.',
  forbidden:
    'The API refused this key: it needs the validate scope and an active account. If your account processes data in the EU only, set the Script property EV_REGION to eu.',
  forbidden_origin:
    'This API key is limited to certain websites (allowed origins). Use a key without allowed origins for Google Sheets.',
});
const EV_SLOW_DOWN = 'The API asked to slow down. Wait a minute, then run again (or lower EV_BATCH_SIZE).';
const EV_THROTTLED = 'The API paused checks for this account for a while (abuse_throttled). Run again later.';
const EV_SHEET_CHANGED =
  'The sheet changed during the run (rows or columns moved), so results were written only to rows that still hold their address. Run again to continue.';

function onOpen() {
  evAddMenu();
}

/** Adds the Email validation menu. */
function evAddMenu() {
  SpreadsheetApp.getUi()
    .createMenu(EV_TITLE)
    .addItem('Validate all rows', 'evValidateAll')
    .addItem('Validate selected rows', 'evValidateSelection')
    .addSeparator()
    .addItem('Add test addresses', 'evAddTestAddresses')
    .addItem('Set API key', 'evSetApiKey')
    .addItem('Remove API key', 'evRemoveApiKey')
    .addToUi();
}

function evValidateAll() {
  evRun(false);
}

function evValidateSelection() {
  evRun(true);
}

function evSetApiKey() {
  const ui = SpreadsheetApp.getUi();
  const answer = ui.prompt(
    EV_TITLE,
    "Paste your API key (it starts with ev_live_). It is kept in this script's Script properties, not in the sheet.",
    ui.ButtonSet.OK_CANCEL,
  );
  if (answer.getSelectedButton() !== ui.Button.OK) return;
  const key = answer.getResponseText().trim();
  if (!EV_KEY_PATTERN.test(key)) {
    evAlert(
      'That is not an API key: keys start with ev_live_ and are 87 characters long. Copy it again from the dashboard.',
    );
    return;
  }
  PropertiesService.getScriptProperties().setProperty(EV_KEY_PROPERTY, key);
  evAlert('API key saved. Choose Email validation → Validate all rows to start.');
}

function evRemoveApiKey() {
  PropertiesService.getScriptProperties().deleteProperty(EV_KEY_PROPERTY);
  evAlert('API key removed from this script.');
}

/** Adds a sheet of free test addresses to try the script on. */
function evAddTestAddresses() {
  const spreadsheet = SpreadsheetApp.getActiveSpreadsheet();
  const name = 'Test addresses';
  let sheet = spreadsheet.getSheetByName(name);
  if (!sheet) {
    sheet = spreadsheet.insertSheet(name);
    const rows = [['Email']].concat(EV_TEST_REASONS.map((reason) => [`${reason}@${EV_SANDBOX_DOMAIN}`]));
    sheet.getRange(1, 1, rows.length, 1).setValues(rows);
  }
  spreadsheet.setActiveSheet(sheet);
  evAlert(
    `The sheet "${name}" holds test addresses: each returns a fixed result for free. Choose Email validation → Validate all rows to try them.`,
  );
}

/** One run over the active sheet (or the selected rows of it). */
function evRun(selectionOnly) {
  const props = PropertiesService.getScriptProperties();
  const key = (props.getProperty(EV_KEY_PROPERTY) || '').trim();
  if (!key) {
    evAlert('Add your API key first: Email validation → Set API key.');
    return;
  }
  const region = (props.getProperty('EV_REGION') || 'us').trim().toLowerCase();
  if (!Object.prototype.hasOwnProperty.call(EV_HOSTS, region)) {
    evAlert('The Script property EV_REGION must be us or eu.');
    return;
  }
  const batchSize = evBatchSize(props.getProperty('EV_BATCH_SIZE'));
  if (!batchSize) {
    evAlert(`The Script property EV_BATCH_SIZE must be a whole number from 1 to ${EV_MAX_BATCH}.`);
    return;
  }
  const lock = LockService.getDocumentLock();
  if (!lock.tryLock(1000)) {
    evAlert('A validation run is already going on in this spreadsheet. Wait for it to finish.');
    return;
  }
  try {
    const sheet = SpreadsheetApp.getActiveSheet();
    const plan = evPlan(sheet, selectionOnly ? SpreadsheetApp.getActiveRange() : null);
    evAlert(typeof plan === 'string' ? plan : evCheck(sheet, plan, key, EV_HOSTS[region], batchSize));
  } finally {
    lock.releaseLock();
  }
}

function evBatchSize(value) {
  if (value === null || value === undefined || String(value).trim() === '') return EV_DEFAULT_BATCH;
  const text = String(value).trim();
  if (!/^\d+$/.test(text)) return 0;
  const n = Number(text);
  return n >= 1 && n <= EV_MAX_BATCH ? n : 0;
}

/**
 * Finds the address column (headed Email, E-mail or Email address in row 1), the result columns and the addresses
 * still to check. Rows that already have a status are skipped; a row whose status is "error" is checked again.
 * Returns a message for the user instead when there is nothing to do.
 */
function evPlan(sheet, selection) {
  const lastRow = sheet.getLastRow();
  const lastColumn = sheet.getLastColumn();
  if (lastRow < 2) {
    return 'This sheet has no addresses: put the header Email in row 1 and one address per row below it.';
  }
  const headers = sheet
    .getRange(1, 1, 1, lastColumn)
    .getValues()[0]
    .map((h) => String(h).trim());
  const emailColumn = headers.findIndex((h) => EV_EMAIL_HEADER.test(h)) + 1;
  if (!emailColumn) return 'No address column found: head the column that holds your addresses Email (in row 1).';
  const found = headers.indexOf(EV_RESULT_HEADERS[0]) + 1;
  const resultColumn = found || lastColumn + 1;
  const width = EV_RESULT_HEADERS.length;
  if (found) {
    for (let i = 1; i < width; i++) {
      const h = headers[found - 1 + i];
      if (h && h !== EV_RESULT_HEADERS[i]) {
        return `Column ${found + i} is in the way: the ${width} columns from "${EV_RESULT_HEADERS[0]}" on hold the results. Move that column, then run again.`;
      }
    }
    if (emailColumn >= found && emailColumn < found + width) {
      return 'The address column is inside the result columns: move it, then run again.';
    }
    // the result columns that exist yet (a range past the sheet's last column is an error)
    const present = Math.min(width, sheet.getMaxColumns() - found + 1);
    const below = sheet.getRange(2, found, lastRow - 1, present).getValues();
    for (let i = 1; i < present; i++) {
      if (!headers[found - 1 + i] && below.some((r) => String(r[i]).trim() !== '')) {
        return `Column ${found + i} is in the way: it has no header but holds data where the results go. Move that column, then run again.`;
      }
    }
  }
  let first = 2;
  let last = lastRow;
  if (selection) {
    first = Math.max(2, selection.getRow());
    last = Math.min(lastRow, selection.getLastRow());
    if (first > last) return 'Select the rows to validate first (below the header row).';
  }
  const count = last - first + 1;
  const emails = sheet.getRange(first, emailColumn, count, 1).getValues();
  const statuses = found ? sheet.getRange(first, resultColumn, count, 1).getValues() : null;
  const rowsByAddress = new Map();
  let skipped = 0;
  for (let i = 0; i < count; i++) {
    const address = String(emails[i][0]).trim();
    if (!address) continue;
    const status = statuses ? String(statuses[i][0]).trim() : '';
    if (status && status !== 'error') {
      skipped++;
      continue;
    }
    const rows = rowsByAddress.get(address);
    if (rows) rows.push(first + i);
    else rowsByAddress.set(address, [first + i]);
  }
  if (!rowsByAddress.size) {
    return skipped
      ? 'Nothing to validate: every address in these rows already has a status. Clear a status to check that address again.'
      : 'Nothing to validate: these rows hold no addresses.';
  }
  const items = [];
  rowsByAddress.forEach((rows, address) => {
    items.push({ address: address, rows: rows });
  });
  return {
    emailColumn: emailColumn,
    emailHeader: headers[emailColumn - 1],
    resultColumn: resultColumn,
    items: items,
    skipped: skipped,
  };
}

/** Checks the planned addresses in batches and writes each batch's results as soon as they arrive. */
function evCheck(sheet, plan, key, host, batchSize) {
  const started = Date.now();
  const width = EV_RESULT_HEADERS.length;
  const missing = plan.resultColumn + width - 1 - sheet.getMaxColumns();
  if (missing > 0) sheet.insertColumnsAfter(sheet.getMaxColumns(), missing);
  sheet.getRange(1, plan.resultColumn, 1, width).setValues([EV_RESULT_HEADERS.slice()]);
  const counts = {};
  let next = 0;
  let stop = '';
  while (next < plan.items.length) {
    if (Date.now() - started > EV_RUN_BUDGET_MS) {
      stop = 'Apps Script limits how long a run may take, so this run stopped after 4 minutes.';
      break;
    }
    const batch = plan.items.slice(next, next + batchSize);
    const batchStarted = Date.now();
    let answers;
    try {
      answers = evFetchBatch(batch, key, host);
    } catch (_err) {
      // No answer (network failure): the requests may have reached the API, so they are not sent again by
      // themselves. Their rows stay empty and the next run checks them.
      stop = 'The API could not be reached, so the last rows were left empty.';
      break;
    }
    next += batch.length;
    // A menu run does not lock the sheet: write only where the sheet still looks as it did when the run started.
    const current = evRowsStillInPlace(sheet, plan, batch);
    const cells = [];
    batch.forEach((item, i) => {
      const status = answers[i].cells[0];
      counts[status] = (counts[status] || 0) + 1;
      for (const row of item.rows) if (current[row]) cells.push([row, answers[i].cells]);
    });
    evWrite(sheet, plan.resultColumn, cells);
    if (current.changed) {
      stop = EV_SHEET_CHANGED;
      break;
    }
    const stopped = answers.find((a) => a.stop);
    if (stopped) {
      stop = stopped.stop;
      break;
    }
    if (answers.some((a) => a.retryable)) {
      stop = answers.some((a) => a.code === 'abuse_throttled') ? EV_THROTTLED : EV_SLOW_DOWN;
      break;
    }
    const wait = 1000 - (Date.now() - batchStarted);
    if (next < plan.items.length && wait > 0) Utilities.sleep(wait);
  }
  return evSummary(counts, next, plan.items.length - next, plan.skipped, stop);
}

/**
 * Which rows of the batch still hold their address under the same headers: { <row>: true, …, changed }. When the
 * header row moved, no row is in place.
 */
function evRowsStillInPlace(sheet, plan, batch) {
  const out = { changed: false };
  const headers = sheet.getRange(1, 1, 1, Math.max(plan.emailColumn, plan.resultColumn)).getValues()[0];
  if (
    String(headers[plan.emailColumn - 1]).trim() !== plan.emailHeader ||
    headers[plan.resultColumn - 1] !== EV_RESULT_HEADERS[0]
  ) {
    out.changed = true;
    return out;
  }
  let first = Infinity;
  let last = 0;
  for (const item of batch) {
    for (const row of item.rows) {
      first = Math.min(first, row);
      last = Math.max(last, row);
    }
  }
  const values = sheet.getRange(first, plan.emailColumn, last - first + 1, 1).getValues();
  for (const item of batch) {
    for (const row of item.rows) {
      if (String(values[row - first][0]).trim() === item.address) out[row] = true;
      else out.changed = true;
    }
  }
  return out;
}

/** Sends one batch in parallel; 429 and 503 answers (never charged) are sent again after Retry-After. */
function evFetchBatch(batch, key, host) {
  const answers = new Array(batch.length);
  let todo = batch.map((_item, i) => i);
  for (let attempt = 0; todo.length; attempt++) {
    let responses;
    try {
      responses = UrlFetchApp.fetchAll(todo.map((i) => evRequest(host, key, batch[i].address)));
    } catch (err) {
      // a failed first attempt is the caller's to report; on a retry, keep the answers already received (the rows
      // being retried keep their 429 or 503 error and are checked again on the next run)
      if (attempt === 0) throw err;
      break;
    }
    const again = [];
    let wait = 0;
    responses.forEach((response, j) => {
      const answer = evRead(response);
      answers[todo[j]] = answer;
      if (answer.retryable && attempt < EV_MAX_RETRIES) {
        again.push(todo[j]);
        wait = Math.max(wait, answer.retryAfter === null ? evBackoff(attempt) : answer.retryAfter);
      }
    });
    // an answer that stops the run (a refused key, no credits) ends the batch: retrying the rest would only repeat it
    const stops = todo.some((i) => answers[i].stop);
    if (!again.length || stops || wait > EV_MAX_RETRY_WAIT_S) break;
    Utilities.sleep(Math.ceil(wait * 1000));
    todo = again;
  }
  return answers;
}

function evRequest(host, key, address) {
  return {
    url: `${host}/v1/validate`,
    method: 'post',
    contentType: 'application/json',
    headers: { Authorization: `Bearer ${key}` },
    payload: JSON.stringify({ email: address }),
    muteHttpExceptions: true,
    followRedirects: false,
  };
}

/** One answer → the four result cells, and whether it may be retried or stops the run. */
function evRead(response) {
  const http = response.getResponseCode();
  let body = null;
  try {
    body = JSON.parse(response.getContentText());
  } catch (_err) {
    body = null;
  }
  if (http >= 200 && http < 300) {
    if (body && typeof body.status === 'string') {
      return {
        cells: [body.status, body.reason, body.action, body.did_you_mean].map(evCell),
        code: '',
        retryable: false,
        retryAfter: null,
        stop: '',
      };
    }
    return evError('unexpected_response', http, response);
  }
  const code = body && body.error && typeof body.error.code === 'string' ? body.error.code : 'unexpected_response';
  return evError(code, http, response);
}

function evError(code, http, response) {
  const retryable = (http === 429 || http === 503) && code !== 'key_daily_cap';
  return {
    cells: ['error', evCell(code), '', ''],
    code: code,
    retryable: retryable,
    retryAfter: retryable ? evRetryAfter(response.getHeaders()) : null,
    stop: Object.prototype.hasOwnProperty.call(EV_STOP_MESSAGES, code) ? EV_STOP_MESSAGES[code] : '',
  };
}

/** Retry-After in whole seconds (digits or an HTTP date), or null. */
function evRetryAfter(headers) {
  const name = Object.keys(headers || {}).find((h) => h.toLowerCase() === 'retry-after');
  if (!name) return null;
  const value = String(headers[name]).trim();
  if (/^\d+$/.test(value)) return Number(value);
  const date = Date.parse(value);
  return Number.isNaN(date) ? null : Math.max(0, Math.ceil((date - Date.now()) / 1000));
}

/** Seconds to wait before attempt + 1 when there is no Retry-After: 0.25–0.5 s, then 0.5–1 s, 1–2 s. */
function evBackoff(attempt) {
  const base = Math.min(8, 0.5 * 2 ** attempt);
  return base / 2 + (Math.random() * base) / 2;
}

/** A value as cell text that Sheets never reads as a formula. */
function evCell(value) {
  const text = value === null || value === undefined ? '' : String(value);
  return /^[=+\-@]/.test(text) ? `'${text}` : text;
}

/** Writes [row, cells] pairs, one call per run of consecutive rows. */
function evWrite(sheet, column, cells) {
  cells.sort((a, b) => a[0] - b[0]);
  let start = 0;
  for (let i = 1; i <= cells.length; i++) {
    if (i < cells.length && cells[i][0] === cells[i - 1][0] + 1) continue;
    sheet
      .getRange(cells[start][0], column, i - start, EV_RESULT_HEADERS.length)
      .setValues(cells.slice(start, i).map((c) => c[1]));
    start = i;
  }
}

function evSummary(counts, checked, left, skipped, stop) {
  const known = EV_STATUS_ORDER.filter((s) => counts[s]);
  const other = Object.keys(counts).filter((s) => EV_STATUS_ORDER.indexOf(s) < 0);
  const parts = known.concat(other).map((s) => `${s} ${counts[s]}`);
  const lines = [
    `Checked ${checked} ${checked === 1 ? 'address' : 'addresses'}${parts.length ? ` (${parts.join(', ')})` : ''}.`,
  ];
  if (counts.error) lines.push('Rows marked error are checked again on the next run.');
  if (skipped)
    lines.push(
      `${skipped} ${skipped === 1 ? 'row' : 'rows'} already had a status and ${skipped === 1 ? 'was' : 'were'} skipped.`,
    );
  if (stop) lines.push(stop);
  if (left) lines.push(`${left} ${left === 1 ? 'address is' : 'addresses are'} left: run again to continue.`);
  return lines.join('\n');
}

function evAlert(text) {
  const ui = SpreadsheetApp.getUi();
  ui.alert(EV_TITLE, text, ui.ButtonSet.OK);
}
```

If your project already has an `onOpen` function, delete the script's own `onOpen` and call `evAddMenu()` from
yours. Every name in the script starts with `ev`, so it does not clash with your own code.

### The manifest (optional)

The manifest, `appsscript.json`, pins the two permissions above and lets the script reach only the two API hosts.
Turn on **Project Settings → Show "appsscript.json" manifest file in editor** to see it.

- **In a project that holds only this script**, replace the file's contents with the manifest below.
- **In a project with code of yours**, do not replace the file: it also holds your libraries, services, time zone
  and permissions. Add the two `oauthScopes` entries and the two `urlFetchWhitelist` entries to it instead, with any
  your own code needs. Once a project's manifest has a `urlFetchWhitelist`, Apps Script blocks every host that is not
  on it, for your code too.

```json
{
  "timeZone": "Etc/UTC",
  "runtimeVersion": "V8",
  "exceptionLogging": "STACKDRIVER",
  "oauthScopes": [
    "https://www.googleapis.com/auth/spreadsheets.currentonly",
    "https://www.googleapis.com/auth/script.external_request"
  ],
  "urlFetchWhitelist": ["https://api.email-validation.org/", "https://api-eu.email-validation.org/"]
}
```

## Related

- [No-code recipes](https://email-validation.org/docs/guides/no-code/) for Zapier, Make and n8n
- [Statuses and reasons](https://email-validation.org/docs/statuses/): what each value in the result columns means
- [Errors](https://email-validation.org/docs/errors/), [rate limits](https://email-validation.org/docs/rate-limits/) and [test addresses](https://email-validation.org/docs/test-addresses/)
