A Google Form's built-in response spreadsheet works fine for basic collection, but anything beyond storing raw answers, like sending a confirmation email or reformatting a response before it lands in the sheet, needs a form submit trigger with custom code behind it.
This tutorial registers an installable onFormSubmit trigger tied to a specific form, which fires the onFormSubmitHandler function every time someone completes the form, whether or not the form's linked spreadsheet is the active one running the script.
The handler reads each answer from the submission event's item responses, builds a row combining a timestamp and the respondent's email with every answer in order, and appends that row to a Form Responses tab.
It also sends a short confirmation email back to the respondent when the form is configured to collect email addresses, demonstrating how a form trigger can close the loop with the person who just submitted rather than only recording their answer.
Need this built? Hire a Google Apps Script developer →
Sheet / project setup
| Piece | Value | Purpose |
|---|---|---|
| Handler | onFormSubmitInstallable | Installable form submit |
| Responses sheet | Form Responses 1 | Source row |
| Queue sheet | Intake Queue | Normalized copy |
What it does
onFormSubmitHandler receives a form submit event, extracts the respondent's email and every item response in the order the questions were answered, and appends the combined row to a Form Responses sheet.
Prerequisites
e.response.getItemResponses() returns one ItemResponse object per question, and calling getResponse() on each one extracts the actual text or choice the respondent selected, in the same order the questions appear on the form.
Walkthrough
Call createFormSubmitTrigger with the target form's ID once, which registers the trigger through forForm rather than forSpreadsheet, and approve the authorization prompt that appears on first run.
Edge cases
removeExistingFormSubmitTriggers loops over every project trigger and deletes any prior one pointed at onFormSubmitHandler, following the same duplicate-prevention pattern used for the daily time-based trigger elsewhere in this series.
Testing
getRespondentEmail returns an empty string rather than throwing when the form does not collect emails, so the confirmation email block is guarded with a truthy check instead of assuming an email is always present.
Hardening
Submit a real test response through the live form rather than running the handler manually with a fabricated event object, since Apps Script cannot easily simulate a genuine FormResponse object for a manual test run.
Variations
If the form is later edited to add or remove questions, the fixed-order row this handler builds will shift, so a production version should map answers by question title rather than by position to stay stable across form changes.
Full code: createFormSubmitTrigger() and onFormSubmitHandler()
Call createFormSubmitTrigger once with the target form's ID, then submit a real test response to confirm the Form Responses sheet and confirmation email both work.
function createFormSubmitTrigger(formId) {
var form = FormApp.openById(formId);
removeExistingFormSubmitTriggers();
ScriptApp.newTrigger('onFormSubmitHandler')
.forForm(form)
.onFormSubmit()
.create();
}
function removeExistingFormSubmitTriggers() {
ScriptApp.getProjectTriggers().forEach(function (trigger) {
if (trigger.getHandlerFunction() === 'onFormSubmitHandler') {
ScriptApp.deleteTrigger(trigger);
}
});
}
function onFormSubmitHandler(e) {
var responses = e.response.getItemResponses();
var sheet = SpreadsheetApp.getActive().getSheetByName('Form Responses') || SpreadsheetApp.getActive().insertSheet('Form Responses');
var row = [new Date(), e.response.getRespondentEmail() || ''];
responses.forEach(function (itemResponse) {
row.push(itemResponse.getResponse());
});
sheet.appendRow(row);
var email = e.response.getRespondentEmail();
if (email) {
MailApp.sendEmail(email, 'Thanks for your submission', 'We received your response and will follow up shortly.');
}
}- Line 2: Opens the target form by ID before registering the trigger.
- Line 5: Binds the trigger to the form rather than the spreadsheet.
- Line 18: Reads every item response from the submission event.
- Line 21: Falls back to an empty string when the form doesn't collect email.
- Line 22: Appends each answer to the row in the order questions were asked.
- Line 28: Guards the confirmation email with a truthy check on the respondent's email.
Deploy this example
- 01
Open Apps Script
In the bound spreadsheet: Extensions → Apps Script. For standalone projects, create one at script.google.com and link your Sheet by ID.
- 02
Paste and save
Add a .gs file, paste the code below, rename constants at the top (sheet names, column letters, API property keys), then save.
- 03
Authorize once
Run the main function from the editor. Accept OAuth scopes when prompted — triggers cannot run until authorization succeeds once.
- 04
Add the trigger
Triggers → Add trigger → choose the handler function and event (time-driven, on edit, or on form submit). Delete test triggers before production.
Before you run: handle form submissions
- 1Target form's ID passed correctly to createFormSubmitTrigger
- 2Trigger registered through forForm rather than forSpreadsheet
- 3Existing onFormSubmitHandler triggers removed before creating a new one
- 4Form configured to collect respondent email if confirmation email is needed
- 5Form Responses sheet exists with a matching column layout
- 6Live test submission confirmed to append a correct row
- 7Answer mapping reviewed for stability against future form edits
Frequently asked questions
forForm binds directly to the Form object's own submit event, which fires reliably even if the form's response spreadsheet is not the same file running this script, unlike a spreadsheet-bound onFormSubmit trigger.
Not realistically, because the event object a manual run provides has no response data; the reliable way to test is submitting the actual form and checking the resulting row.
getRespondentEmail returns an empty string in that case, and the handler's truthy check skips sending a confirmation email instead of failing.
Read each ItemResponse's getItem().getTitle() to get the question text, then write answers into a row based on a lookup by title instead of relying on the fixed order getItemResponses() returns.
Yes, but each one should point at a different handler function; this tutorial's duplicate-removal logic only manages triggers pointed at onFormSubmitHandler specifically, so unrelated triggers on the same form are left alone.
Yes, installable triggers tied to a form are removed automatically if the underlying form is deleted, since the trigger has nothing left to attach to.