Google Sheets webhook with Apps Script: send rows with UrlFetchApp, receive with doPost

To send Google Sheets data to a webhook, call UrlFetchApp.fetch from an installable edit, form submit or time-driven trigger in Apps Script; consumer accounts get 20,000 URL Fetch calls a day. Point it at a free webhooks.sh bin to see the exact request: no signup, 48 hours, 100 requests a day.

Google Sheets has no built-in webhook setting, so you send data out with Apps Script's UrlFetchApp.fetch from an installable trigger, and you receive incoming webhooks by deploying a doPost web app. This page covers the JSON content-type trap, which trigger to use, the Apps Script quotas, the doPost receiving side, and how to inspect the exact HTTP request using a free bin.

Send a row or edit with UrlFetchApp

The main trap when you use UrlFetchApp to post JSON is its default configuration. By default, UrlFetchApp.fetch sends application/x-www-form-urlencoded and turns a JavaScript object passed in payload into form fields, which means a webhook receiver expecting JSON gets form data instead. To send actual JSON, set contentType to application/json and pass JSON.stringify(payload) as the payload.

You also want muteHttpExceptions set to true. When it's false (the default), fetch throws an exception on any failure status code, whereas true returns the HTTPResponse so your script can log the error status instead of crashing.

In your sheet, open Extensions, Apps Script, paste the code below, and run installTrigger once to create the installable trigger and grant access for the https://www.googleapis.com/auth/script.external_request scope.

const WEBHOOK_URL = "https://<bin>.webhooks.page/sheets";

function sendEdit(e) {
  const payload = {
    sheet: e.range.getSheet().getName(),
    cell: e.range.getA1Notation(),
    oldValue: e.oldValue,
    value: e.value,
  };
  const res = UrlFetchApp.fetch(WEBHOOK_URL, {
    method: "post",
    contentType: "application/json",
    payload: JSON.stringify(payload),
    muteHttpExceptions: true,
  });
  console.log(res.getResponseCode(), res.getContentText());
}

function installTrigger() {
  ScriptApp.newTrigger("sendEdit")
    .forSpreadsheet(SpreadsheetApp.getActive())
    .onEdit()
    .create();
}

Which trigger to use

If you name a function onEdit and call UrlFetchApp.fetch inside it, the script will fail. A function named onEdit(e) is a simple trigger, and simple triggers can't access services that require authorization. You need an installable trigger instead, which runs under the account of the user who created it and can make external HTTP requests.

The biggest surprise with Sheets triggers is what doesn't fire them: script executions and API requests don't cause triggers to run. Edits made by another script, the Sheets API, or third-party integrations won't fire an edit trigger (for example, Range.setValue() never fires onEdit). If another app or script is adding rows to your sheet, you need a time-driven trigger that runs on a clock and checks for new rows, whereas Google Forms responses have their own installable form submit trigger with values, namedValues, and range on the event object.

Watch the frequency if you poll with a clock trigger. A time-driven trigger running every minute makes 1,440 calls a day, which sits well inside Google's URL Fetch quota, but will blow past a free webhooks.sh bin's cap of 100 requests a day in under two hours (returning 429 daily_cap_reached).

TriggerCan call UrlFetchAppFires when
Simple onEdit(e)NoA user edits a cell; 30-second limit
Installable on editYesA user edits a cell
Installable on form submitYesA linked form adds a response row
Time-drivenYesOn a schedule, as often as every minute

Apps Script quotas that apply

Google's Apps Script quotas are calculated per user and reset 24 hours after the first request. On a busy sheet, watch total trigger runtime (90 minutes a day for consumer accounts, 6 hours for Google Workspace) as closely as the daily UrlFetchApp call count.

QuotaConsumer accountsGoogle Workspace
URL Fetch calls20,000 a day100,000 a day
Triggers total runtime90 minutes a day6 hours a day
Script runtime6 minutes per run6 minutes per run
Triggers20 per user per script20 per user per script
URL Fetch POST size50MB per call50MB per call
URL Fetch URL length2KB2KB

Receive webhooks in a sheet with doPost

