ServicesProductsDocsBlogConsultingAboutContactGet Started
Back to BlogA finance officer in a site office stamping a request slip in the middle tray of a three-stage steel approval rack, a pile of unsigned paper forms beside her.
13 min readMageSheet Team

Google Forms Approval Workflow: Multi-Step Approvals with Apps Script

Google FormsApps ScriptApproval WorkflowGoogle SheetsAutomationGoogle Workspace

Google Forms has no approval step. A response lands in a Sheet, and from there someone forwards it, chases it, and copies the decision back by hand. You can close that gap with about 150 lines of Apps Script: a form-submit trigger that works out who should approve, an email to that person, a small web page where they approve or reject, and a log sheet that records every decision — routing through as many levels as your rules require.

It runs inside the Google account you already pay for. No per-response fee, no add-on subscription, and the logic sits in your own Drive where you can read it.

Before you build anything, though, check whether you need to. Two cases where you shouldn't:

  • Under ~20 requests a month with a single approver. A Marketplace add-on's free tier will cover you. The Form Approvals add-on, for instance, lists 20 free responses a month, with paid plans from about $7 to $65 a month depending on volume and term (their published pricing, checked October 2026). Below that line, a build is money you'll never get back.
  • You're approving documents, not requests. If the thing being approved is a contract or a policy doc sitting in Drive, Google's native Drive Approvals already does it — reviewers, due dates, a lock on the file once it's approved. It's included on Business Standard and above, Enterprise Standard and Plus, Education Plus and the Essentials editions, but not on Business Starter.

Everything below is for the common middle ground: expense claims, purchase requests, leave requests, discount approvals — form-based, rule-driven, and frequent enough that someone is losing hours to chasing.

What "multi-step" actually requires

A real approval workflow is more than "email the manager." In the request flows we've built for small teams, the same five requirements come up every time:

RequirementWhy it mattersWhere it lives in the build
Routing by answersA 200 expense shouldn't wake the director; a 20,000 purchase shouldA rules tab in the Sheet
Sequential stagesFinance shouldn't see a request the manager hasn't clearedA chain and a stage counter per request
Safe decision linksEmail security scanners click links; robots must not approve purchasesA confirmation page plus a per-step token
One decision per stageTwo approvers clicking at once must not both advance the requestLockService around the decision
Chasing and an audit trail"Who approved this, and when?" six months laterA history column and a daily reminder trigger

The fifth one is where most homemade versions fall short, and it's the one auditors and accountants ask about first.

The data model: keep state off the responses tab

Use three tabs in the response spreadsheet:

  • Form Responses 1 — untouched. Forms owns it.
  • Rules — amount bands to approval chains, and department to manager.
  • Approvals — one row per request: ID, response row, requester, amount, chain, current stage, current token, status, last updated, history.

Don't add status columns next to the form responses. Forms controls that tab's layout: a new question gets a new column, a deleted question leaves its old column behind, and columns you added by hand sit in the way of both. A script that writes to "column K" can end up writing into the wrong place without any error. A separate tab keyed by request ID avoids the problem.

The rules, for a typical purchase-request flow:

// Amount bands → ordered chain of roles. Keep this in the Rules tab in production;
// it is inline here so the pattern is readable.
const CHAINS = [
  { max: 500,      steps: ['manager'] },
  { max: 5000,     steps: ['manager', 'finance'] },
  { max: Infinity, steps: ['manager', 'finance', 'director'] },
];
const ROLES = { finance: 'finance@example.com', director: 'director@example.com' };

function managerFor_(department) {
  const rows = SpreadsheetApp.getActive().getSheetByName('Rules')
    .getRange('A2:B').getValues();               // A: department, B: manager email
  const hit = rows.find(r => String(r[0]).trim() === String(department).trim());
  if (!hit) throw new Error('No manager mapped for department "' + department + '"');
  return hit[1];
}

Step 1: catch the submission and build the chain

Attach an installable "On form submit" trigger to the spreadsheet (Triggers → Add trigger → From spreadsheet → On form submit). There is no simple-trigger version of form submit, and simple triggers can't send email anyway.

