Build a shipment and package tracker
Pull carrier tracking statuses into a Northwind Sheet — for outbound deliverables.
Published Oct 3, 2025
Northwind ships physical deliverables — printed reports, sample kits, signed contracts — and the team likes to know where each parcel is without logging into a carrier site for every tracking number. Checking by hand is dull and easy to skip, so a customer asking “has it arrived?” often gets a vague answer.
This script keeps a Shipments Sheet up to date on its own. It walks every row,
skips anything already delivered, calls the carrier’s tracking API for the rest,
and writes back the latest status and the time it was checked. Run it on a
schedule and the Sheet becomes a live board of every parcel in transit.
What you’ll need
- A
Shipmentsspreadsheet with a header row containing at least:carrier,trackingId,status, andlastChecked. - A carrier tracking API endpoint that accepts a carrier and tracking ID and
returns JSON with a
statusfield. Swap the example URL for your provider’s. - If the API needs a key, store it in Script Properties rather than the code — see Store API keys and secrets securely.
The script
// The spreadsheet holding outbound shipments.
const SHIPMENTS_SHEET_ID = '1abcShipmentsId';
// The carrier tracking API base URL. Returns JSON with a "status" field.
const TRACKING_API = 'https://api.tracking.example/v1/track';
// Rows already at this status are skipped — no need to re-check.
const FINAL_STATUS = 'delivered';
/**
* Walks every shipment row, queries the carrier API for anything not
* yet delivered, and writes back the latest status and check time.
*/
function updateTracking() {
const sheet = SpreadsheetApp.openById(SHIPMENTS_SHEET_ID).getSheets()[0];
const values = sheet.getDataRange().getValues();
const [h, ...rows] = values;
// Bail out if there is nothing but a header row.
if (!rows.length) {
Logger.log('No shipments to track — nothing to do.');
return;
}
// Map header names to column indexes so the code reads clearly.
const col = Object.fromEntries(h.map((k, i) => [k, i]));
rows.forEach((r, i) => {
// Skip parcels that have already arrived.
if (r[col.status] === FINAL_STATUS) return;
// Ask the carrier API for this tracking number.
const url = TRACKING_API +
'?carrier=' + encodeURIComponent(r[col.carrier]) +
'&id=' + encodeURIComponent(r[col.trackingId]);
const res = UrlFetchApp.fetch(url, { muteHttpExceptions: true });
// A non-200 response means leave the row untouched this round.
if (res.getResponseCode() !== 200) return;
const data = JSON.parse(res.getContentText());
// Write the fresh status and timestamp back into the value grid.
values[i + 1][col.status] = data.status;
values[i + 1][col.lastChecked] = new Date();
});
// One write for the whole grid — far faster than cell by cell.
sheet.getDataRange().setValues(values);
Logger.log('Tracking update complete.');
}
How it works
updateTrackingopens the shipments sheet and reads the entire grid in onegetValuescall, splitting off the header row.- If there are no data rows it logs a message and stops.
- It builds a
collookup so the rest of the code refers to columns by name (col.status) rather than fragile numeric indexes. - For each row it skips anything already marked
delivered— those parcels never need another API call. - For every remaining row it builds the tracking URL, URL-encoding the carrier
and ID, and fetches it with
muteHttpExceptionsso a bad response does not halt the loop. - On a successful response it parses the JSON and writes the new
statusand the current time into the in-memoryvaluesgrid (offset by one for the header). - After the loop it pushes the whole grid back with a single
setValuescall — one write instead of one per row.
Example run
Before a run the Shipments sheet might look like this:
| carrier | trackingId | status | lastChecked |
|---|---|---|---|
| Royal Mail | RM12345 | in transit | 2026-05-24 06:00 |
| DPD | DP98765 | delivered | 2026-05-23 18:00 |
| FedEx | FX55512 | label created | 2026-05-24 06:00 |
After a run the DPD row is left alone, and the other two are refreshed:
| carrier | trackingId | status | lastChecked |
|---|---|---|---|
| Royal Mail | RM12345 | out for delivery | 2026-05-25 10:00 |
| DPD | DP98765 | delivered | 2026-05-23 18:00 |
| FedEx | FX55512 | in transit | 2026-05-25 10:00 |
Trigger it
Run this on a time-based trigger so the board stays current:
- In the Apps Script editor, open Triggers (the clock icon).
- Add a trigger for
updateTracking. - Choose a time-driven trigger, hour timer, every 4 hours — frequent enough to be useful without burning through API quota.
Watch out for
- API quotas are real. Every non-delivered row is one request per run; a long in-transit list checked every four hours adds up. Widen the interval or batch the calls if you hit a limit.
- The script trusts the API’s
statusstring. If the carrier returns a different shape or field name, parsing will fail — logres.getContentText()once to confirm the structure before relying on it. - A failed request leaves the row’s old status in place. That is deliberate, but it means a persistently broken endpoint will show stale data with no warning.
setValuesrewrites the whole grid, so do not add formulas to the tracked range — they will be overwritten with their last computed values.UrlFetchAppallows 20,000 calls a day on consumer accounts. A large shipments sheet checked frequently can approach that ceiling.
Related
Sync calendar bookings with Calendly
Bridge Google Calendar and Calendly — Northwind bookings on either side appear on both.
Updated Jan 7, 2026
Connect to an air-quality and weather feed
Build a Northwind environmental dashboard — current London AQI plus 5-day forecast.
Updated Dec 30, 2025
Build a podcast and media stats tracker
Pull Northwind's podcast download numbers across platforms into a single sheet.
Updated Dec 10, 2025
Track real-estate listings for new matches
Monitor property feeds for Northwind office hunts — alert when a match appears.
Updated Nov 28, 2025
Translate columns with a translation API
Localise Northwind text in bulk without manual work — via Google Translate or DeepL.
Updated Nov 24, 2025