Sync a Google Form to Sheets with validation and alerts
Form submissions land in a spreadsheet unchecked, so bad or incomplete entries aren't caught until someone reviews the sheet later.
Google Form submissions can be written to Sheets and checked against validation rules on arrival, with invalid or urgent entries triggering a Gmail alert to the right person, so bad data is caught at submission rather than weeks later. Evenops builds the rules in Apps Script, keeping the sheet clean without a manual review pass.
How does it run?
4 stepsStep 01Trigger
Form submitted
Step 02Write
Response written to Sheets
Step 03Check
Entry checked against validation rules
Step 04Alert
Invalid or urgent entries trigger a Gmail alert
What do you get?
3 outcomes- ✓Bad entries are caught at submission, not weeks later
- ✓Urgent submissions reach someone immediately
- ✓The sheet stays clean without a manual review pass
Which tools does it use?
Time saved
Replaces manual review of form submissions for errors.What does the script do?
An Apps Script that runs each time someone submits your Google Form, checks the new response against rules you write, and records the verdict beside it in the response sheet (OK, a list of problems, or Urgent). It then sends one short email listing every response that needs a person, with a link to each row. Rules cover required answers, email format, number ranges, a pattern such as a phone number, and a fixed list of allowed values. A response containing words you choose, such as urgent, is marked Urgent. An hourly run picks up anything the submit trigger missed. It reports problems; it does not stop a bad response being submitted or change the person's answers.
What do you need first?
- A Google Form already connected to a response sheet (in the Form, open Responses and choose Link to Sheets).
- The ID of that response spreadsheet, and the exact text of the form questions you want to check, because the script finds columns by their heading.
- A shared mailbox for the digest.
- About 25 minutes. No paid add-on.
How do you set it up?
7 stepsCreate the script
Open script.google.com in the account that owns the form and its response sheet, create a project and paste the code below into Code.gs.
Fill in CONFIG
Set SPREADSHEET_ID and SHEET_NAME. In RULES, write one line per question, with column spelled exactly like the sheet heading. Set URGENT_COLUMN and URGENT_WORDS, and ALERT_TO.
Run it once by hand
Pick checkResponses and press Run, then authorise Sheets and email access. Two columns headed Check and Alerted appear to the right of the form's columns, every existing response gets a verdict, and you receive one digest if any were flagged.
Look at the verdicts
Filter the Check column. If a rule flags too much or too little, adjust RULES, clear the Check and Alerted cells for the rows you want re-checked, and run again.
Install the triggers
Pick installTriggers and press Run once. It creates a form-submit trigger on the spreadsheet and an hourly safety-net run, and replaces earlier copies, so running it twice is safe.
Submit a test response
Send a deliberately bad response through the live form and confirm the verdict appears and the digest arrives. Delete the test row afterwards.
Turn on failure emails
In Triggers, open each trigger and set Failure notification settings to notify you immediately.
The script
// Checks Google Form responses as they arrive in a Sheet and emails a digest of the ones that need a person.
// Paste into Code.gs in a new project at script.google.com, edit CONFIG, run installTriggers() once.
const CONFIG = {
SPREADSHEET_ID: 'PASTE_THE_RESPONSES_SPREADSHEET_ID_HERE',
SHEET_NAME: 'Form responses 1',
CHECK_HEADER: 'Check', // columns the script adds to the right of the form's columns
ALERTED_HEADER: 'Alerted',
// column must match a header in the sheet exactly (it is the form question text).
// types: email, number (min, max), pattern (a regular expression), oneOf (values). Leave type out for text.
RULES: [
{ column: 'Name', required: true },
{ column: 'Email', required: true, type: 'email' },
{ column: 'Phone', type: 'pattern', pattern: '^\\+?[0-9 ()-]{8,16}$', hint: 'does not look like a phone number' },
{ column: 'Quantity', type: 'number', min: 1, max: 500 },
{ column: 'Site', required: true, type: 'oneOf', values: ['North', 'South', 'East'] },
],
URGENT_COLUMN: 'Details', // a response whose text contains any of these words is marked Urgent
URGENT_WORDS: ['urgent', 'asap', 'emergency'],
MAX_ROWS_PER_RUN: 300,
MAX_ROWS_IN_DIGEST: 50,
// Stop well before the 6 minute limit. On a gmail.com account the whole day's
// trigger time is 90 minutes, so lower this (for example 60 * 1000) there.
TIME_BUDGET_MS: 4 * 60 * 1000,
ALERT_TO: 'ops@example.com',
ALERT_EVERY_MS: 6 * 60 * 60 * 1000,
};
const EMAIL_PATTERN = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
function checkResponses() {
const lock = LockService.getScriptLock();
// If two submissions land together the second run may find the lock busy and leave.
// The hourly run installed below picks up whatever it skipped.
if (!lock.tryLock(30 * 1000)) return;
const started = Date.now();
const problems = [];
try {
const ss = SpreadsheetApp.openById(CONFIG.SPREADSHEET_ID);
const sheet = ss.getSheetByName(CONFIG.SHEET_NAME);
if (!sheet) throw new Error('There is no tab called ' + CONFIG.SHEET_NAME);
const lastRow = sheet.getLastRow();
if (lastRow < 2) return;
// One read of the whole response tab, then everything happens in memory.
const grid = sheet.getRange(1, 1, lastRow, sheet.getLastColumn()).getDisplayValues();
const headers = grid[0].slice();
const added = [];
let checkCol = headers.indexOf(CONFIG.CHECK_HEADER);
if (checkCol < 0) { checkCol = headers.length; headers.push(CONFIG.CHECK_HEADER); added.push(CONFIG.CHECK_HEADER); }
let alertedCol = headers.indexOf(CONFIG.ALERTED_HEADER);
if (alertedCol < 0) { alertedCol = headers.length; headers.push(CONFIG.ALERTED_HEADER); added.push(CONFIG.ALERTED_HEADER); }
if (added.length) sheet.getRange(1, headers.length - added.length + 1, 1, added.length).setValues([added]);
const rules = prepareRules_(headers, problems);
const urgentCol = headers.indexOf(CONFIG.URGENT_COLUMN);
if (urgentCol < 0) problems.push('Urgent column not found in the sheet: ' + CONFIG.URGENT_COLUMN);
const rows = grid.slice(1).map(function (r) {
const row = r.slice();
while (row.length < headers.length) row.push('');
return row;
});
// Pass 1: check every response that has no result yet.
let checked = 0;
let changed = false;
for (let i = 0; i < rows.length; i++) {
const row = rows[i];
if (row[checkCol] !== '') continue;
if (checked >= CONFIG.MAX_ROWS_PER_RUN || Date.now() - started > CONFIG.TIME_BUDGET_MS) break; // the next run carries on
row[checkCol] = verdict_(row, rules, urgentCol);
checked++;
changed = true;
}
if (changed) writeColumn_(sheet, checkCol, rows);
// Pass 2: one digest for everything flagged and not yet reported.
const pending = [];
rows.forEach(function (row, i) {
if (row[checkCol] !== '' && row[checkCol] !== 'OK' && row[alertedCol] === '') pending.push(i);
});
if (pending.length) {
sendDigest_(ss, sheet, rows, pending, checkCol);
const stamp = Utilities.formatDate(new Date(), Session.getScriptTimeZone(), 'yyyy-MM-dd HH:mm');
pending.forEach(function (i) { rows[i][alertedCol] = stamp; });
writeColumn_(sheet, alertedCol, rows); // only after the email went out
}
} catch (err) {
problems.push('Run failed: ' + err.message);
} finally {
lock.releaseLock();
}
alertOnce_(problems);
}
function prepareRules_(headers, problems) {
const usable = [];
CONFIG.RULES.forEach(function (rule) {
const col = headers.indexOf(rule.column);
if (col < 0) { problems.push('A rule refers to a column that is not in the sheet: ' + rule.column); return; }
usable.push({ rule: rule, col: col, regex: rule.type === 'pattern' ? new RegExp(rule.pattern) : null });
});
return usable;
}
function verdict_(row, rules, urgentCol) {
const issues = [];
rules.forEach(function (r) {
const value = String(row[r.col]).trim();
const label = r.rule.column;
if (value === '') {
if (r.rule.required) issues.push(label + ' is empty');
return;
}
if (r.rule.type === 'email' && !EMAIL_PATTERN.test(value)) issues.push(label + ' is not a valid email');
if (r.rule.type === 'pattern' && !r.regex.test(value)) issues.push(label + ' ' + (r.rule.hint || 'is in the wrong format'));
if (r.rule.type === 'number') {
const n = Number(value.replace(/,/g, ''));
if (!isFinite(n)) issues.push(label + ' is not a number');
else if ((r.rule.min !== undefined && n < r.rule.min) || (r.rule.max !== undefined && n > r.rule.max)) {
issues.push(label + ' is outside ' + r.rule.min + ' to ' + r.rule.max);
}
}
if (r.rule.type === 'oneOf') {
const ok = r.rule.values.some(function (v) { return v.toLowerCase() === value.toLowerCase(); });
if (!ok) issues.push(label + ' is not one of the expected values');
}
});
let urgent = false;
if (urgentCol >= 0) {
const text = String(row[urgentCol]).toLowerCase();
urgent = CONFIG.URGENT_WORDS.some(function (w) { return text.indexOf(w.toLowerCase()) !== -1; });
}
if (urgent) issues.unshift('Urgent');
if (!issues.length) return 'OK';
return urgent ? issues.join('; ') : 'Problem: ' + issues.join('; ');
}
// Writes one script-owned column back in a single call.
function writeColumn_(sheet, col, rows) {
sheet.getRange(2, col + 1, rows.length, 1).setValues(rows.map(function (r) { return [safe_(r[col])]; }));
}
function sendDigest_(ss, sheet, rows, pending, checkCol) {
const shown = pending.slice(0, CONFIG.MAX_ROWS_IN_DIGEST);
const base = ss.getUrl() + '#gid=' + sheet.getSheetId();
const items = shown.map(function (i) {
const number = i + 2; // row 1 is the header
return '<li>Row <a href="' + esc_(base + '&range=A' + number) + '">' + number + '</a>: ' +
esc_(rows[i][0]) + ' - ' + esc_(rows[i][checkCol]) + '</li>';
}).join('');
const more = pending.length > shown.length
? '<p>And ' + (pending.length - shown.length) + ' more in the sheet.</p>'
: '';
MailApp.sendEmail({
to: CONFIG.ALERT_TO,
subject: 'Form responses to look at (' + pending.length + ')',
body: pending.length + ' form responses need a look. Open the sheet and filter the Check column: ' + ss.getUrl(),
htmlBody: '<p>' + pending.length + ' form responses need a look.</p><ul>' + items + '</ul>' + more,
});
}
function esc_(value) {
return String(value)
.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>')
.replace(/"/g, '"').replace(/'/g, ''');
}
// Stops text typed by a stranger, like "=IMPORTXML(...)", running as a formula.
function safe_(value) {
const s = String(value);
return /^[=+@\t\r-]/.test(s) ? "'" + s : s;
}
// Keeps the held-back problems in a script property. Oldest entries are dropped first if the text
// would get long (a property value is limited to about 9 KB), and a failure to store never fails the run.
function saveQueue_(props, queue) {
try {
while (queue.length > 1 && JSON.stringify(queue).length > 6000) queue.shift();
props.setProperty('pendingProblems', JSON.stringify(queue));
} catch (e) { /* the problems are lost only if the store itself is down */ }
}
// Emails the problems to ALERT_TO, at most once every ALERT_EVERY_MS. Problems found while an email is
// being held back are kept in a script property and go out with the next one, so each is reported.
function alertOnce_(problems, force) {
const props = PropertiesService.getScriptProperties();
let queue = [];
try { queue = JSON.parse(props.getProperty('pendingProblems') || '[]'); } catch (e) { queue = []; }
(problems || []).forEach(function (p) {
const text = String(p).slice(0, 160);
if (queue.indexOf(text) === -1) queue.push(text);
});
queue = queue.slice(-30);
if (!queue.length) return;
const last = Number(props.getProperty('lastAlertAt') || 0);
if (!force && Date.now() - last < CONFIG.ALERT_EVERY_MS) {
saveQueue_(props, queue);
return;
}
try {
MailApp.sendEmail(CONFIG.ALERT_TO, 'Form checks need attention (' + queue.length + ')', queue.join('\n'));
props.setProperty('lastAlertAt', String(Date.now()));
props.deleteProperty('pendingProblems');
} catch (err) {
saveQueue_(props, queue); // keep them for the next try
}
}
// Run once by hand. Safe to run again: it replaces the old triggers.
function installTriggers() {
ScriptApp.getProjectTriggers()
.filter(function (t) { return t.getHandlerFunction() === 'checkResponses'; })
.forEach(function (t) { ScriptApp.deleteTrigger(t); });
ScriptApp.newTrigger('checkResponses').forSpreadsheet(CONFIG.SPREADSHEET_ID).onFormSubmit().create();
ScriptApp.newTrigger('checkResponses').timeBased().everyHours(1).create(); // safety net
}
Every Apps Script method in this script was checked against Google's Apps Script reference (developers.google.com/apps-script/reference) on 11/10/2026. Limits come from Google's quota table, checked the same day. The event fields and the trigger restrictions were checked against Google's triggers guide the same day.
What should you know before relying on it?
9 caveats- Quotas
- Google's quota table lists a 6 minute limit per execution, 20 triggers per user per script, and a total trigger runtime of 90 minutes a day on a gmail.com account or 6 hours a day on Google Workspace. Two kinds of run use that trigger-runtime budget: one for every form submission, and 24 hourly safety-net runs. At 200 submissions a day that is 224 runs, which on a gmail.com account works out to an average of about 24 seconds per run and on Workspace about 96. Each run reads the response tab once and checks only responses that have no verdict, so a typical run is short. Open Executions in the Apps Script editor to see how long your runs really take. The digest is one email per run that finds flagged responses, not one per response, which keeps well inside the recipient limits (100 a day on gmail.com, 1,500 on Workspace).
- Submit events can be missed
- If two responses arrive together, the second run may find the lock busy and leave. Nothing is lost: it checks for responses without a verdict, not just the one in the event, and the hourly run catches anything left. Google also says a trigger does not run for responses created by a script, so do not use scripted test submissions.
- It cannot stop a submission
- The check happens after the response is saved. To block bad answers at the source, use the form's own response validation for the simple cases and keep this script for the rules a form cannot express.
- Idempotency
- A response is checked once, because the verdict is saved beside it, and reported once, because the Alerted time is saved only after the digest email has been sent. If the email fails, the next run tries again. To re-check or re-report a row, clear its Check or Alerted cell.
- Stays fast as the sheet grows
- Each run reads the whole response tab in one call, works through it in memory, and writes the verdict column in one call. It never looks up the sheet once per response. MAX_ROWS_PER_RUN and the time budget stop a very large backlog from running into the six minute limit; the next run carries on.
- Escaping
- Everything taken from a response is escaped before it goes into the HTML digest, so a response containing angle brackets shows as text. Anything written back to the sheet that starts with an equals, plus, minus or at sign is prefixed with an apostrophe so it cannot run as a formula.
- Keep the headings stable
- Rules find columns by heading. If you reword a question in the form, update the matching rule, and look at the sheet after editing the form to make sure the Check and Alerted columns are still where you expect. A rule that points at a missing heading is reported to ALERT_TO instead of being skipped silently.
- Do not sort the response tab
- Verdicts sit beside each response. Sorting the tab while the script runs can separate them. Use a filter view or copy the data to another tab instead.
- Who gets alerted
- The digest and any run failure go to ALERT_TO. Failures are emailed at most once every 6 hours. Problems found while an alert is being held back are kept and sent with the next one, so each is reported once. Use a shared mailbox, and install the script from a dedicated shared Workspace user, because installable triggers run as the account that created them.
When should you call us?
Checks like these suit a form with a handful of rules and one person watching the digest. Call us when answers need checking against another list (customers, staff, stock), when the right person to alert depends on the answer, when responses should create records in another system, or when you need proof that every response was handled. We build that with logging, alerting and a runbook.
Tell us what you needContinues · Want this wired into your stack?
Want this wired into your stack?
Tell us your tools and the task. We'll scope it and build it as production code you own.
We typically respond within 24 hours
