Approvals in a spreadsheet usually look like this: someone types “ok” in column H, someone else types “approved!!” in column J, and nobody’s sure which row is actually done. The fix isn’t a new app. It’s one menu item that stamps a real status, optional note, and tells the requester — without anyone touching formulas or remembering the column letters.
This is a different shape from treating a sheet like a job queue. A queue is for scripts claiming work. This is for humans clicking approve on the row in front of them.
TL;DR
- Status column with Pending / Approved / Rejected (data validation list)
- Custom menu: Approve row / Reject row acting on the active row
- Optional prompt for a note; stamp who + when
- Toast for the clicker; optional email to the requester via a small HTML template
- Happy path is click-simple; weird cases are handled and commented
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.
Sheet shape
A Id | B Requester | C Email | D Item | E Status | F Note | G DecidedBy | H DecidedAt
Put a dropdown on Status (Pending, Approved, Rejected). New rows start Pending.
The menu
function onOpen() {
SpreadsheetApp.getUi()
.createMenu('Approvals')
.addItem('Approve row', 'approveActiveRow')
.addItem('Reject row', 'rejectActiveRow')
.addToUi();
}
function approveActiveRow() { decideActiveRow_('Approved'); }
function rejectActiveRow() { decideActiveRow_('Rejected'); }
Decide the active row
var APPROVAL_SHEET = 'Requests';
function decideActiveRow_(newStatus) {
var ss = SpreadsheetApp.getActive();
var sheet = ss.getActiveSheet();
var ui = SpreadsheetApp.getUi();
// Edge: menu used on the wrong tab
if (sheet.getName() !== APPROVAL_SHEET) {
ui.alert('Open the "' + APPROVAL_SHEET + '" tab and select a request row.');
return;
}
var rowIndex = sheet.getActiveRange().getRow();
// Edge: header row or blank row
if (rowIndex < 2) {
ui.alert('Select a request row (not the header).');
return;
}
var headers = sheet.getRange(1, 1, 1, sheet.getLastColumn()).getValues()[0];
var col = function (name) {
var i = headers.indexOf(name);
if (i === -1) throw new Error('Missing column: ' + name);
return i + 1;
};
var statusCell = sheet.getRange(rowIndex, col('Status'));
var current = String(statusCell.getValue() || '').trim();
// Edge: already decided — don’t silently flip Approved ↔ Rejected
if (current && current !== 'Pending') {
var again = ui.alert(
'This row is already ' + current + '. Change it to ' + newStatus + '?',
ui.ButtonSet.YES_NO
);
if (again !== ui.Button.YES) return;
}
// Optional note (Reject usually wants one)
var noteResp = ui.prompt(
newStatus + ' — optional note for the requester',
ui.ButtonSet.OK_CANCEL
);
if (noteResp.getSelectedButton() !== ui.Button.OK) return;
var note = noteResp.getResponseText().trim();
// Lock so two approvers don’t race the same row
var lock = LockService.getDocumentLock();
if (!lock.tryLock(5000)) {
ui.alert('Someone else is updating approvals. Try again in a moment.');
return;
}
try {
var now = Utilities.formatDate(new Date(), ss.getSpreadsheetTimeZone(), 'yyyy-MM-dd HH:mm');
var who = Session.getActiveUser().getEmail() || 'unknown';
statusCell.setValue(newStatus);
sheet.getRange(rowIndex, col('Note')).setValue(note);
sheet.getRange(rowIndex, col('DecidedBy')).setValue(who);
sheet.getRange(rowIndex, col('DecidedAt')).setValue(now);
SpreadsheetApp.flush();
} finally {
lock.releaseLock();
}
var row = {
id: sheet.getRange(rowIndex, col('Id')).getValue(),
requester: sheet.getRange(rowIndex, col('Requester')).getValue(),
email: sheet.getRange(rowIndex, col('Email')).getValue(),
item: sheet.getRange(rowIndex, col('Item')).getValue(),
status: newStatus,
note: note
};
ss.toast(row.item + ' → ' + newStatus, 'Approvals', 6);
notifyRequester_(row);
}
Tell the requester (HTML-safe)
Build the notice with fillHtmlTemplate so a note with < or & doesn’t break the email.
var NOTICE_HTML =
'<p>Hi {{requester}},</p>' +
'<p>Your request <strong>{{item}}</strong> (#{{id}}) was <strong>{{status}}</strong>.</p>' +
'<p>{{note}}</p>';
function notifyRequester_(row) {
// Edge: no email on file — toast already told the approver; don’t throw
if (!row.email) return;
var html = fillHtmlTemplate(NOTICE_HTML, {
requester: row.requester,
item: row.item,
id: row.id,
status: row.status,
note: row.note || '—'
});
GmailApp.sendEmail(row.email, 'Request ' + row.status + ': ' + row.item, 'Please view in HTML.', {
htmlBody: html,
name: 'Ops approvals'
});
}
Edge cases (in comments, not in the happy path)
- Wrong tab / header row — alert and exit early
- Already decided — confirm before flipping
- Cancel on the note prompt — no write
- Two approvers at once — document lock + short
tryLock - Missing email — skip notice, still update the row
- Protected Status column — run as someone who can edit, or protect everything except Status/Note for approvers
Make it feel native
- Conditional formatting: Approved = green, Rejected = red, Pending = amber
- Freeze the header row so “select a row” is obvious
- Filter view: “Pending only” for the daily approver sweep
Soft Co-Pilot note
NitroGAS puts fillHtmlTemplate in the snippet catalog. Ask Co-Pilot for teammate-readable status messages — the toast text, the alert copy, and the requester email — so ops see plain English, not “Exception: Range not found.” Free core; Co-Pilot is a pass when you want that copy done.
Closing checklist
- Status dropdown: Pending / Approved / Rejected
- Approve / Reject menu on the active row
- Optional note + DecidedBy + DecidedAt stamped
- Toast for the approver; optional HTML email to the requester
- Edge cases handled with early returns and comments
Happy Coding!


