Build a low-stock reorder alert system
Watch Inventory levels and email the procurement owner when any SKU falls below its threshold.
Published Oct 18, 2025
Northwind’s print shop runs on consumables — paper stock, ink cartridges, mounting board. Run out of any one of them mid-job and the job stops. The person handling procurement cannot watch the inventory sheet all day, so a low stock level usually gets noticed too late, when someone reaches for the last ream.
This script does the watching. It reads an Inventory sheet, compares each
item’s quantity on hand against its own reorder threshold, and emails
procurement a single digest of everything that needs reordering. A
lastAlerted date stops the same item from nagging more than once a day, so the
alert stays useful instead of becoming background noise.
What you’ll need
- A Google Sheet with one tab named
Inventoryand these columns in row 1:sku,name,onHand,reorderAt,lastAlerted.onHand— current quantity in stock.reorderAt— the threshold; at or below this, the item needs reordering.lastAlerted— left blank; the script fills it with the date of the last alert.
- The Sheet’s ID, taken from its URL.
- The email address of whoever owns procurement.
The script
// The spreadsheet that holds the Inventory tab.
const INVENTORY_SHEET_ID = '1abcInventoryId';
// Who gets the reorder alert.
const PROCUREMENT_EMAIL = '[email protected]';
/**
* Reads the Inventory sheet, finds items at or below their reorder
* threshold, and emails procurement a single digest. Records the alert
* date per item so nothing is flagged twice in the same day.
*/
function checkLowStock() {
const sheet = SpreadsheetApp.openById(INVENTORY_SHEET_ID).getSheets()[0];
// 1. Read the whole sheet. The first row is the header.
const values = sheet.getDataRange().getValues();
const [header, ...rows] = values;
// 2. Stop if the sheet has a header but no stock rows.
if (!rows.length) {
Logger.log('Inventory sheet is empty — nothing to check.');
return;
}
// 3. Map column names to their index, so the code reads by name
// rather than by fragile numeric positions.
const col = Object.fromEntries(header.map((h, i) => [h, i]));
// 4. Today's date as a plain string, for the lastAlerted check.
const today = Utilities.formatDate(new Date(), 'GMT', 'yyyy-MM-dd');
// 5. Build the alert lines and stamp each flagged item with today's date.
const lines = [];
rows.forEach((r, i) => {
// Skip items still above their threshold.
if (r[col.onHand] > r[col.reorderAt]) return;
// Skip items already alerted today.
if (r[col.lastAlerted] === today) return;
lines.push('• ' + r[col.name] + ' (' + r[col.sku] + '): '
+ r[col.onHand] + ' left');
// i is the row index within rows; +1 maps it back to values.
values[i + 1][col.lastAlerted] = today;
});
// 6. If anything is low, email the digest and save the updated dates.
if (lines.length) {
GmailApp.sendEmail(
PROCUREMENT_EMAIL,
'Reorder ' + lines.length + ' item' + (lines.length > 1 ? 's' : ''),
lines.join('\n')
);
sheet.getDataRange().setValues(values);
Logger.log('Alerted on ' + lines.length + ' low-stock item(s).');
} else {
Logger.log('All stock levels are healthy.');
}
}
How it works
checkLowStockopens the inventory spreadsheet and reads the entire tab in one call, splitting off the header row from the stock rows.- If there are no stock rows, it logs a message and stops.
- It builds a
collookup mapping each column name to its index, so the rest of the code refers tocol.onHandrather than a hard-coded number — adding a column later will not break it. - It formats today’s date as a
yyyy-MM-ddstring for comparison against thelastAlertedcolumn. - It walks each row. An item is skipped if its
onHandis still abovereorderAt, or if it was already alerted today. Anything that passes both checks is added to the digest, and itslastAlertedcell in the in-memoryvaluesarray is set to today. - If the digest has any lines, it emails procurement a single message listing
every low item, then writes the updated
valuesback so the newlastAlerteddates persist.
Example run
Say the Inventory tab looks like this when the check runs:
| sku | name | onHand | reorderAt | lastAlerted |
|---|---|---|---|---|
| PPR-A4 | A4 paper (ream) | 3 | 10 | |
| INK-CYN | Cyan ink | 12 | 8 | |
| MNT-BLK | Black mount board | 5 | 5 |
A4 paper is below its threshold and black mount board is exactly at it, so procurement receives:
Subject: Reorder 2 items • A4 paper (ream) (PPR-A4): 3 left • Black mount board (MNT-BLK): 5 left
Both rows have their lastAlerted cell updated to today’s date. If the check
runs again an hour later with the same levels, those two items are skipped and
no duplicate email is sent. Cyan ink, at 12 against a threshold of 8, is never
mentioned.
Trigger it
Run this on an hourly schedule during the working day:
- In the Apps Script editor, open Triggers (the clock icon).
- Click Add Trigger.
- Choose the
checkLowStockfunction, an event source of Time-driven, an Hour timer, and Every hour. - Save and approve the authorisation prompt the first time.
A trigger cannot be limited to business hours alone, so it will also run
overnight — harmless, since the lastAlerted check means it simply finds
nothing new to send.
Watch out for
- The
lastAlertedguard is per calendar day. If A4 paper stays low for a week, procurement is reminded once each day until it is restocked — useful as a nudge, but turn it into a one-off alert if you prefer by clearing the date only whenonHandrises back above the threshold. - Dates are compared as
GMTstrings. If your team works in a very different timezone, the “new day” boundary may land at an odd local hour — switch the timezone inUtilities.formatDateto match. - The whole sheet is read and written with
getDataRange. That is fine for a print shop’s inventory but would be slow for tens of thousands of rows; batch the write if the sheet ever gets that large. - Column names must match the header row exactly, including capitalisation.
Rename a header and the matching
col.lookup quietly becomesundefined. - If a manual edit is in progress when the script writes back, the
setValuescall could overwrite an unsaved change. Hourly runs make a clash unlikely, but avoid editing the sheet exactly on the hour.
Related
Build an OKR tracker with progress rollups
Aggregate Northwind's key-result progress into objective-level scores automatically.
Updated Jan 14, 2026
Build a recurring-task generator
Spawn new Northwind task rows on a daily or weekly cadence from a Recurring sheet.
Updated Jan 10, 2026
Build a multi-sheet search-and-jump tool
Find a value across every tab of a workbook and click through to the cell that contains it.
Updated Jan 7, 2026
Auto-rebuild grouped summaries on edit
Refresh pivot-style rollups the instant data changes — no manual recompute.
Updated Jan 4, 2026
Build a data-quality scorecard
Grade any sheet on completeness, validity, and freshness — surface gaps as a single score.
Updated Dec 31, 2025