To go the other direction and receive webhooks into a sheet, write an Apps Script doPost(e) function and deploy the project as a web app. When a POST request hits the web app, doPost(e) reads the raw request body from e.postData.contents (alongside e.postData.type and e.postData.length) and appends a row to the sheet.

When you deploy the web app, set its access so the webhook sender can reach it without signing into a Google account, and give the sender the URL ending in /exec. Don't use the /dev URL for webhooks, because /dev only works for users who have edit access to the script.

There are two catches on the receiving side:

  • Request headers aren't among the event object's documented fields, so a doPost web app can't read a provider's signature header like Stripe-Signature or X-Hub-Signature-256 to verify the sender.
  • Anything you return with ContentService gets redirected to a one-time URL at script.googleusercontent.com, so to test your endpoint and read the reply with a curl POST request, pass -L to follow redirects and let -d make the request a POST.

Apps Script

function doPost(e) {
  const data = JSON.parse(e.postData.contents);
  SpreadsheetApp.getActive().getSheets()[0]
    .appendRow([new Date(), JSON.stringify(data)]);
  return ContentService
    .createTextOutput(JSON.stringify({ ok: true }))
    .setMimeType(ContentService.MimeType.JSON);
}

Terminal

curl -L "https://script.google.com/macros/s/<id>/exec" \
  -H "Content-Type: application/json" \
  -d '{"name":"test","amount":42}'

Test the request before the real endpoint

  1. Create a free bin in your browser at view.webhooks.page or run curl -X POST https://webhooks.sh/v1/bins, then paste the ingest_url (which looks like https://<bin>.webhooks.page/) into WEBHOOK_URL in your script.
  2. Edit a cell in the sheet or submit your linked form, then check the bin viewer for the Content-Type header, the raw JSON body, and the exact field names.
  3. Switch WEBHOOK_URL to your real endpoint once the payload looks right.

The bin replies with HTTP 200 and a small JSON body like {"ok":true,"delivery_id":"..."}, so res.getResponseCode() in Apps Script logs 200, and if your check in the viewer shows form fields instead of a JSON body, contentType wasn't set to application/json.

Keep the URL past 48 hours

A free bin lasts 48 hours and accepts 100 requests a day (up to 100KB per body), which is plenty for testing a new script. If you want to keep using the same webhooks.page URL, claim the bin from the viewer page with a free Google sign-in before it expires to stop the 48-hour timer and keep a rolling 30 days of request history. If you're running production sheets, Solo ($12 a month on the pricing page) gives you named endpoints on webhooks.host with 250,000 requests a month and automatic forwarding to up to 3 verified HTTPS URLs.

FAQ

How do I send data from Google Sheets to a webhook?

Write an Apps Script function that calls UrlFetchApp.fetch with contentType set to application/json and JSON.stringify on your payload object. Run that function from an installable edit, form submit, or time-driven trigger so the script has permission to make external requests.

Why doesn't my onEdit function call UrlFetchApp?

A function named onEdit runs as a simple trigger, and simple triggers aren't allowed to call services that require authorization like UrlFetchApp. Rename the function and attach it to an installable edit trigger instead.

Does onEdit fire when another app adds rows to the sheet?

No, script executions and API requests never fire edit triggers. If another app or integration writes rows to your sheet, use a time-driven trigger that checks for new rows on a schedule.

How many UrlFetchApp calls can Apps Script make per day?

Consumer accounts (like gmail.com) can make 20,000 URL Fetch calls per day, and Google Workspace accounts can make 100,000 per day. These quotas are per user and reset 24 hours after the first request.

Can Google Sheets receive webhooks?

Yes, by writing a doPost(e) function in Apps Script and deploying it as a web app that appends rows to your sheet. The script reads the incoming payload from e.postData.contents, though it can't inspect HTTP request headers to verify webhook signatures.

Why does my Apps Script web app return a redirect?

Apps Script serves ContentService output from a one-time URL at script.googleusercontent.com. Any HTTP client that needs to read the response body has to follow redirects, such as passing curl -L.

Last updated

Try it in 10 seconds.

Create a free bin at view.webhooks.page — no signup, no install. Point your sender at the URL and watch requests land live. Bins expire after 48 hours.