Simple triggers vs installable triggers (when onEdit lies to you)

TL;DR — Simple vs installable Apps Script triggers: why onEdit can’t call UrlFetch or Gmail, how installable handlers fix it, and how to stop duplicate fires.

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 (onEditSimple vs onEditInstallable).
  • 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() in onOpen (with caveats)
  • Lightweight formatting / validation helpers

What fails (often with a vague "authorization" or silent no-op depending on context):

  • UrlFetchApp
  • GmailApp / MailApp
  • DriveApp (most useful bits)
  • SpreadsheetApp.openById / other files
  • Anything that needs oauth beyond the implicit simple bubble

When does onEdit "lie" to you?

Classic symptoms:

  1. Handler runs in the editor's test — fails for real edits
  2. Works for the owner — fails for editors (installable trigger installed as owner, but you're debugging simple onEdit)
  3. You added GmailApp.sendEmail inside onEdit — emails never send; execution transcript shows failure or the function appears not to run
  4. You have both a simple onEdit and an installable trigger pointed at onEdit → 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"?

  1. Executions page — filter by function name; red failures beat guessing
  2. Confirm you don't rely on simple onEdit for UrlFetch
  3. Confirm installable trigger exists, enabled, correct spreadsheet
  4. Confirm event filter (sheet name / column) isn't too tight
  5. 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:

  1. Installable onEdit writes pending onto a Jobs tab (or sets a "needs sync" flag)
  2. 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 onEdit only 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 onEdit double-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!