Google Apps Script Google Forms Automation: Process Responses, Send Emails, and Route Data (2026)

Google Apps Script Google Forms Automation: Process Responses, Send Emails, and Route Data (2026)

One thing to watch: checkbox questions return arrays, not strings. If your form has "Select all that apply" questions, the response for that item will be something like ["Option A", "Option C"]. Handle that in your downstream logic.


Recipe 1: Send a Custom Confirmation Email on Submit

The built-in "response receipt" in Google Forms is bare-bones. It just dumps the answers back at the respondent. Here's how to send something that actually looks professional.

function sendConfirmationEmail(e) {
  const data = getResponseMap(e);
  const recipientEmail = e.response.getRespondentEmail();
  
  if (!recipientEmail) {
    Logger.log('No respondent email collected. Skipping.');
    return;
  }

  const subject = `Thanks for your submission, ${data['Full Name']}`;
  const htmlBody = `
    <h2>We received your request</h2>
    <p>Hi ${data['Full Name']},</p>
    <p>We got your form submission for <strong>${data['Service Type']}</strong> 
    and will follow up within 24 hours.</p>
    <p>Reference: ${e.response.getId()}</p>
  `;

  GmailApp.sendEmail(recipientEmail, subject, '', {
    htmlBody: htmlBody,
    name: 'Your Company Name'
  });
}

A few notes. First, getRespondentEmail() only works if you've turned on "Collect email addresses" in the form settings. Second, GmailApp.sendEmail has a daily quota: 100 emails for free accounts, 1,500 for Google Workspace. If your form gets more traffic than that, you'll need to batch or use a transactional email service via UrlFetchApp.

The e.response.getId() call gives you a unique response ID you can use as a reference number. Handy for support forms.


Recipe 2: Route Responses to Different Sheets Based on Answers

Say you have a form where people select a department: Sales, Support, or Engineering. You want each department's responses landing in a separate spreadsheet tab.

function routeToSheet(e) {
  const data = getResponseMap(e);
  const department = data['Department'];
  const ss = SpreadsheetApp.openById('YOUR_SPREADSHEET_ID');
  
  let sheet = ss.getSheetByName(department);
  if (!sheet) {
    sheet = ss.insertSheet(department);
    sheet.appendRow(['Timestamp', 'Name', 'Email', 'Department', 'Message']);
  }
  
  sheet.appendRow([
    e.response.getTimestamp(),
    data['Full Name'],
    data['Email'],
    department,
    data['Message']
  ]);
}

This creates the sheet on the fly if it doesn't exist yet. So you can add new department options to your form without touching the script.

But here's the thing people miss. If you're routing responses, you probably also want to notify someone. Combine this with Recipe 1:

const DEPT_CONTACTS = {
  'Sales': 'sales-lead@company.com',
  'Support': 'support-queue@company.com',
  'Engineering': 'eng-manager@company.com'
};

function routeAndNotify(e) {
  const data = getResponseMap(e);
  const dept = data['Department'];
  
  routeToSheet(e);
  
  const notify = DEPT_CONTACTS[dept];
  if (notify) {
    GmailApp.sendEmail(notify, `New ${dept} form submission`, 
      `From: ${data['Full Name']}\n\n${data['Message']}`);
  }
}

Recipe 3: Create a Google Doc or PDF From Each Response

This one is gold for intake forms, applications, and any workflow where you need a formatted record of each submission.

function createResponseDoc(e) {
  const data = getResponseMap(e);
  const template = DriveApp.getFileById('YOUR_TEMPLATE_DOC_ID');
  const folder = DriveApp.getFolderById('YOUR_OUTPUT_FOLDER_ID');
  
  const docName = `Submission - ${data['Full Name']} - ${Utilities.formatDate(
    e.response.getTimestamp(), 'America/New_York', 'yyyy-MM-dd'
  )}`;
  
  const copy = template.makeCopy(docName, folder);
  const doc = DocumentApp.openById(copy.getId());
  const body = doc.getBody();
  
  // Replace placeholder tags in template
  body.replaceText('{{name}}', data['Full Name']);
  body.replaceText('{{email}}', data['Email']);
  body.replaceText('{{service}}', data['Service Type']);
  body.replaceText('{{details}}', data['Details']);
  
  doc.saveAndClose();
  
  // Optional: convert to PDF
  const pdf = DriveApp.getFileById(copy.getId())
    .getAs('application/pdf');
  folder.createFile(pdf).setName(docName + '.pdf');
}

