All posts

Google Forms Email Verification: Check Every Response Automatically

··11 min read

To verify emails in Google Forms automatically, link the form to a Google Sheet and add a short Apps Script to that Sheet. An installable On form submit trigger runs the script each time someone responds. The script sends the email answer to a verification API with UrlFetchApp, then writes the status, the reason and the check date into three new columns. Google Forms on its own only checks the format of an address. It never checks whether the mailbox exists.

This guide gives you the full script, the setup steps, a backfill for responses you already have, and the Apps Script quotas that cap how far it goes. The examples use SimpleVerifier's API. Any API that takes an email and returns a status works the same way.

What Google Forms checks on its own

Forms has three settings that touch email addresses. None of them confirms a mailbox.

Setting What it does Confirms the mailbox?
Collect email addresses: Verified Records the respondent's Google Account address with each response It is a real Google address, but respondents need a Google sign-in
Collect email addresses: Responder input Respondents must type an address before they submit No
Response validation: Text, Email address Rejects answers not shaped like text@example.com No, format only

The wording comes from Google's help pages. For Verified, Google says "respondents must confirm their Google Account email address gets collected with their response". For Responder input, "they will be required to enter their email address before they submit the form" (Google Forms help). Response validation for short answers offers "Text" with an email address rule, or a regular expression (Google Forms help).

So a respondent can type jane@compnay.com and pass every check. The typo only shows up when your confirmation email bounces. Our guide to catching email typos at signup shows how common that pattern is.

Verified is the right choice for internal forms where everyone has a Google account. For public forms, event registrations and lead forms, you need Responder input or an email question, plus a real check behind it.

What you will build

  1. The form saves each response to a linked Sheet.
  2. An installable trigger calls onFormSubmitVerify with the new row.
  3. The function reads the email answer, calls the verification API and writes three columns: SV Status, SV Reason and SV Checked At.
  4. A second function, backfillVerify, fills those columns for rows that came in before you set this up.

If your responses already sit in a Sheet and you would rather use formulas, the Google Sheets add-on adds =SV_VERIFY() with no code. The script below suits forms that should check every response the moment it arrives.

Step 1: Link the form to a Sheet

In Google Forms, open the form, click Responses, then Link to Sheets (Google Forms help). Create a new spreadsheet or pick an existing one.

Look at the header row. Note the exact header of the email column. If you use the built-in collection setting it is usually Email Address. If you asked an email question, it is the question title, such as Work email.

Step 2: Add the API key to Script Properties

In the Sheet, open Extensions > Apps Script. This creates a script bound to the spreadsheet.

Do not paste the key into the code. In the Apps Script editor, click Project Settings, then under Script Properties click Add script property. Google's properties guide describes this screen. Add:

Property Value
SIMPLEVERIFIER_KEY sv_live_... (your key)

Click Save script properties. Script properties are shared by all users of the script, so treat edit access to the script as access to the key.

SimpleVerifier keys start with sv_live_ and are shown once when you create them. The API docs explain where to make one.

Step 3: Paste the script

Replace the contents of Code.gs with this. Change EMAIL_HEADER to match your column.

const EMAIL_HEADER = 'Email Address';
const RESULT_HEADERS = ['SV Status', 'SV Reason', 'SV Checked At'];
const API_URL = 'https://simpleverifier.com/api/v1/verify';

/** Runs on every form submission (installable trigger). */
function onFormSubmitVerify(e) {
  const sheet = e.range.getSheet();
  const row = e.range.getRow();
  const answer = (e.namedValues[EMAIL_HEADER] || [''])[0];
  writeResult_(sheet, row, resultColumns_(sheet), verifyEmail_(answer));
}

/** Calls the API. Returns { status, reason } and never throws on HTTP errors. */
function verifyEmail_(raw) {
  const email = String(raw || '').trim().toLowerCase();
  if (!email) return { status: 'skipped', reason: 'No email in response' };

  const key = PropertiesService.getScriptProperties().getProperty('SIMPLEVERIFIER_KEY');
  if (!key) throw new Error('Add SIMPLEVERIFIER_KEY in Project Settings > Script Properties');

  for (let attempt = 1; attempt <= 3; attempt++) {
    const res = UrlFetchApp.fetch(API_URL + '?email=' + encodeURIComponent(email), {
      method: 'get',
      headers: { Authorization: 'Bearer ' + key },
      muteHttpExceptions: true,
    });
    const code = res.getResponseCode();
    if (code === 429 || code === 503) {
      Utilities.sleep(5000 * attempt);
      continue;
    }
    let body = {};
    try { body = JSON.parse(res.getContentText()); } catch (err) {}
    if (code >= 200 && code < 300 && body.status) {
      return { status: body.status, reason: body.reason || '' };
    }
    return { status: 'error', reason: 'HTTP ' + code + ': ' + (body.error || 'unexpected response') };
  }
  return { status: 'error', reason: 'Still rate limited after 3 tries' };
}