function onRequestSubmit(e) {
  const v = e.namedValues;                        // { 'Amount': ['1200'], ... }
  const amount = Number(String(v['Amount'][0]).replace(/[^\d.]/g, ''));
  const chain = CHAINS.find(c => amount <= c.max).steps
    .map(role => role === 'manager' ? managerFor_(v['Department'][0]) : ROLES[role]);

  const rec = {
    id: Utilities.getUuid(),
    respRow: e.range.getRow(),
    requester: v['Email Address'][0],             // turn on "Collect email addresses"
    amount, chain, stage: 0,
    token: newToken_(),
    status: 'PENDING',
    history: [],
  };
  appendRecord_(rec);
  notifyApprover_(rec);
}

function newToken_() {
  return Utilities.getUuid().replace(/-/g, '');
}

Turn on Collect email addresses in the form settings, or you won't know who to tell when the decision comes back. And validate the amount field in the form itself (Response validation → Number), so the script isn't parsing "about 300 euros."

appendRecord_, findRecord_ and saveRecord_ are thin helpers: an appendRow, a lookup of the ID in column A, and a setValues on that row with chain and history serialized as JSON and "last updated" set to now. The column order is the one listed in the data model above; the reminder trigger below relies on it. Keep them boring.

Step 2: the email and the confirmation page

The approver gets an email with one link — not two. That's deliberate.

const WEBAPP_URL = PropertiesService.getScriptProperties().getProperty('WEBAPP_URL');

function notifyApprover_(rec, opts) {
  const to = rec.chain[rec.stage];
  const url = WEBAPP_URL + '?id=' + rec.id + '&t=' + rec.token;
  const prefix = opts && opts.reminder ? 'Reminder: ' : '';
  MailApp.sendEmail({
    to,
    subject: prefix + 'Approval needed: ' + rec.amount + ' from ' + rec.requester,
    htmlBody:
      '<p>' + rec.requester + ' has requested <b>' + rec.amount + '</b>.</p>' +
      '<p>Step ' + (rec.stage + 1) + ' of ' + rec.chain.length + '.</p>' +
      '<p><a href="' + url + '">Review and decide</a></p>',
  });
}

Why not "Approve" and "Reject" links straight in the email? Because many corporate mail gateways and link-preview tools open every URL in an incoming message to scan it. If your approval happens on a plain page load, a security scanner can approve a 12,000 purchase before the finance lead has finished their coffee. The link should only show the request; the decision has to be a deliberate button press.

The web app:

function doGet(e) {
  const rec = findRecord_(e.parameter.id);
  if (!rec) return HtmlService.createHtmlOutput('Unknown request.');
  const t = HtmlService.createTemplateFromFile('Decide');
  t.rec = rec;
  t.token = e.parameter.t || '';
  return t.evaluate().setTitle('Approval request');
}

And Decide.html, which calls back into the script only when a button is pressed:

<p><b><?= rec.requester ?></b> requests <b><?= rec.amount ?></b>
   (step <?= rec.stage + 1 ?> of <?= rec.chain.length ?>, status <?= rec.status ?>)</p>
<textarea id="c" placeholder="Comment (required to reject)"></textarea>
<button onclick="go('APPROVE')">Approve</button>
<button onclick="go('REJECT')">Reject</button>
<p id="out"></p>
<div id="req" data-id="<?= rec.id ?>" data-t="<?= token ?>"></div>
<script>
  function go(d) {
    const c = document.getElementById('c').value;
    if (d === 'REJECT' && !c.trim()) { alert('Please add a reason.'); return; }
    const req = document.getElementById('req').dataset;
    const buttons = document.querySelectorAll('button');
    const out = document.getElementById('out');
    buttons.forEach(b => b.disabled = true);
    google.script.run
      .withSuccessHandler(msg => out.textContent = msg)
      .withFailureHandler(err => {
        out.textContent = 'Something went wrong: ' + err.message + ' Please try again.';
        buttons.forEach(b => b.disabled = false);
      })
      .decide(req.id, req.t, d, c);
  }
</script>

The request ID and token sit in data- attributes rather than inside the <script> block. Apps Script escapes template output according to where it lands, and an HTML attribute is the simplest place to get exactly the string you put in.

Deploy it as a web app, Execute as: Me, Who has access: Anyone within your domain, and store the /exec URL in Script Properties as WEBAPP_URL. The "within your domain" option only exists on Google Workspace accounts.

Step 3: the decision, with a lock and three checks

This is the function that earns the build:

function decide(id, token, decision, comment) {
  const lock = LockService.getScriptLock();
  lock.waitLock(10000);                           // one decision at a time
  try {
    const rec = findRecord_(id);
    if (!rec || rec.status !== 'PENDING') return 'This request is already closed.';
    if (token !== rec.token) return 'This link has expired: the request has moved on.';

    const approver = rec.chain[rec.stage];
    const who = Session.getActiveUser().getEmail();
    if (!who || who.toLowerCase() !== approver.toLowerCase()) {
      return 'This step is assigned to ' + approver + '.';
    }

    rec.history.push({ stage: rec.stage, by: who, decision, comment,
                       at: new Date().toISOString() });

    if (decision === 'REJECT') {
      rec.status = 'REJECTED';
    } else if (rec.stage + 1 < rec.chain.length) {
      rec.stage += 1;
      rec.token = newToken_();                    // old links die here
    } else {
      rec.status = 'APPROVED';
    }
    if (rec.status !== 'PENDING') rec.token = '';

    saveRecord_(rec);
    if (rec.status === 'PENDING') notifyApprover_(rec);
    else notifyRequester_(rec);
    return 'Recorded: ' + decision.toLowerCase() + '. Thank you.';
  } finally {
    lock.releaseLock();
  }
}

The three checks, in plain terms:

  1. Is the request still open? A rejected or completed request can't be reopened by an old email.
  2. Is this the current step's link? Each stage gets a fresh token, so the manager's link stops working the moment finance has the request. Forwarded or stale emails become harmless.
  3. Is the person clicking the person assigned? With "Execute as: Me", Apps Script only reveals the visitor's email when they're in the same Workspace domain as the script owner. That's why access is limited to your domain — it turns the identity check from optional into reliable.

If you have approvers outside your domain — an external accountant, a client signing off on a quote — getActiveUser() returns an empty string for them, and the strict check above will refuse them. The same applies to everyone if you run this on a free gmail.com account, which has no domain to restrict to. The fallback is token-only approval: the unguessable per-step token becomes the credential, much like a password-reset link. It's a reasonable trade-off for low-value approvals and the wrong one for large purchases. Decide per workflow, and write the decision down. Our guide to authentication patterns for Apps Script web apps covers the stronger options if your approvers need real logins.

The lock matters more than it looks. Without it, two people opening the same request in two tabs and clicking within a second of each other can both read "stage 1," both advance it, and send finance two emails with different tokens. With LockService, the second click waits, re-reads the record, and gets "This link has expired."

Want this scoped for your own forms? We build approval workflows as fixed-scope projects: your routing rules, your thresholds, your audit columns, delivered into your own Google account. Send us the form you're approving by hand today on WhatsApp and we'll tell you whether it's an add-on job or a build.

Step 4: reminders, so nothing sits for a week

Approval workflows rarely fail on a rejection. They fail on silence. Add a time-driven trigger that runs once a day:

function chaseStale() {
  const rows = SpreadsheetApp.getActive().getSheetByName('Approvals')
    .getDataRange().getValues().slice(1);
  const now = Date.now();
  rows.forEach(r => {
    const [id, , , , , , , status, updated] = r;
    if (status !== 'PENDING') return;
    const hours = (now - new Date(updated).getTime()) / 36e5;
    if (hours > 48) notifyApprover_(findRecord_(id), { reminder: true });
  });
}

From there, escalation is a policy choice rather than a technical one: after 96 hours, copy the approver's manager; after a week, route to a named backup. Put those thresholds in the Rules tab too, so the office manager can change them without opening the editor.

When this runs unattended, you also want to know when it breaks — a renamed column or a removed department will throw inside the trigger, and by default nobody hears about it. The patterns in our guide to Apps Script error handling, logging and alerts are exactly the layer this code needs before it goes live.

Limits worth knowing before you start

Honest constraints, from Google's published Apps Script quotas:

  • Email is the real ceiling. 100 recipients a day on a free gmail.com account, 1,500 a day on Google Workspace. A three-step chain sends about four emails per request plus reminders. That's roughly 20 requests a day on a free account and several hundred on Workspace.
  • Trigger runtime: 90 minutes a day in total on consumer accounts, 6 hours on Workspace. Each submission takes a second or two, so you won't come close.
  • 6 minutes per execution — irrelevant per decision, but relevant if you later bolt on a monthly report across thousands of rows. Our post on working around the 6-minute limit covers the chunking pattern.
  • Owner dependency. With "Execute as: Me," every email goes out from the owner's account, and the triggers belong to that account. Own the script with a shared operations account, not with whoever set it up, or the workflow leaves when they do.