Create a Google Doc template first with {{placeholder}} tags wherever you want form data inserted. The script copies the template, swaps in the values, and optionally exports a PDF.

Real talk: the replaceText approach is straightforward but fragile. If someone edits the template and accidentally adds a space inside {{ name }}, the replacement silently fails. I usually add a validation step that checks whether all placeholders were actually replaced.


Recipe 4: Post Form Responses to Slack Automatically

If your team lives in Slack, piping form submissions into a channel saves everyone from checking a spreadsheet.

function postToSlack(e) {
  const data = getResponseMap(e);
  const webhookUrl = PropertiesService.getScriptProperties()
    .getProperty('SLACK_WEBHOOK_URL');
  
  const payload = {
    blocks: [
      {
        type: 'header',
        text: {
          type: 'plain_text',
          text: 'New Form Submission'
        }
      },
      {
        type: 'section',
        fields: [
          { type: 'mrkdwn', text: `*Name:*\n${data['Full Name']}` },
          { type: 'mrkdwn', text: `*Email:*\n${data['Email']}` },
          { type: 'mrkdwn', text: `*Type:*\n${data['Request Type']}` }
        ]
      }
    ]
  };

  UrlFetchApp.fetch(webhookUrl, {
    method: 'post',
    contentType: 'application/json',
    payload: JSON.stringify(payload)
  });
}
๐Ÿ’ก
Store your webhook URL in Script Properties (File > Project Properties > Script Properties), not hardcoded in the source. This keeps secrets out of version history and makes it easy to swap environments.

Two things to note. First, Slack's Block Kit formatting gives you much nicer messages than plain text. The fields layout puts data side by side, which is easier to scan. Second, UrlFetchApp.fetch is synchronous. If Slack's API is slow (rare, but it happens), your trigger execution time goes up. For high-volume forms, consider writing to a queue sheet and processing Slack notifications in a separate time-driven trigger.


Recipe 5: Build an Approval Workflow for Form Submissions

This is where things get interesting. You can build a lightweight approval system entirely inside Google Workspace, no external tools required.

The flow: form submission arrives, script sends an email to the approver with "Approve" and "Reject" links, approver clicks a link, a web app endpoint processes the decision and updates a tracking sheet.

function requestApproval(e) {
  const data = getResponseMap(e);
  const responseId = e.response.getId();
  const approverEmail = 'manager@company.com';
  
  // Log the pending approval
  const ss = SpreadsheetApp.openById('YOUR_TRACKING_SHEET_ID');
  const sheet = ss.getSheetByName('Approvals');
  sheet.appendRow([
    responseId, data['Full Name'], data['Request'], 
    'PENDING', new Date(), ''
  ]);
  
  // Build approval links
  const scriptUrl = ScriptApp.getService().getUrl();
  const approveUrl = `${scriptUrl}?action=approve&id=${responseId}`;
  const rejectUrl = `${scriptUrl}?action=reject&id=${responseId}`;
  
  GmailApp.sendEmail(approverEmail, 
    `Approval needed: ${data['Full Name']} - ${data['Request']}`,
    '',
    {
      htmlBody: `
        <p><strong>${data['Full Name']}</strong> submitted a request:</p>
        <p>${data['Request']}</p>
        <p>${data['Details']}</p>
        <br>
        <a href="${approveUrl}" style="background:#22c55e;color:white;padding:10px 20px;text-decoration:none;border-radius:4px;">Approve</a>
        &nbsp;
        <a href="${rejectUrl}" style="background:#ef4444;color:white;padding:10px 20px;text-decoration:none;border-radius:4px;">Reject</a>
      `
    }
  );
}

And the web app handler:

function doGet(req) {
  const action = req.parameter.action;
  const responseId = req.parameter.id;
  
  const ss = SpreadsheetApp.openById('YOUR_TRACKING_SHEET_ID');
  const sheet = ss.getSheetByName('Approvals');
  const rows = sheet.getDataRange().getValues();
  
  for (let i = 1; i < rows.length; i++) {
    if (rows[i][0] === responseId) {
      sheet.getRange(i + 1, 4).setValue(action.toUpperCase());
      sheet.getRange(i + 1, 6).setValue(new Date());
      break;
    }
  }
  
  return HtmlService.createHtmlOutput(
    `<h2>Request ${action}d</h2><p>The response has been recorded.</p>`
  );
}