/** Finds the result columns by header, creating any that are missing. */
function resultColumns_(sheet) {
  const lastCol = sheet.getLastColumn();
  const headers = sheet.getRange(1, 1, 1, lastCol).getValues()[0];
  return RESULT_HEADERS.map(function (name) {
    let idx = headers.indexOf(name);
    if (idx === -1) {
      headers.push(name);
      idx = headers.length - 1;
      sheet.getRange(1, idx + 1).setValue(name);
    }
    return idx + 1;
  });
}

function writeResult_(sheet, row, cols, result) {
  sheet.getRange(row, cols[0]).setValue(result.status);
  sheet.getRange(row, cols[1]).setValue(result.reason);
  sheet.getRange(row, cols[2]).setValue(new Date());
}

Why each piece is there

  • e.namedValues maps each question title to its answers, as a list. Google's event object reference shows the shape: {'First Name': ['Jane'], ...}. That is why the code takes [0].
  • e.range is the range the submission wrote to. Its row is the row to update.
  • muteHttpExceptions: true stops UrlFetchApp from throwing when the API returns an error code. Per the UrlFetchApp reference, with it set "the fetch doesn't throw an exception if the response code indicates failure". The script reads the code itself and writes a readable error into the row.
  • 429 and 503 retry with a growing pause. The API returns 429 above 120 calls a minute on one key and 503 when the service is briefly unavailable. Retrying 400, 401 or 402 would not help, so those go straight to the row.
  • Headers looked up by name. When you add a question to the form, the Sheet's columns change. Finding SV Status by its header keeps the script pointed at the right column.

The API waits on the recipient's mail server, so a call can take a few seconds. Our docs suggest a client timeout of about 30 seconds. The UrlFetchApp default is 360 seconds, per the same reference, so no change is needed.

Step 4: Install the trigger

Form-submit events reach the script through an installable trigger. It runs with your authorisation, which the external API call needs.

From the editor: click Triggers in the left panel, then Add Trigger. Choose:

Field Value
Function to run onFormSubmitVerify
Event source From spreadsheet
Event type On form submit

Click Save and approve the permissions prompt. Google's installable triggers guide covers the same steps.

From code: run this once.

function installTrigger() {
  ScriptApp.newTrigger('onFormSubmitVerify')
    .forSpreadsheet(SpreadsheetApp.getActive())
    .onFormSubmit()
    .create();
}

Two details from that guide matter here. Installable triggers "always run under the account of the person who created them", so the person who installs it owns the quota it uses. And if the trigger fails, Apps Script emails the owner a failure summary. Watch for those the first week.

Step 5: Test with four responses

Submit the form yourself four times:

Test answer Expected SV Status Expected SV Reason
An address you own valid Mailbox exists
A made-up address at gmail.com invalid Mailbox not found
An address at a domain that accepts all mail risky Accepts all addresses
info@ at your own company risky Role-based address

Each row should fill within a few seconds. If a row shows error with HTTP 401, the key is wrong. HTTP 402 means the key's subscription is not active.

Step 6: Backfill responses you already have

The trigger only sees new responses. For older rows, add this function and run it from the editor.

/** Verifies rows with an empty SV Status. Re-run until it reports 0 left. */
function backfillVerify() {
  const sheet = SpreadsheetApp.getActive().getSheets()[0]; // the form responses tab
  const cols = resultColumns_(sheet);
  const data = sheet.getDataRange().getValues();
  const emailCol = data[0].indexOf(EMAIL_HEADER);
  if (emailCol === -1) throw new Error('No column called ' + EMAIL_HEADER);

  const started = Date.now();
  let left = 0;
  for (let i = 1; i < data.length; i++) {
    if (data[i][cols[0] - 1]) continue;            // already checked
    if (Date.now() - started > 5 * 60 * 1000) {     // stop before the 6-minute cap
      left++;
      continue;
    }
    writeResult_(sheet, i + 1, cols, verifyEmail_(data[i][emailCol]));
    Utilities.sleep(600);                            // stays under 120 calls a minute
  }
  Logger.log(left + ' rows left. Run again to continue.');
}

Change getSheets()[0] if your responses tab is not the first one. The function stops after five minutes because Google caps script runtime at 6 minutes per execution (quotas). Run it again and it picks up where it stopped, because it skips rows that already have a status.

The Apps Script quotas that limit this

From Google's quotas page:

