Skip to content
// Apps Script

Fix "You do not have permission to call X" in Apps Script.

Simple triggers like onEdit and onOpen run in a restricted sandbox that can't call GmailApp, UrlFetchApp, or other services requiring OAuth consent. Learn how to convert to an installable trigger to fix the permission error.

I added code to onEdit or onOpen and now Apps Script throws "You do not have permission to call X" and I can't figure out why it works in the editor but breaks on the sheet.

The script

copy · paste · trigger
Code.gs
Apps Script
// Installable trigger: run from Triggers menu, not as a simple onEdit
// Grants full OAuth scope so GmailApp and UrlFetchApp are available

function onEditInstallable(e) {
  const sheet = e.source.getActiveSheet();
  const range = e.range;

  if (sheet.getName() !== 'Orders') return;
  if (range.getColumn() !== 3) return;

  const row = range.getRow();
  const email = sheet.getRange(row, 1).getValue();
  const status = range.getValue();

  if (email && status === 'Shipped') {
    GmailApp.sendEmail(email, 'Your order shipped', 'It is on its way.');
  }
}

// Wire it up: Extensions > Apps Script > Triggers > Add Trigger
// Choose: onEditInstallable / From spreadsheet / On edit

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

Walkthrough

Why the sandbox blocks you

Apps Script has two categories of trigger. Simple triggers are the reserved function names — onOpen, onEdit, onSelectionChange — that Google fires automatically without asking the user for consent. Because they run without an OAuth handshake, they operate in a stripped-down sandbox: read and write spreadsheet cells, yes; call GmailApp, UrlFetchApp, DriveApp, or any service that touches data outside the sheet, no. The error message 'You do not have permission to call X' is the sandbox enforcing that boundary, not a quota issue or an account restriction.

The function works fine when you run it manually in the editor because the editor prompts for consent on first run and stores a token. The trigger fires under a different execution context with no stored token, so the same function call fails. This asymmetry is the first thing that confuses people: it looks like the code is fine and the trigger is broken, when actually the trigger type is the constraint.

Converting to an installable trigger

An installable trigger runs as you, the user who created it, with your full OAuth scopes. To wire one up, rename the function so it does not collide with the reserved names (onEditInstallable is the conventional pattern), then go to Extensions > Apps Script > Triggers (the clock icon in the left sidebar) and click Add Trigger. Set the function to your renamed version, the event source to 'From spreadsheet', and the event type to 'On edit'. Google will prompt for consent once; after that, every edit fires the function with full permissions.

The trade-off is portability. A simple trigger fires for any user who edits the sheet, including collaborators. An installable trigger fires under your account, meaning Gmail sends come from your address and Drive operations use your quota. If the sheet has multiple editors and each one needs their own trigger behavior, each person needs to install their own trigger. That is usually fine for internal tooling; it matters if you are distributing the sheet to strangers.

Narrowing scope before you run

The event object e passed to an installable trigger carries the same properties as the simple version: e.range, e.source, e.oldValue, e.value. The first thing I do in any sheet trigger is filter to the specific sheet name and column before touching any external service, because a spreadsheet with 10 tabs will fire the trigger on every edit across all of them. The example above checks sheet.getName() and range.getColumn() before calling GmailApp. Without those guards, one accidental cell edit anywhere sends a spurious email.

If you need UrlFetchApp — hitting an external API on each edit — the same pattern applies. The fetch call goes inside the guard, not outside it. Apps Script has a 6-minute execution limit per trigger invocation and a daily quota of roughly 20,000 UrlFetch calls on a free Workspace account; keeping the guard tight means you spend quota only when the relevant cell actually changes.

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
Does this error appear for onOpen too, not just onEdit?
Yes. onOpen has the same restricted sandbox. Any service requiring OAuth — SpreadsheetApp.getUi() is fine, but GmailApp, DriveApp, CalendarApp, and UrlFetchApp all throw. Convert to an installable onOpen trigger the same way: rename the function and register it under 'On open' in the Triggers panel.
I created the installable trigger but the error still appears. What did I miss?
Check that the trigger is pointing to the renamed function, not the old reserved-name version. In the Triggers panel, the Function column must show your new name. If you see 'onEdit' there, it is still a simple trigger. Also verify the trigger's status column does not show a red error icon — if a previous run failed, click it to see whether there is a secondary permission error like 'Access not granted' that means the OAuth consent step did not complete.
Will all collaborators see emails sent from my account?
Yes. GmailApp.sendEmail sends from the account that installed the trigger — your address. The From field shows your email to the recipient. If that is a problem, create a dedicated Google account for the automation, install the trigger from that account, and use its inbox. That is the lowest-friction option that keeps everything inside Google's free tier.
Can I use UrlFetchApp to call an external webhook from onEdit?
Yes, once the trigger is installable. UrlFetchApp.fetch is fully available. The daily limit for free Workspace is 20,000 calls; Google Workspace Business bumps that to 100,000. If your sheet has heavy edit traffic, add a debounce check — compare the new value to the old one with e.oldValue before firing the fetch, so you do not hit quota on no-op edits like clicking through cells.
// one good script a week

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