appscript.dev
Automation Intermediate Sheets

Build a branded approval interface

Approve Northwind requests through a custom UI — clients click, decision is logged.

Published Nov 8, 2025

Northwind sends clients a steady stream of things to sign off — design drafts, quotes, scope changes. Chasing those approvals by email is slow and messy: the decision lives in a reply somewhere, nobody is sure if it was a yes or a maybe, and the project manager has to hunt through a thread to find out.

This script turns each approval into a single link. The client opens it, sees the request laid out on a branded page, and clicks Approve or Reject. The decision and a timestamp land straight back in the tracking sheet, so the status is always one glance away — no inbox archaeology required.

What you’ll need

  • An Approvals Google Sheet with a header row and these columns in order: id, title, description, requestedBy, decision, decidedAt. The decision and decidedAt columns stay blank until the client clicks.
  • A short, unguessable value in the id column for each row — a UUID works well, so the link cannot be guessed.
  • An HTML file named Approval in the same Apps Script project (see below).
  • The project deployed as a web app so clients can open it.

The script

// The spreadsheet that holds your pending approvals.
const APPROVALS_SHEET_ID = '1abcApprovalsId';

// Column positions (1-based) for the two cells the client writes to.
const DECISION_COL = 5;   // "decision"
const DECIDED_AT_COL = 6; // "decidedAt"

/**
 * Web app entry point. Reads the request id from the URL, looks the
 * request up, and renders the branded approval page.
 */
function doGet(e) {
  const id = e.parameter.id;

  // Bail out early if the link is missing its id.
  if (!id) return HtmlService.createHtmlOutput('Missing id');

  // Find the matching request; show a clear message if it has gone.
  const request = findRequest(id);
  if (!request) return HtmlService.createHtmlOutput('Request not found');

  // Pass the request into the Approval template and render it.
  const t = HtmlService.createTemplateFromFile('Approval');
  t.request = request;
  return t.evaluate();
}

/**
 * Records a decision against a request. Called from the page when the
 * client clicks Approve or Reject (via google.script.run).
 */
function decide(id, decision) {
  const sheet = SpreadsheetApp.openById(APPROVALS_SHEET_ID).getSheets()[0];
  const values = sheet.getDataRange().getValues();

  // Locate the row whose id column matches.
  const row = values.findIndex((r) => r[0] === id);
  if (row === -1) return;

  // Write the decision and the moment it was made.
  sheet.getRange(row + 1, DECISION_COL).setValue(decision);
  sheet.getRange(row + 1, DECIDED_AT_COL).setValue(new Date());
}

/**
 * Looks up a single request by id and returns it as a plain object
 * keyed by the sheet's header names.
 */
function findRequest(id) {
  const [h, ...rows] = SpreadsheetApp.openById(APPROVALS_SHEET_ID).getSheets()[0]
    .getDataRange().getValues();
  const r = rows.find((r) => r[0] === id);
  return r ? Object.fromEntries(h.map((k, i) => [k, r[i]])) : null;
}

The Approval HTML file holds the branded page. A minimal version:

<!DOCTYPE html>
<html>
  <body style="font-family: Arial, sans-serif; max-width: 480px; margin: 40px auto;">
    <h2><?= request.title ?></h2>
    <p><?= request.description ?></p>
    <p style="color: #666;">Requested by <?= request.requestedBy ?></p>

    <button onclick="send('Approved')">Approve</button>
    <button onclick="send('Rejected')">Reject</button>
    <p id="status"></p>

    <script>
      // Send the decision back to the server, then confirm to the client.
      function send(decision) {
        google.script.run
          .withSuccessHandler(function () {
            document.getElementById('status').textContent =
              'Recorded: ' + decision + '. You can close this page.';
          })
          .decide('<?= request.id ?>', decision);
      }
    </script>
  </body>
</html>

How it works

  1. doGet runs whenever someone opens the web app URL. It reads the id query parameter — the part after ?id= in the link.
  2. If there is no id, it returns a short message instead of an empty page.
  3. findRequest reads the whole Approvals sheet and finds the row whose first column matches the id, returning it as an object keyed by header name. If nothing matches, doGet shows “Request not found”.
  4. The matching request is passed into the Approval template, which renders the title, description and requester on a branded page.
  5. When the client clicks a button, the page calls decide on the server via google.script.run, passing the id and the chosen decision.
  6. decide finds the row again and writes the decision into column 5 and a timestamp into column 6, so the tracking sheet updates instantly.

Example run

The Approvals sheet before the client clicks:

idtitledescriptionrequestedBydecisiondecidedAt
a1b2c3Logo draft v2Updated mark with the new colourPriya

The client opens https://script.google.com/.../exec?id=a1b2c3, sees the request, and clicks Approve. The same row afterwards:

idtitledescriptionrequestedBydecisiondecidedAt
a1b2c3Logo draft v2Updated mark with the new colourPriyaApproved2026-05-25 14:30

Run it

This is an on-demand web app, so there is no trigger to set:

  1. In the Apps Script editor, click Deploy > New deployment.
  2. Choose Web app, set Execute as to yourself and Who has access to Anyone so external clients can open the link.
  3. Copy the web app URL and append ?id= plus the request id from the sheet — that full link is what you send the client.

Watch out for

  • The id is the only thing protecting a request. Use long, random values, not sequential numbers, or anyone could open another client’s approval by editing the URL.
  • There is no check that a decision was already made. A client can reopen the link and approve something they rejected. Add a guard in decide that bails out if the decision cell is already filled, if a final answer matters.
  • decide runs getDataRange on every click. That is fine for a few hundred rows; for a very large sheet, store the row number in the link instead of searching for the id each time.
  • “Execute as me” means the script writes to the sheet with your permissions — the client never needs access to the spreadsheet itself, which is the point, but it also means a buggy decide runs as you.

Related