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).
| Trigger | Can call UrlFetchApp | Fires when |
|---|---|---|
Simple onEdit(e) | No | A user edits a cell; 30-second limit |
| Installable on edit | Yes | A user edits a cell |
| Installable on form submit | Yes | A linked form adds a response row |
| Time-driven | Yes | On 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.
| Quota | Consumer accounts | Google Workspace |
|---|---|---|
| URL Fetch calls | 20,000 a day | 100,000 a day |
| Triggers total runtime | 90 minutes a day | 6 hours a day |
| Script runtime | 6 minutes per run | 6 minutes per run |
| Triggers | 20 per user per script | 20 per user per script |
| URL Fetch POST size | 50MB per call | 50MB per call |
| URL Fetch URL length | 2KB | 2KB |
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
doPostweb app can't read a provider's signature header likeStripe-SignatureorX-Hub-Signature-256to verify the sender. - Anything you return with
ContentServicegets redirected to a one-time URL atscript.googleusercontent.com, so to test your endpoint and read the reply with a curl POST request, pass-Lto follow redirects and let-dmake 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
- Create a free bin in your browser at view.webhooks.page or run
curl -X POST https://webhooks.sh/v1/bins, then paste theingest_url(which looks likehttps://<bin>.webhooks.page/) intoWEBHOOK_URLin your script. - Edit a cell in the sheet or submit your linked form, then check the bin viewer for the
Content-Typeheader, the raw JSON body, and the exact field names. - Switch
WEBHOOK_URLto 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.