Skip to content
// Sheets · Apps Script

Append a row from a web request with Apps Script.

How to write a doPost function in Google Apps Script that parses JSON from an external HTTP request and appends the data as a new row in a Google Sheet — including the deployment settings that make it actually work.

I want to POST JSON from my app (or a form) to a Google Apps Script Web App URL and have it append a row to a Sheet automatically.

The script

copy · paste · trigger
Code.gs
Apps Script
// POST handler — appends one row per request
// Sheet columns: timestamp | name | email | message
function doPost(e) {
  var sheet = SpreadsheetApp
    .getActiveSpreadsheet()
    .getSheetByName('Responses');

  var data = JSON.parse(e.postData.contents);

  sheet.appendRow([
    new Date(),
    data.name,
    data.email,
    data.message
  ]);

  return ContentService
    .createTextOutput(JSON.stringify({ status: 'ok' }))
    .setMimeType(ContentService.MimeType.JSON);
}

Need a variant? Gnaw writes a custom version from one sentence — fields, triggers, edge cases handled.

Walkthrough

What e.postData.contents actually is

When an external client sends a POST with a JSON body, Apps Script puts the raw request body string into e.postData.contents. That is a string, not an object. Every beginner hits e.postData.contents.name and gets undefined, then spends twenty minutes wondering why the data is missing. The fix is one line: var data = JSON.parse(e.postData.contents).

e.postData also has a type field you can check against 'application/json' if you want to be strict, but in practice I skip the check in internal tools and just parse. If the body is malformed, JSON.parse throws and the script errors out with a clear message in the Apps Script execution log — which is a better failure mode than silently writing undefined into your sheet.

One real gotcha: if the client sends the request as application/x-www-form-urlencoded instead of application/json, the body lands in e.parameter (as a key-value object) rather than e.postData.contents. This trips up people using default HTML form submissions. Either set the fetch content-type header explicitly to application/json on the client, or read from e.parameter instead.

The deployment setting that most people get wrong

After writing doPost, you deploy via Extensions > Apps Script > Deploy > New deployment, choose Web app, and hit two dropdowns. Execute as: Me is correct — the script runs under your Google account and has access to your Sheet. Who has access: Anyone is the required setting for any external caller that is not signed into Google.

The default for Who has access is 'Only myself.' With that setting, every unauthenticated POST gets a 302 redirect to a Google login page. Your client follows the redirect, hits the login page, and gets back HTML with a 200 status. The request looks like it succeeded; no row appears in the sheet. This is the single most common reason doPost appears to silently do nothing.

Each time you edit the script you must deploy a new version and copy the new /exec URL. The previous URL keeps running the old code. The /dev URL runs the latest saved code but requires the caller to be authenticated as the script owner — fine for your own testing in a browser, useless for external POST requests.

Returning a response the caller can trust

ContentService.createTextOutput is the correct way to return data from a doPost. Returning a plain string or nothing works in casual testing but breaks clients that check Content-Type or parse the response body.

Set .setMimeType(ContentService.MimeType.JSON) so the response header reads application/json. Some HTTP clients (including fetch in browsers) will refuse to parse the body as JSON if the Content-Type does not match. The JSON.stringify({ status: 'ok' }) pattern gives the caller a machine-readable confirmation without any Sheet data leaking back.

One operational note: Apps Script Web Apps do not support CORS preflight (OPTIONS) by default. If you are calling from a browser frontend on a different origin, the browser will send a preflight OPTIONS request before the POST. Apps Script returns a 405 for OPTIONS, which causes the browser to block the actual POST. The standard fix is to send the request from a server-side process instead of directly from the browser, or to proxy it through a Cloudflare Worker that adds the right headers.

Want a custom version?

Describe your sheet and the rule you want. Gnaw writes the Apps Script — fields, triggers, edge cases — in one shot.

FAQ

4 questions
Why does my doPost run without errors but no row appears in the sheet?
Almost always a deployment setting. Check that Who has access is set to Anyone (not 'Only myself') in the Web App deployment. With the default setting, external requests get a login redirect that looks like a success to the caller but never reaches your doPost function.
Do I need to re-deploy every time I change the code?
Yes. Changes to the script only go live after you create a new deployment version. The /exec URL is tied to a specific deployed version; the /dev URL always runs latest-saved but requires the caller to be authenticated as the owner, so it is not useful for external POST requests.
How do I handle fields that might be missing from the JSON body?
Read them with a fallback: data.message || '' will write an empty string instead of undefined if the key is absent. appendRow writes undefined as a blank cell, which is usually harmless, but explicit fallbacks make the sheet columns predictable and prevent type errors downstream.
Can I POST to Apps Script from a browser fetch call?
You can, but preflight (CORS OPTIONS) will fail because Apps Script does not handle OPTIONS requests. The POST itself may still succeed with mode: 'no-cors', but you lose the ability to read the response. For reliable results, send the POST from a backend or a Cloudflare Worker rather than directly from the browser.
// one good script a week

Get a working Apps Script snippet in your inbox, weekly.