The Error
You wire up some code in your Google Sheets script โ sending an email, calling an external API, maybe showing a simple alert โ and then the trigger stops working. Pull up the Apps Script execution log and you see:
Exception: The script does not have permission to perform that action.
The frustrating part: running that exact function manually from the editor works fine. The moment a trigger fires it, though โ it blows up. Here's what's going on and how to fix it.
Why This Happens
Apps Script has two trigger types, and they work very differently under the hood:
- Simple triggers โ reserved function names like
onEdit(e),onOpen(e), andonChange(e). They fire automatically but run in a sandboxed context with no user credentials attached. They cannot do anything requiring OAuth authorization, period. - Installable triggers โ triggers you create explicitly (via the Triggers UI or
ScriptAppcode). These run under the credentials of whoever created them, with the full set of permissions authorized at setup time.
That boundary is hard. Even if your appsscript.json lists every OAuth scope you need, a simple trigger won't touch them. The sandbox ignores your tokens entirely.
Things that reliably fail inside simple triggers:
MailApp.sendEmail()orGmailApp.sendEmail()UrlFetchApp.fetch()โ any external HTTP callSpreadsheetApp.getUi().alert()or any UI dialogDriveApp.createFile()or similar Drive operations- Calendar, Contacts, or Admin SDK calls
The Fix: Switch to an Installable Trigger
Step 1 โ Rename your function
Any function named onEdit is treated as a simple trigger by Apps Script โ regardless of how you registered it. Rename it to something that isn't on the reserved list:
// Before โ simple trigger, limited permissions
function onEdit(e) {
if (e.range.getColumn() === 3) {
MailApp.sendEmail('boss@company.com', 'Row updated', 'Someone edited column C');
}
}
// After โ rename so it's no longer treated as a simple trigger
function onEditHandler(e) {
if (e.range.getColumn() === 3) {
MailApp.sendEmail('boss@company.com', 'Row updated', 'Someone edited column C');
}
}
Step 2 โ Create an installable trigger
Option A โ Via the UI (easiest for one-time setup):
- Open your script in the Apps Script editor.
- Click the alarm clock icon (Triggers) in the left sidebar.
- Click + Add Trigger (bottom right corner).
- Set function to
onEditHandler, event source to From spreadsheet, event type to On edit. - Click Save โ you'll be prompted to authorize the script.
Option B โ Programmatically (run once, then delete):
function createEditTrigger() {
const ss = SpreadsheetApp.getActiveSpreadsheet();
// Remove existing triggers for this handler to avoid duplicates
ScriptApp.getProjectTriggers().forEach(t => {
if (t.getHandlerFunction() === 'onEditHandler') {
ScriptApp.deleteTrigger(t);
}
});
ScriptApp.newTrigger('onEditHandler')
.forSpreadsheet(ss)
.onEdit()
.create();
console.log('Installable trigger created successfully.');
}
Hit Run on createEditTrigger() from the editor. Approve the authorization popup. From that point on, the trigger runs with your full OAuth credentials on every edit โ no more permission errors.
Step 3 โ Check your OAuth scopes
Still seeing the error after authorizing? Check your appsscript.json manifest. Enable it via Project Settings โ Show appsscript.json manifest file:
{
"timeZone": "America/New_York",
"dependencies": {},
"exceptionLogging": "STACKDRIVER",
"runtimeVersion": "V8",
"oauthScopes": [
"https://www.googleapis.com/auth/spreadsheets",
"https://www.googleapis.com/auth/gmail.send",
"https://www.googleapis.com/auth/script.external_request"
]
}
After updating the manifest, run any function from the editor once to kick off re-authorization with the new scope list.
Verify the Fix
- Open the Triggers page (alarm icon). Your
onEditHandlershould appear with type On edit, source Spreadsheet, and your email in the owner column. - Edit a cell in the spreadsheet to fire the trigger.
- Check Executions (list icon in the left sidebar). A clean run shows status Completed. Still seeing the permission exception? Go back to Step 3 and re-authorize.
- Confirm the action actually happened โ email in your inbox, API response in the log, whatever your script is supposed to do.
Want a quick sanity check that the trigger is running under your account? Temporarily drop this line in:
function onEditHandler(e) {
console.log('Effective user:', Session.getEffectiveUser().getEmail());
// ... rest of your code
}
Your email appears in the Executions log? The installable trigger is wired up correctly. Nothing there, or an error? It's still firing as a simple trigger somewhere.
Watch Out For: Duplicate Triggers
Run createEditTrigger() twice and you now have two triggers pointing at the same handler. Every edit fires it twice โ two emails sent, two API calls made. Check the Triggers page before running the setup function again, or rely on the deduplication logic already in the code above. To audit what's registered right now:
function listTriggers() {
ScriptApp.getProjectTriggers().forEach(t => {
console.log(
t.getHandlerFunction(),
t.getEventType(),
t.getTriggerSourceId()
);
});
}
Tips
- Keep simple triggers simple. Use
onEditonly for lightweight, no-auth work โ formatting cells, stamping a "Last modified" timestamp, that sort of thing. Anything touching external services belongs in an installable trigger. - Trigger ownership matters in shared scripts. Installable triggers run as whoever created them. On a team sheet, each person who needs the automation under their own account should create their own trigger โ or pick one service account and set up a single shared trigger there.
- Don't over-scope. Only request the OAuth scopes your script actually uses. Listing
https://mail.google.com/whengmail.sendis enough will trip authorization review and confuse anyone granting access. - Time-based triggers aren't affected by this. Jobs scheduled with
ScriptApp.newTrigger().timeBased()are always installable by nature. This restriction only hits event-based reserved function names likeonEditandonOpen.