Build, add-on or AppSheet?

OptionBest forWhere it stops
Form Approvals and similar add-onsLow volume, standard sequential or parallel chains, no developerPriced per response tier; the routing logic and history live in the vendor's system
Drive Approvals (native)Approving a file: a contract, a policy, a designNot for form responses; Business Standard and above
AppSheetYou want a mobile app front end with approval actionsPer-user licensing once you outgrow what's bundled with your edition — see our AppSheet vs Apps Script comparison
Apps Script (this post)Rules specific to your business; approvals that must update other sheets, budgets or stockSomeone has to own the code; external approvers need the token-only trade-off

The strongest argument for building isn't the subscription you skip. It's what happens after the approval. An approved purchase request can decrement a department budget, add a line to a reorder list in a Sheets-based stock tracker, or create a draft invoice in a billing system that runs on Google Workspace — all in the same script, in the same account, with no connector in between. That's where a form-plus-add-on setup runs out, and it's the same pattern we describe in our broader guide to replacing subscription SaaS with Google Workspace. If you're still weighing it, the build vs buy decision framework is the honest version of that calculation, and the complete guide to production Apps Script covers the deployment and ownership basics this post assumes.

Want this built for your business?

If requests in your team currently move through forwarded emails and "did you see my message?", the useful first step is a short conversation about your actual rules: who approves what, above which amount, and what should happen once something is approved.

If the honest answer is "the free tier of an add-on is enough," that's what we'll tell you. If it isn't, you get a fixed-scope build that lives in your Google account — and see what Apps Script development actually costs before you talk to anyone, including us.

Frequently Asked Questions

Does Google Forms have a built-in approval workflow?

No. Google Forms collects responses and can send them to a Sheet, but it has no concept of a request being pending, approved or rejected, and no way to route a response to a specific person based on its answers. Google's nearest native feature is Drive Approvals, which works on files rather than form responses and is only available on Business Standard, Business Plus, Enterprise Standard and Plus, Education Plus and the Essentials editions. For form-based requests — expenses, leave, purchases — the gap is filled either by a Marketplace add-on or by a small Apps Script project attached to the response sheet.

How do I make the approver depend on the answers in the form?

Keep the routing rules in a sheet, not in the code. A typical setup has an Approvers tab mapping each department to its manager, and a rules table mapping amount bands to a chain of roles — for example, up to 500 needs the manager only, up to 5,000 adds finance, anything above adds the director. When a response arrives, an installable form-submit trigger reads the department and amount, looks up the chain, and stores it with the request. Changing who approves what then means editing a cell, not redeploying a script.

Is it safe to put Approve and Reject links directly in the email?

Not as one-click links. Corporate email security scanners and link-preview tools routinely open every URL in an incoming message to check it, and if that URL performs the approval on a plain GET request, a robot can approve a purchase before the human has read the email. The safe pattern is a link that opens a small confirmation page showing the request, where the actual decision is a button press that calls the script. Combine that with a per-step token so old links stop working once the request has moved on, and with a check of the signed-in approver's email where your setup allows it.

How many approval requests can Apps Script handle?

More than most teams generate. The practical ceiling is email: Apps Script allows 100 email recipients per day on a free gmail.com account and 1,500 per day on a Google Workspace account. A three-step approval sends roughly four emails per request, plus reminders, so a free account handles around 20 requests a day and a Workspace account several hundred. Each decision takes a second or two, far from the 6-minute execution limit, and the cap of 30 simultaneous executions only matters if dozens of approvers click at the same moment. Note that the approver identity check described here needs a Workspace domain; on a free account you fall back to token-only links.

When should I use a paid add-on instead of building this?

When your volume is low, your rules are simple, or nobody will own the script. The Form Approvals add-on, for example, lists a free tier of 20 responses a month and paid plans from roughly $7 to $65 a month depending on volume and billing term (their published pricing, checked October 2026) — at a handful of requests a month that is cheaper than any build. Building pays off when the routing logic is specific to your business, when approvals need to write into other systems such as a budget sheet or an inventory reorder list, or when the volume would push you into the higher tiers indefinitely.

Stay Updated

Get the latest insights on AI, e-commerce, and Magento delivered to your inbox.

We value your privacy

We use essential cookies to make our site work. With your consent, we may also use non-essential cookies to improve user experience and analyze website traffic. By clicking "Accept", you agree to our website's cookie use as described in our Cookie Policy.