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
ApprovalsGoogle Sheet with a header row and these columns in order:id,title,description,requestedBy,decision,decidedAt. ThedecisionanddecidedAtcolumns stay blank until the client clicks. - A short, unguessable value in the
idcolumn for each row — a UUID works well, so the link cannot be guessed. - An HTML file named
Approvalin 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
doGetruns whenever someone opens the web app URL. It reads theidquery parameter — the part after?id=in the link.- If there is no
id, it returns a short message instead of an empty page. findRequestreads the wholeApprovalssheet and finds the row whose first column matches theid, returning it as an object keyed by header name. If nothing matches,doGetshows “Request not found”.- The matching request is passed into the
Approvaltemplate, which renders the title, description and requester on a branded page. - When the client clicks a button, the page calls
decideon the server viagoogle.script.run, passing theidand the chosen decision. decidefinds 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:
| id | title | description | requestedBy | decision | decidedAt |
|---|---|---|---|---|---|
| a1b2c3 | Logo draft v2 | Updated mark with the new colour | Priya |
The client opens https://script.google.com/.../exec?id=a1b2c3, sees the
request, and clicks Approve. The same row afterwards:
| id | title | description | requestedBy | decision | decidedAt |
|---|---|---|---|---|---|
| a1b2c3 | Logo draft v2 | Updated mark with the new colour | Priya | Approved | 2026-05-25 14:30 |
Run it
This is an on-demand web app, so there is no trigger to set:
- In the Apps Script editor, click Deploy > New deployment.
- Choose Web app, set Execute as to yourself and Who has access to Anyone so external clients can open the link.
- 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
idis 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
decidethat bails out if thedecisioncell is already filled, if a final answer matters. deciderunsgetDataRangeon 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
decideruns as you.
Related
Build an interactive quiz or assessment app
Run Northwind tests with scoring and feedback — questions in a Sheet, results in another.
Updated Nov 4, 2025
Build a multi-page web app with routing
Structure a real Northwind app across views — query-param routing, shared layout.
Updated Oct 31, 2025
Build a form-to-PDF web service
Convert Northwind form submissions to PDFs on the fly — POST in, PDF out.
Updated Oct 27, 2025
Build an expiring secure-download generator
Issue time-limited Northwind links via a web app — token in URL, server-side check.
Updated Oct 23, 2025
Build a guided onboarding tour for Sheets
Walk Northwind's first-time users through dialogs — each step explains one feature.
Updated Oct 19, 2025