Staff keep using the spreadsheet they already know. WordPress stays current automatically.
Most WordPress developers building inventory systems think about the admin interface first. What they should think about is: who is actually entering the data, what tool do they already use, and how do we meet them there? For most small businesses, the answer is a spreadsheet — usually Google Sheets.
Before the sync was built, the Anchor dealership's inventory workflow was: update the spreadsheet (which the entire staff could access and edit), then manually re-enter every change in WordPress admin. Price change? Two places. New unit added? Two places. Unit sold and needs to come down? Two places. That's where the 4-hour inventory update time came from — not from any one change being hard, but from duplicating every change across two systems.
The staff were not going to stop using the spreadsheet. It was their operational system. The solution wasn't to replace it — it was to make WordPress read from it automatically.
A standalone Google Apps Script lives in its own Google Drive file, runs independently, and must authenticate to any Sheets it needs to access. A bound script lives inside the Google Sheet itself — it's created via Extensions → Apps Script from within the sheet. It gets direct access to the sheet's data without any OAuth complexity, runs in the context of the sheet owner's Google account, and appears under the Extensions menu in the sheet's UI.
For a Sheets-to-WordPress sync, bound is the right choice. The script runs with the sheet's permissions, no service account required, and staff can trigger a sync manually from the Extensions menu if they want an immediate push.
The script has three core functions: a row parser, a WordPress upsert function, and a scheduler.
function rowToObject(headers, row) {
var obj = {};
headers.forEach(function(header, i) {
obj[header] = row[i] !== undefined ? row[i] : '';
});
return obj;
}
function getInventoryData() {
var sheet = SpreadsheetApp.getActiveSpreadsheet()
.getSheetByName('Inventory');
var data = sheet.getDataRange().getValues();
var headers = data[0].map(function(h) {
return h.toString().trim().toLowerCase().replace(/\s+/g, '_');
});
return data.slice(1)
.filter(function(row) { return row[0] !== ''; })
.map(function(row) { return rowToObject(headers, row); });
}
function upsertToWordPress(unit) {
var props = PropertiesService.getScriptProperties();
var baseUrl = props.getProperty('WP_BASE_URL');
var auth = getAuthHeader();
// Search for existing post by stock number
var searchUrl = baseUrl + '/wp-json/wp/v2/[client]_inventory'
+ '?meta_key=[client]_stock_number'
+ '&meta_value=' + encodeURIComponent(unit.stock_number)
+ '&per_page=1';
var searchResp = UrlFetchApp.fetch(searchUrl, {
headers: { 'Authorization': auth },
muteHttpExceptions: true
});
var existing = JSON.parse(searchResp.getContentText());
var postId = (existing.length > 0) ? existing[0].id : null;
var payload = {
title: unit.year + ' ' + unit.make + ' ' + unit.model,
status: unit.status === 'sold' ? 'draft' : 'publish',
meta: {
'[client]_price': parseFloat(unit.price) || 0,
'[client]_stock_number': unit.stock_number,
'[client]_year': parseInt(unit.year) || 0,
'[client]_make': unit.make,
'[client]_model': unit.model,
'[client]_condition': unit.condition,
'[client]_sort_priority': parseInt(unit.sort_priority) || 0
}
};
var method = postId ? 'put' : 'post';
var url = postId
? baseUrl + '/wp-json/wp/v2/[client]_inventory/' + postId
: baseUrl + '/wp-json/wp/v2/[client]_inventory';
UrlFetchApp.fetch(url, {
method: method,
headers: {
'Authorization': auth,
'Content-Type': 'application/json'
},
payload: JSON.stringify(payload),
muteHttpExceptions: true
});
}
WordPress Application Passwords (built in since WP 5.6) create a separate credential for API access that isn't the admin account password. Generate one at Users → Profile → Application Passwords. The credential is Base64-encoded as username:app_password and sent as a Bearer token.
function getAuthHeader() {
var props = PropertiesService.getScriptProperties();
var username = props.getProperty('WP_USERNAME');
var appPass = props.getProperty('WP_APP_PASSWORD');
return 'Basic ' + Utilities.base64Encode(username + ':' + appPass);
}
Credentials go in Script Properties (Extensions → Apps Script → Project Settings → Script Properties), never hardcoded. A hardcoded credential in a bound script is readable by anyone with edit access to the sheet.
Google Apps Script has its own IDE, but it's not under version control by default. clasp (Command Line Apps Script) pulls the bound script to your local filesystem and pushes changes back. The script is then in your git repository like any other code.
# Install and authenticate
npx -y @google/clasp@latest login --no-localhost
# Pull existing bound script
npx clasp clone <SCRIPT_ID>
# Push local changes back to the bound script
npx clasp push
The spreadsheet was already the operational system. The staff knew how to use it, had their own column order preferences, and were updating it daily. The sync doesn't change their workflow at all — they update the sheet exactly as before. WordPress reflects those changes on the next sync run (triggered by a time-based Apps Script trigger, set to run every hour or twice daily).
This is what produces the 60× speedup. Not faster typing — eliminating the duplicate entry entirely. The staff does the work once, in the tool they already use, and the website updates itself.
The sync script has been running in production since Era 1. It runs twice daily on a time-based trigger. When a unit's price changes in the sheet, the website price reflects it within 12 hours — without anyone touching WordPress admin. When a unit sells and the sheet marks it sold, the listing automatically moves to draft (no longer publicly visible) on the next sync.
In the first month after deployment, inventory update time dropped from roughly 4 hours per session to about 4 minutes — the time it takes to update the spreadsheet. The 60× speedup comes entirely from eliminating the duplication step.
muteHttpExceptions: true on every UrlFetchApp call. Without it, any non-2xx response throws an uncaught exception that terminates the sync mid-run and leaves inventory in a partial state.Every lesson stays free — no account, no paywall, no email gate, ever. But if you’d rather have this system standing on your business than wire all 48 lessons yourself, leave your email. We’ll send you a direct line to a build — and you’ll be first to hear when we add new tools to the curriculum.
None of this gates a single lesson. The curriculum was free before you got here and it stays that way.
You came here to understand the system, and now you do. If you’d rather have it standing on your business than spend the next three months wiring it yourself, GAP Concierge is the same architecture from these lessons — a white-label AI agent that knows your catalog and captures your leads — set up for you, from $97/mo.
See GAP Concierge →