To make this work, you need to deploy the script as a web app (Deploy > New Deployment > Web app). Set "Execute as" to yourself and "Who has access" to "Anyone." The approval links will work without requiring the approver to be logged into a Google account.


Error Handling and Logging for Google Forms Automations

Form triggers run in the background. When they fail, nobody sees the error unless you've set up logging.

The minimum viable error handling:

function handleSubmission(e) {
  try {
    sendConfirmationEmail(e);
    routeToSheet(e);
    postToSlack(e);
  } catch (error) {
    Logger.log(`Trigger failed: ${error.message}`);
    Logger.log(`Stack: ${error.stack}`);
    
    // Send yourself an alert
    GmailApp.sendEmail(
      'you@company.com',
      'Form automation error',
      `Error: ${error.message}\nTimestamp: ${new Date()}\nStack: ${error.stack}`
    );
  }
}

Logger.log writes to the Apps Script execution log, which you can view in the Executions panel (left sidebar in the script editor). But here's the problem: logs are only retained for 7 days. For anything production-critical, email yourself on failure or write errors to a dedicated "Error Log" sheet.

๐Ÿ’ก
If you need structured logging, write errors to a Google Sheet with columns for timestamp, function name, error message, and response ID. This gives you a queryable error history that doesn't disappear after a week.

Testing Your Form Automation Without Spamming Submissions

Testing form triggers is annoying. You have to submit a real response every time, which pollutes your response sheet and can fire off emails to real people.

Here's what actually works:

1. Build a test harness with a fake event object

function testTrigger() {
  const fakeEvent = {
    response: FormApp.getActiveForm().getResponses().pop(),
    values: ['2026-04-12', 'Test User', 'test@example.com', 'Engineering', 'Test message']
  };
  
  handleSubmission(fakeEvent);
}

This grabs the most recent real response and replays it through your handler. You only need one real submission, then you can iterate on the script using this test function.

2. Use a staging form

Clone your production form, point it at a test spreadsheet, and set up triggers on the clone. All your development happens against the copy. When you're confident it works, move the script to the production form.

3. Guard against accidental production runs

const IS_TEST_MODE = true; // Flip to false for production

function sendEmail(to, subject, body, options) {
  if (IS_TEST_MODE) {
    Logger.log(`[TEST] Would send to ${to}: ${subject}`);
    return;
  }
  GmailApp.sendEmail(to, subject, body, options);
}

Wrapping GmailApp.sendEmail in a test-aware function means you can run the full pipeline without accidentally emailing customers.


Performance Tips for High-Volume Forms

Most form automations handle a few hundred responses per day and performance doesn't matter. But if you're running a public-facing form that gets thousands of submissions, you need to think about execution time and quotas.

Batch your spreadsheet writes. Every call to sheet.appendRow() or sheet.getRange().setValue() is a network round trip. If your script does five separate writes, that's five round trips per trigger execution. Use sheet.getRange(row, col, numRows, numCols).setValues(data) to write everything in one call.

Cache frequently accessed data. If your routing logic reads a config sheet to look up department contacts, use CacheService to avoid hitting the spreadsheet on every trigger:

function getDeptContacts() {
  const cache = CacheService.getScriptCache();
  let contacts = cache.get('dept_contacts');
  
  if (!contacts) {
    const sheet = SpreadsheetApp.openById('CONFIG_SHEET_ID')
      .getSheetByName('Contacts');
    const data = sheet.getDataRange().getValues();
    contacts = JSON.stringify(data);
    cache.put('dept_contacts', contacts, 3600); // Cache for 1 hour
  }
  
  return JSON.parse(contacts);
}

Watch your quotas. Apps Script has daily limits: 100 emails for free accounts (1,500 for Workspace), 20,000 UrlFetchApp calls, 6 minutes max execution time per trigger. The execution time limit is the one that catches people off guard. If your trigger does email + Slack + doc generation + sheet routing, those API calls add up. Keep each trigger handler under 30 seconds to leave margin.

Use separate triggers for independent tasks. Instead of one monolithic handler, split your automation into focused functions with their own triggers. This way, if the Slack notification fails, the confirmation email still goes out.


Complete Code: Copy-Paste Starter Template