Quota Consumer account Google Workspace
URL Fetch calls 20,000 / day 100,000 / day
Script runtime 6 min / execution 6 min / execution
Triggers total runtime 90 min / day 6 hr / day

Google adds that quotas are per user and "reset 24 hours after the first request".

For a form, trigger runtime usually binds first. As an illustration, if each check took 3 seconds, 90 minutes of trigger runtime covers about 1,800 responses a day on a consumer account. Real check times vary with the recipient's servers. Most forms never get near that. A form that gets a burst of thousands in a day, such as a giveaway, is better handled with a CSV export at the end.

Worked example: an event registration form

A team runs a webinar with a public registration form. It asks for name and Work email, with response validation set to Text, Email address. The confirmation and reminder emails go out from their email platform, which imports the Sheet.

They set EMAIL_HEADER = 'Work email' and install the trigger. Three responses come in (illustrative):

Work email SV Status SV Reason Action
sam.lee@acme-logistics.com valid Mailbox exists Import and send reminders
priya@northwind.io risky Accepts all addresses Import, but keep out of later cold follow-up
tom@acmelogistics.con invalid No mail server Do not import; sales checks the name by hand

All three passed Google's format check. The third would have bounced every reminder. They add a filter view on SV Status and only import valid and risky rows. For more on handling registrations, see event registration verification.

What to do with each result

Status Meaning Suggested handling
valid The mail server confirmed the mailbox Use normally
risky Might work, not confirmed: catch-all, role address, provider hides status, server would not confirm Use for transactional mail; be careful in cold or bulk follow-up
invalid Bad format, no mail server, disposable, or mailbox not found Do not mail; fix by hand if the rest of the response is useful
error / skipped The check did not run Re-run backfillVerify later

Risky is not one thing. A catch-all domain and a role address call for different handling. Our guide to accept-all vs unknown results breaks the reasons down.

When not to use the script

The script fits forms that collect responses one at a time. Two cases suit other tools:

Common problems

Symptom Likely cause Fix
Columns never appear Trigger not installed, or installed on the wrong function Check the Triggers panel
Every row says skipped EMAIL_HEADER does not match the column header Copy the header text exactly
HTTP 401 in every row Key missing "sv_live_" prefix, or property name wrong Re-add SIMPLEVERIFIER_KEY
HTTP 402 Subscription not active Check the plan; packs do not include API access
Still rate limited Another script or tool shares the key Give each integration its own key
Failure emails from Google Script error, often the missing key Read the error, fix, then run backfillVerify

What it costs

API access on SimpleVerifier comes with Pro at $29.99 a month, with no per-call credits, or Email + Phone at $49.99 a month, which adds live carrier lookups for phone fields. If you only clean a form's responses once in a while, pay-as-you-go packs cost $9 for 2,500 or $19 for 10,000 verifications by CSV upload. See pricing for the details.

The short version

Google Forms checks format, not mailboxes. Link the form to a Sheet, put the API key in Script Properties, and add an installable On form submit trigger that calls the verification API with UrlFetchApp and muteHttpExceptions. Write status, reason and date into columns found by header. Backfill old rows in five-minute runs, and keep the quotas in mind. Use the result to decide who gets your follow-up, since the response itself is already in.

Common questions

Can Google Forms verify that an email address is real?

No. Response validation with 'Text: Email address' only checks the format. The Responder input setting makes people type an address but does not check it. The Verified setting records the respondent's Google Account address, which is real but requires a Google sign-in. To confirm a typed address has a working mailbox, call an email verification API from Apps Script.

Can Apps Script reject a Google Forms response with a bad email?

Not in a reliable way. The form submit trigger runs after the response is recorded, so the respondent has already seen the confirmation page. Use the result to decide what happens next: skip the confirmation email, flag the row, or ask a person to follow up.

Where should I store the API key in Apps Script?

In Script Properties, under Project Settings. The code reads it with PropertiesService.getScriptProperties().getProperty('SIMPLEVERIFIER_KEY'). Anyone with edit access to the script can still see it, so only share the script with people you trust with the key.

How many responses can the script verify per day?

Google's quota page lists 20,000 URL Fetch calls a day for consumer accounts and 100,000 for Google Workspace. Trigger runtime is usually the tighter limit: 90 minutes a day on a consumer account and 6 hours on Workspace. Each check waits on the recipient's mail server, so the real number depends on how fast those servers answer.

Do I need a paid plan to use the API from Apps Script?

Yes. On SimpleVerifier, API access comes with the Pro plan ($29.99 a month) or Email + Phone ($49.99 a month). Pay-as-you-go packs cover CSV uploads only, which still works for Google Forms: download the responses as a CSV and upload it.

Verify unlimited addresses for $29.99/month

Real SMTP mailbox checks. No credits, no per-email fees.

Get Started