You wrote a perfect onEdit that emails the team when Status flips to Done — and nothing happens. Or worse: it works when you edit, then fails for everyone else. Welcome to the gap between simple triggers and installable triggers. Apps Script will not shout; it just quietly refuses the services your handler needs. Here's the decision tree we use on bootstrapper workbooks.
TL;DR
- Simple triggers (
onEdit,onOpen,onInstall, …) run without asking for authorization — and cannot call most services (no UrlFetch, Gmail,openById, etc.). - Installable triggers run as the installing user with that user's scopes — they can email, fetch, and open other files.
- Same function name on both = double fire or mystery auth errors. Prefer distinct handlers (
onEditSimplevsonEditInstallable). - Create installable triggers once (idempotent setup); delete-by-handler before recreate.
- Auth identity ≠ "whoever just edited the cell."
Using the Apps Script editor a lot? NitroGAS drops free themes & snippets right into script.google.com — optional Co-Pilot when you want a boost.
What is a simple trigger, really?
A simple trigger is a specially named function Apps Script wires for you when the file opens or a cell changes. No "Triggers" UI row required for onEdit / onOpen to exist.
function onEdit(e) {
// Runs on almost every edit — but with a tiny authorization bubble
var sheet = e.range.getSheet();
sheet.getRange('Z1').setValue('edited'); // OK: bound spreadsheet writes
}
What usually works in simple triggers:
- Read/write the bound spreadsheet
SpreadsheetApp.getUi()inonOpen(with caveats)- Lightweight formatting / validation helpers
What fails (often with a vague "authorization" or silent no-op depending on context):
UrlFetchAppGmailApp/MailAppDriveApp(most useful bits)SpreadsheetApp.openById/ other files- Anything that needs oauth beyond the implicit simple bubble
When does onEdit "lie" to you?
Classic symptoms:
- Handler runs in the editor's test — fails for real edits
- Works for the owner — fails for editors (installable trigger installed as owner, but you're debugging simple
onEdit) - You added
GmailApp.sendEmailinsideonEdit— emails never send; execution transcript shows failure or the function appears not to run - You have both a simple
onEditand an installable trigger pointed atonEdit→ logic runs twice
The lie is the assumption that "edit happened ⇒ my full automation ran." Simple onEdit is a courtesy callback, not a webhook with your full OAuth grant.
How do installable triggers fix it?
Installable triggers are created via the Triggers UI or ScriptApp.newTrigger(...). They run as the user who created them, with whatever scopes that project has authorized.
function onEditInstallable(e) {
if (!e || !e.range) return;
var sheet = e.range.getSheet();
if (sheet.getName() !== 'Tasks') return;
if (e.range.getColumn() !== headerCol_(sheet, 'status')) return;
var status = String(e.range.getValue() || '').toLowerCase();
if (status !== 'done') return;
var row = getActiveRowAsObject(sheet); // or build from e.range.getRow()
notifyDone_(row); // may Gmail / UrlFetch — allowed here
}
function setupInstallableOnEdit() {
deleteTriggersByHandler('onEditInstallable');
ScriptApp.newTrigger('onEditInstallable')
.forSpreadsheet(SpreadsheetApp.getActive())
.onEdit()
.create();
}
Rename away from bare onEdit so you don't also get the simple trigger for free.
Simple vs installable — quick chooser
| Need | Prefer |
|---|---|
| Toast / note / validate in-bound sheet | Simple onEdit |
| Email, Slack webhook, HubSpot POST | Installable |
| Read another spreadsheet by id | Installable |
| Run as a specific service account-ish identity | Installable (installing user) |
| Fast local formatting with zero auth prompts for viewers | Simple |
Many production sheets use both: a tiny simple onEdit for UX (color the row), and an installable handler for side effects. Keep the side-effect path gated and idempotent.
How do you install without doppelgängers?
Creating a new trigger every time someone runs "Setup" is how you get five emails per edit. Delete by handler name first (see the sibling guide on trigger doppelgängers).
function deleteTriggersByHandler(handlerName) {
ScriptApp.getProjectTriggers().forEach(function (t) {
if (t.getHandlerFunction() === handlerName) {
ScriptApp.deleteTrigger(t);
}
});
}
Run setup from a custom menu once per environment, not from onOpen.
Who does the installable trigger run as?
The installing user. Implications:
Session.getActiveUser().getEmail()inside an installable time-driven trigger is usually that installer- Gmail sends from their mailbox (or their alias rules)
- Drive access is theirs — editors don't magically gain the installer's Drive
If freelancers come and go, reinstall triggers under a durable ops account, not a contractor's personal Gmail.
How do you debug "it didn't fire"?
- Executions page — filter by function name; red failures beat guessing
- Confirm you don't rely on simple
onEditfor UrlFetch - Confirm installable trigger exists, enabled, correct spreadsheet
- Confirm event filter (sheet name / column) isn't too tight
- Edit as a different user — ownership vs editor bugs show up here
function onEditInstallable(e) {
try {
handleStatusEdit_(e);
} catch (err) {
// Simple triggers often can't email; installable can:
emailErrorAlert('onEditInstallable failed', err, {
sheet: e && e.range && e.range.getSheet().getName(),
a1: e && e.range && e.range.getA1Notation()
});
throw err;
}
}
Can time-driven triggers be simple?
No. Time-driven and on-form-submit handlers are always installable (created in UI or via ScriptApp). There is no simple onClock cousin.
That matters for queue workers and nightly syncs:
- They already run with the installer's OAuth — UrlFetch/Gmail are fine
- They still need idempotent setup (one trigger per handler)
- They still need failure email / log sheet because nobody is staring at the sheet
If your onEdit side effects feel cramped, move the heavy work onto a queue row + time-driven worker instead of doing UrlFetch inside the edit handler. Edits stay snappy; the worker retries cleanly.
Should you put UrlFetch directly in onEditInstallable?
Sometimes — for a single cheap webhook. Often — no. Edit storms (paste of 50 rows) will fan out 50 fetches and blow quotas. Pattern that scales:
- Installable
onEditwritespendingonto a Jobs tab (or sets a "needs sync" flag) - Time-driven trigger drains the queue with
claimNextQueueRow
You still needed installable onEdit to record the intent; you avoided doing the expensive work inline.
Minimal test plan
- Simple
onEditonly writes in-bound cells — no UrlFetch - Installable handler sends a test email on a controlled column edit
- After two "Setup" runs, only one installable trigger remains
- Editor (non-owner) edit still fires installable path
- Renamed handlers: no bare
onEditdouble-processing
Soft Co-Pilot note
Picking handler names and packing the event object is where people stall. NitroGAS keeps trigger setup / delete-by-handler snippets handy; Co-Pilot can help split a tangled onEdit into simple UX + installable side effects. Free extension; Co-Pilot optional.
Closing checklist
- Side effects live on installable handlers, not bare simple
onEdit - Function names don't collide (no dual-fire)
- Idempotent setup menu documented
- Installing user is a durable ops identity
- Failures alert somehow (email / log sheet)
- Team knows simple ≠ "full automation"
Happy Coding!


