ript Handler
Apps Script exposes web endpoints through the doPost(e) function. When a form submits via POST, Google routes the request to this handler. The payload is automatically parsed into e.parameter when the request uses multipart/form-data or application/x-www-form-urlencoded.
/**
* Handles incoming POST requests from client-side forms.
* Validates payload structure, appends data to the target sheet,
* and returns a standardized JSON response.
*/
function handleFormSubmission(request) {
const LOCK_TIMEOUT_MS = 5000;
const lock = LockService.getScriptLock();
try {
lock.waitLock(LOCK_TIMEOUT_MS);
const sheet = SpreadsheetApp.getActiveSpreadsheet().getSheetByName('InboundLeads');
if (!sheet) {
throw new Error('Target sheet "InboundLeads" not found.');
}
const payload = request.parameter;
// Validate required fields before writing
const requiredFields = ['applicantName', 'contactAddress', 'projectScope'];
const missingFields = requiredFields.filter(field => !payload[field]);
if (missingFields.length > 0) {
throw new Error(`Missing required fields: ${missingFields.join(', ')}`);
}
// Generate a deterministic submission ID for idempotency tracking
const submissionId = Utilities.getUuid();
const timestamp = new Date().toISOString();
// Append row: Timestamp, Name, Email, Scope, ID
sheet.appendRow([
timestamp,
String(payload.applicantName).trim(),
String(payload.contactAddress).trim(),
String(payload.projectScope).trim(),
submissionId
]);
return ContentService.createTextOutput(
JSON.stringify({
status: 'accepted',
submissionId: submissionId,
message: 'Record appended successfully.'
})
).setMimeType(ContentService.MimeType.JSON);
} catch (executionError) {
Logger.log('Ingestion failure: %s', executionError.message);
return ContentService.createTextOutput(
JSON.stringify({
status: 'rejected',
error: executionError.message
})
).setMimeType(ContentService.MimeType.JSON);
} finally {
lock.releaseLock();
}
}
Architecture Rationale:
LockService prevents race conditions when multiple submissions arrive simultaneously. Sheets appendRow is not atomic across concurrent requests.
- Explicit field validation fails fast, preventing malformed rows from corrupting the dataset.
Utilities.getUuid() generates a unique identifier per submission, enabling downstream deduplication or audit trails.
ContentService is mandatory. Apps Script web apps must return plain text or JSON; returning HTML or undefined causes deployment errors.
Step 3: Deploy as a Web App
- Open the Apps Script editor from your spreadsheet (
Extensions > Apps Script).
- Paste the handler code and save.
- Click
Deploy > New deployment.
- Select
Web app as the deployment type.
- Configure execution identity:
Execute as: Me (your Google account).
- Configure access:
Who has access: Anyone. This allows unauthenticated POST requests from any origin.
- Complete the OAuth authorization flow. Google will display a security warning because the script requests access to your Drive/Sheets. Proceed through
Advanced > Go to [Project Name] (unsafe) > Allow.
- Copy the generated
Web App URL. This is your ingestion endpoint.
Step 4: Client-Side Integration
Modern browsers handle form submissions natively, but using fetch with FormData provides better control over loading states, error handling, and response parsing.
const INGESTION_ENDPOINT = 'https://script.google.com/macros/s/YOUR_DEPLOYMENT_ID/exec';
async function submitLeadForm(formElement) {
const submitButton = formElement.querySelector('[type="submit"]');
const originalLabel = submitButton.textContent;
try {
submitButton.disabled = true;
submitButton.textContent = 'Processing...';
const formData = new FormData(formElement);
const response = await fetch(INGESTION_ENDPOINT, {
method: 'POST',
body: formData,
// Do NOT set Content-Type header.
// Browsers automatically set the correct multipart boundary.
});
const result = await response.json();
if (result.status === 'accepted') {
formElement.reset();
alert(`Submission recorded. Reference: ${result.submissionId}`);
} else {
throw new Error(result.error || 'Unknown rejection reason');
}
} catch (networkError) {
console.error('Form submission failed:', networkError);
alert('Failed to submit. Please check your connection and try again.');
} finally {
submitButton.disabled = false;
submitButton.textContent = originalLabel;
}
}
// Attach to form
document.getElementById('leadForm').addEventListener('submit', (event) => {
event.preventDefault();
submitLeadForm(event.target);
});
Why this approach:
FormData automatically serializes inputs, handles file uploads (if added later), and sets the correct Content-Type with boundary strings.
- Omitting the
Content-Type header prevents browser conflicts with multipart boundaries.
- UI state management (
disabled, loading text) prevents duplicate submissions during network latency.
event.preventDefault() stops the native form navigation, keeping the user on the same page.
Pitfall Guide
1. CORS Misconception
Explanation: Developers often attempt to configure CORS headers in Apps Script or add mode: 'no-cors' to fetch requests. Apps Script web apps handle CORS automatically for POST requests. Adding custom headers or no-cors breaks response parsing.
Fix: Remove all CORS-related fetch options. Rely on Apps Script's built-in handling. Ensure the deployment access is set to Anyone.
2. e.parameter vs e.postData.contents
Explanation: e.parameter only populates when the request uses standard form encoding. If you send application/json, e.parameter remains empty, and you must parse e.postData.contents manually.
Fix: Stick to FormData on the client side. If JSON is required, update the backend to parse JSON.parse(request.postData.contents) and adjust the frontend to JSON.stringify() with explicit headers.
3. Deployment Access Misconfiguration
Explanation: Setting access to Anyone with Google Account or Only myself blocks unauthenticated form submissions. The endpoint returns 403 or redirects to an OAuth consent screen, breaking the UX.
Fix: Always use Anyone for public form endpoints. If authentication is required, implement token validation inside the script instead of relying on deployment settings.
Explanation: Apps Script expects Content-Type: multipart/form-data; boundary=----WebKitFormBoundary.... Manually setting Content-Type: application/x-www-form-urlencoded without proper encoding, or omitting the boundary, causes e.parameter to parse incorrectly.
Fix: Never manually set Content-Type when using FormData. Let the browser generate the boundary. Validate payloads in the script using Object.keys(request.parameter).length.
5. Rate Limiting & Quota Exhaustion
Explanation: Google enforces a 6-minute execution limit per run and daily trigger quotas. High-frequency submissions or slow appendRow operations can exhaust quotas, causing 429 or timeout errors.
Fix: Batch writes using sheet.getRange().setValues() for bulk operations. Implement client-side rate limiting or queueing. Monitor Logger.log output in the Apps Script execution dashboard.
6. Security Exposure of "Anyone" Access
Explanation: An open endpoint can be abused for spam, DDoS, or data injection. Without validation, malicious payloads can corrupt the sheet or trigger quota exhaustion.
Fix: Implement server-side validation (as shown in the core solution). Add a simple honeypot field in the HTML form. Consider IP allowlisting or token-based validation for production use.
7. Schema Drift & Type Coercion
Explanation: Appending rows with mismatched data types (e.g., numbers as strings, dates in inconsistent formats) breaks downstream analysis. Sheets auto-coerces types, which can lead to silent data corruption.
Fix: Explicitly cast values using String(), Number(), or new Date() before appendRow. Maintain a strict header row and validate column count matches the payload.
Production Bundle
Action Checklist
Decision Matrix
| Scenario | Recommended Approach | Why | Cost Impact |
|---|
| Internal feedback form (<50 submissions/day) | HTML Form + Sheets + Apps Script | Zero infrastructure, instant visibility, built-in sharing | $0 |
| Customer-facing lead capture with validation | Sheets + Apps Script + honeypot/token | Fast deployment, acceptable for low volume, requires basic spam protection | $0 |
| High-concurrency transactional data (>100 req/sec) | Serverless Functions + Managed DB | Sheets cannot handle concurrent writes or guarantee ACID compliance | $10β$50+/mo |
| Audit-compliant data storage | Dedicated database with IAM & encryption | Sheets lacks row-level security, audit trails, and compliance certifications | Variable |
| Prototype/MVP validation | Sheets + Apps Script | Eliminates backend setup time, enables rapid iteration | $0 |
Configuration Template
Copy this into your Apps Script editor. Replace TargetSheetName and adjust field mappings as needed.
function doPost(request) {
const SPREADSHEET_ID = 'YOUR_SPREADSHEET_ID'; // Optional: use openById if cross-sheet
const SHEET_NAME = 'TargetSheetName';
const REQUIRED_KEYS = ['fieldOne', 'fieldTwo', 'fieldThree'];
const lock = LockService.getScriptLock();
try {
lock.waitLock(3000);
const ss = SpreadsheetApp.getActiveSpreadsheet();
const sheet = ss.getSheetByName(SHEET_NAME);
if (!sheet) throw new Error(`Sheet "${SHEET_NAME}" not found.`);
const params = request.parameter;
const missing = REQUIRED_KEYS.filter(k => !params[k]);
if (missing.length) throw new Error(`Missing: ${missing.join(', ')}`);
const recordId = Utilities.getUuid();
const createdAt = new Date().toISOString();
sheet.appendRow([
createdAt,
String(params.fieldOne).trim(),
String(params.fieldTwo).trim(),
String(params.fieldThree).trim(),
recordId
]);
return ContentService.createTextOutput(
JSON.stringify({ status: 'ok', id: recordId })
).setMimeType(ContentService.MimeType.JSON);
} catch (err) {
Logger.log('Error: %s', err.message);
return ContentService.createTextOutput(
JSON.stringify({ status: 'fail', error: err.message })
).setMimeType(ContentService.MimeType.JSON);
} finally {
lock.releaseLock();
}
}
Quick Start Guide
- Create the spreadsheet: Open Google Sheets, create a new file, and add headers in row 1:
Timestamp, fieldOne, fieldTwo, fieldThree, RecordID.
- Open Apps Script: Navigate to
Extensions > Apps Script. Paste the Configuration Template code. Save the project.
- Deploy: Click
Deploy > New deployment > Web app. Set Execute as: Me, Who has access: Anyone. Authorize when prompted. Copy the Web App URL.
- Wire the frontend: Create an HTML form with inputs matching the header names. Attach the provided
fetch handler, replacing INGESTION_ENDPOINT with your copied URL.
- Test: Submit a test entry. Verify the row appears in the sheet. Check the Apps Script execution log (
Executions tab) for any warnings. Iterate on validation rules as needed.