Here's a full starter template that combines the patterns from this article. Fork it and strip out what you don't need.

/**
 * Google Forms Automation Starter Template
 * Handles: confirmation emails, sheet routing, Slack notifications
 * 
 * Setup:
 * 1. Open your form's script editor (three-dot menu > Script editor)
 * 2. Paste this code
 * 3. Run createFormTrigger() once to set up the installable trigger
 * 4. Set SLACK_WEBHOOK_URL in Script Properties
 */

// === CONFIGURATION ===
const CONFIG = {
  ROUTING_SHEET_ID: 'your-spreadsheet-id-here',
  ADMIN_EMAIL: 'you@company.com',
  ENABLE_SLACK: true,
  ENABLE_EMAIL_CONFIRMATION: true,
  ENABLE_ROUTING: true
};

// === TRIGGER SETUP (run once) ===
function createFormTrigger() {
  // Delete existing triggers to avoid duplicates
  const triggers = ScriptApp.getProjectTriggers();
  triggers.forEach(t => {
    if (t.getHandlerFunction() === 'onSubmit') {
      ScriptApp.deleteTrigger(t);
    }
  });
  
  ScriptApp.newTrigger('onSubmit')
    .forForm(FormApp.getActiveForm())
    .onFormSubmit()
    .create();
  
  Logger.log('Trigger created.');
}

// === UTILITIES ===
function getResponseMap(e) {
  const map = {};
  e.response.getItemResponses().forEach(item => {
    map[item.getItem().getTitle()] = item.getResponse();
  });
  return map;
}

// === MAIN HANDLER ===
function onSubmit(e) {
  const data = getResponseMap(e);
  const errors = [];

  if (CONFIG.ENABLE_EMAIL_CONFIRMATION) {
    try { sendConfirmation(e, data); }
    catch (err) { errors.push(`Email: ${err.message}`); }
  }

  if (CONFIG.ENABLE_ROUTING) {
    try { routeResponse(e, data); }
    catch (err) { errors.push(`Routing: ${err.message}`); }
  }

  if (CONFIG.ENABLE_SLACK) {
    try { notifySlack(data); }
    catch (err) { errors.push(`Slack: ${err.message}`); }
  }

  if (errors.length > 0) {
    GmailApp.sendEmail(CONFIG.ADMIN_EMAIL, 'Form automation errors',
      `Errors during processing:\n\n${errors.join('\n')}\n\nResponse ID: ${e.response.getId()}`
    );
  }
}

// === CONFIRMATION EMAIL ===
function sendConfirmation(e, data) {
  const email = e.response.getRespondentEmail();
  if (!email) return;
  
  GmailApp.sendEmail(email, 'Thanks for your submission', '', {
    htmlBody: `<p>Hi ${data['Full Name'] || 'there'},</p>
               <p>We received your submission and will be in touch soon.</p>
               <p>Reference: ${e.response.getId()}</p>`,
    name: 'Your Company'
  });
}

// === SHEET ROUTING ===
function routeResponse(e, data) {
  const ss = SpreadsheetApp.openById(CONFIG.ROUTING_SHEET_ID);
  const dept = data['Department'] || 'General';
  let sheet = ss.getSheetByName(dept);
  
  if (!sheet) {
    sheet = ss.insertSheet(dept);
    const headers = Object.keys(data);
    headers.unshift('Timestamp');
    sheet.appendRow(headers);
  }
  
  const row = Object.values(data);
  row.unshift(e.response.getTimestamp());
  sheet.appendRow(row);
}

// === SLACK NOTIFICATION ===
function notifySlack(data) {
  const url = PropertiesService.getScriptProperties()
    .getProperty('SLACK_WEBHOOK_URL');
  if (!url) return;
  
  const text = Object.entries(data)
    .map(([key, val]) => `*${key}:* ${val}`)
    .join('\n');
  
  UrlFetchApp.fetch(url, {
    method: 'post',
    contentType: 'application/json',
    payload: JSON.stringify({ text: text })
  });
}

To get started: open your Google Form, go to the three-dot menu, click "Script editor," paste this template, update the CONFIG object with your IDs, and run createFormTrigger() once. That's it. Every new submission will flow through your automation pipeline.

From there, add recipes as needed. The modular structure means you can plug in document generation, approval workflows, or any other handler without touching the core logic.

โ† Back to Blog