=GOLDPRICE() returns the live XAU/USD price per troy ounce from goldprice.dev in any cell after adding the Apps Script. Free prices lag 15 minutes. IMPORTDATA cannot read this JSON endpoint, so a custom function is needed. GOLDHISTORY() returns 30-day daily closes; GOLDPRICEDELTA() returns signed price and percentage deltas against a normalized reference cell. No API key is stored. Run installGoldRefresh() once to bind one refresh cell and create a quota-safe four-hour trigger.
Start without putting an API key in the Sheet
The XAU spot endpoint and a 30-day daily-history window both work anonymously, so this tutorial stores no credential in Apps Script. Anonymous access uses the Free controls: 10 requests per minute, 100 per hour, and 5,000 per month per IP. A four-hour workbook fits that budget.
Google may route several Sheets users through shared egress, so anonymous capacity can vary. If a shared or production workbook needs a predictable account-level bucket, call your own server-side proxy and keep the goldprice.dev key there. Do not paste a bearer token into a bound script that other editors can inspect. A free account key carries the same 5,000-call monthly allowance under your account rather than a shared IP.
The steps below work without a key. A free API key gives you your own quota. Longer history and higher limits need paid plans.
Add the three gold-price functions to Apps Script
Open your Sheet and click Extensions → Apps Script. Delete the default function myFunction() {} and paste the code below. It defines GOLDPRICE(metal, currency, refreshToken) for current spot, GOLDHISTORY(fromDate, toDate, refreshToken) for chart-ready daily closes, and GOLDPRICEDELTA(referencePrice, metal, currency, refreshToken) for signed price and percentage deltas. The functions cache identical requests via CacheService, send no Authorization header, and return plain error strings instead of throwing.
For quantities, purchase costs, and indicative valuations in one workbook, use the gold portfolio tracker template.
// google-sheets: Apps Script Code.gsconst SPOT_CACHE_TTL = 55; // Cache successful spot responses for 55 seconds.const BARS_CACHE_TTL = 300; // Cache successful daily bars for 5 minutes.const REFRESH_RANGE_NAME = 'GOLDPRICE_REFRESH_TOKEN';const SPREADSHEET_ID_KEY = 'GOLDPRICE_SPREADSHEET_ID';const INSTALLER_EMAIL_KEY = 'GOLDPRICE_INSTALLER_EMAIL';const LAST_REFRESH_AT_KEY = 'GOLDPRICE_LAST_REFRESH_AT';const MIN_REFRESH_INTERVAL_MS = 4 * 60 * 60 * 1000;/** * Returns the current spot price for a metal/currency pair. * @customfunction */function GOLDPRICE(metal, currency, refreshToken) { const symbol = metal + '-' + currency + '-SPOT'; const cache = CacheService.getScriptCache(); const cacheKey = 'spot_' + symbol; const cached = cache.get(cacheKey); if (cached) return cached; const url = `https://api.goldprice.dev/v1/spot/${symbol}`; const resp = UrlFetchApp.fetch(url, { muteHttpExceptions: true, }); const code = resp.getResponseCode(); if (code === 401) return '#ACCESS_ERROR: use a server-side proxy'; if (code === 403) return '#PLAN_GATED: symbol not on your tier'; if (code === 429) return '#RATE_LIMIT: wait a minute and retry'; if (code !== 200) return '#ERROR_' + code; const data = JSON.parse(resp.getContentText()); if (!data || !data.price) return '#NO_DATA'; // Price stays a string, same as the API wire format: Sheets displays and // sorts it fine. Wrap in VALUE() if you need it in arithmetic. cache.put(cacheKey, data.price, SPOT_CACHE_TTL); return data.price;}/** * Compares live spot with a reference cell in the same currency and unit. * Spills [signed price delta, signed percentage delta] into two cells. * @customfunction */function GOLDPRICEDELTA(referencePrice, metal, currency, refreshToken) { const reference = Number(referencePrice); if (!Number.isFinite(reference) || reference <= 0) { return [['#BAD_REFERENCE: enter a positive price', '']]; } const liveResult = GOLDPRICE(metal, currency, refreshToken); const live = Number(liveResult); if (!Number.isFinite(live)) return [[liveResult, '']]; const signedDelta = live - reference; return [[Number(signedDelta.toFixed(2)), signedDelta / reference]];}/** * Returns chart-ready daily closes for a date range. * @customfunction */function GOLDHISTORY(fromDate, toDate, refreshToken) { const now = new Date(); const rollingFrom = new Date(now.getTime() - 29 * 24 * 60 * 60 * 1000); const from = toIsoDate_( fromDate === '' || fromDate == null ? rollingFrom : fromDate ); const to = toIsoDate_(toDate === '' || toDate == null ? now : toDate); if (!from || !to) return [['#BAD_DATE: put real dates in B2/B3']]; const cache = CacheService.getScriptCache(); const cacheKey = 'bars_' + from + '_' + to; const cached = cache.get(cacheKey); if (cached) return JSON.parse(cached); const url = 'https://api.goldprice.dev/v1/bars?symbol=XAU-USD-SPOT&interval=1d' + '&from=' + from + '&to=' + to; const resp = UrlFetchApp.fetch(url, { muteHttpExceptions: true, }); const code = resp.getResponseCode(); if (code === 401) return [['#ACCESS_ERROR: use a server-side proxy']]; if (code === 403) return [['#PLAN_GATED: range not on your tier']]; if (code === 429) return [['#RATE_LIMIT: wait a minute and retry']]; if (code !== 200) return [['#ERROR_' + code]]; const data = JSON.parse(resp.getContentText()); const bars = Array.isArray(data.bars) ? data.bars : []; if (bars.length === 0) return [['#NO_DATA: empty range']]; // /v1/bars returns bars newest-first; reverse to chronological order so // a line chart built from this range reads left-to-right in time. const rows = bars.slice().reverse().map(function (bar) { return [bar.bar_start.slice(0, 10), Number(bar.close)]; }); cache.put(cacheKey, JSON.stringify(rows), BARS_CACHE_TTL); return rows;}function installGoldRefresh() { const lock = LockService.getScriptLock(); lock.waitLock(30000); try { const spreadsheet = SpreadsheetApp.getActiveSpreadsheet(); if (!spreadsheet) throw new Error('Open the bound Google Sheet and run again'); const installerEmail = Session.getEffectiveUser().getEmail(); if (!installerEmail) throw new Error('Run the installer from your Google account'); const properties = PropertiesService.getScriptProperties(); const existingInstaller = properties.getProperty(INSTALLER_EMAIL_KEY); if (existingInstaller && existingInstaller !== installerEmail) { throw new Error('Gold refresh is already owned by another editor'); } const refreshRange = spreadsheet.getActiveSheet().getRange('B1'); spreadsheet.setNamedRange(REFRESH_RANGE_NAME, refreshRange); // getProjectTriggers() only returns this user's triggers. The shared // installer-email claim above prevents a second editor from creating a // second schedule, while this replacement keeps owner reruns idempotent. ScriptApp.getProjectTriggers() .filter(function (trigger) { return trigger.getHandlerFunction() === 'refreshGoldData'; }) .forEach(function (trigger) { ScriptApp.deleteTrigger(trigger); }); ScriptApp.newTrigger('refreshGoldData') .timeBased() .everyHours(4) .create(); properties.setProperties({ [SPREADSHEET_ID_KEY]: spreadsheet.getId(), [INSTALLER_EMAIL_KEY]: installerEmail, }); } finally { lock.releaseLock(); } refreshGoldData();}function uninstallGoldRefresh() { const lock = LockService.getScriptLock(); lock.waitLock(30000); try { const properties = PropertiesService.getScriptProperties(); const installerEmail = Session.getEffectiveUser().getEmail(); const existingInstaller = properties.getProperty(INSTALLER_EMAIL_KEY); if (existingInstaller && existingInstaller !== installerEmail) { throw new Error('Only the trigger owner can uninstall gold refresh'); } ScriptApp.getProjectTriggers() .filter(function (trigger) { return trigger.getHandlerFunction() === 'refreshGoldData'; }) .forEach(function (trigger) { ScriptApp.deleteTrigger(trigger); }); properties.deleteProperty(SPREADSHEET_ID_KEY); properties.deleteProperty(INSTALLER_EMAIL_KEY); properties.deleteProperty(LAST_REFRESH_AT_KEY); } finally { lock.releaseLock(); }}function refreshGoldData(event) { const lock = LockService.getScriptLock(); lock.waitLock(30000); try { // Clock triggers pass an event object, but they cannot supply the metal, // currency, date-range, or reference arguments required by sheet formulas. // This wrapper only changes the named token cell those formulas reference. const properties = PropertiesService.getScriptProperties(); const spreadsheetId = properties.getProperty(SPREADSHEET_ID_KEY); if (!spreadsheetId) throw new Error('Run installGoldRefresh first'); // Script properties and the script lock are shared across editors. This // gate coalesces legacy triggers owned by different users into one refresh // per four hours even though Apps Script cannot enumerate their triggers. const now = Date.now(); const lastRefreshAt = Number(properties.getProperty(LAST_REFRESH_AT_KEY)); if (lastRefreshAt && now - lastRefreshAt < MIN_REFRESH_INTERVAL_MS) return; const spreadsheet = SpreadsheetApp.openById(spreadsheetId); const refreshRange = spreadsheet.getRangeByName(REFRESH_RANGE_NAME); if (!refreshRange) throw new Error('Run installGoldRefresh to restore B1'); refreshRange.setValue(new Date(now).toISOString()); properties.setProperty(LAST_REFRESH_AT_KEY, String(now)); } finally { lock.releaseLock(); }}function toIsoDate_(value) { const d = value instanceof Date ? value : new Date(value); if (isNaN(d.getTime())) return null; return d.toISOString().slice(0, 10);}Wire up price, history, and reference cells
Put a refresh token in B1 and an optional USD-per-troy-ounce reference quote in C1. Leave B2 and B3 blank for a rolling 30-calendar-day history, or type fixed dates. GOLDPRICEDELTA() returns signed live-minus-reference price and percentage deltas. The comparison is valid only when both cells use the same currency and unit.
Google states that GOOGLEFINANCE quotes may be delayed up to 20 minutes, not all futures are supported, and historical results cannot be accessed through Apps Script. If you supply a separate reference quote, normalize it to USD per troy ounce in C1; goldprice.dev supplies the programmatic spot and history side. Never pass NOW() or TODAY() into a custom-function argument: the rolling dates are calculated inside GOLDHISTORY() when B2/B3 are blank.
// B1: refresh token, written by refreshGoldData(), added in "Add the three gold-price functions to Apps Script"// B2/B3: leave blank for a rolling 30-calendar-day range, or type fixed dates// C1: optional reference quote normalized to USD per troy ounce// Current USD spot, recalculates whenever B1 changes=GOLDPRICE("XAU", "USD", $B$1)// Live spot minus C1: spills [signed USD delta, signed percentage delta]// Format the second result cell as Percent.=GOLDPRICEDELTA($C$1, "XAU", "USD", $B$1)// Current EUR spot, same token cell drives both=GOLDPRICE("XAU", "EUR", $B$1)// 30 days of daily closes, chart-ready (date, close)=GOLDHISTORY($B$2, $B$3, $B$1)Build a line chart from the historical result
Select the rows GOLDHISTORY() spilled into, then Insert → Chart and pick Line chart. The number of rows depends on the requested range and available daily bars. Because the function already reverses the API’s newest-first bars into chronological order, the x-axis runs oldest to newest without extra sorting.
Add a 4-hour refresh trigger
Choose one Google account to own the schedule. If any editor followed the old tutorial, each must first delete their refreshGoldData entry from Apps Script → Triggers. Then run installGoldRefresh once and authorize it. The installer binds B1 and creates one four-hour trigger. A shared lock and timestamp coalesce any inaccessible legacy triggers to one refresh per four hours.
For a planned handoff, the owner runs uninstallGoldRefresh, then the new owner installs. If the owner is unavailable, delete the three GOLDPRICE_* entries in Project Settings → Script Properties before reinstalling; the shared interval guard still prevents duplicate calls. Four formulas × six refreshes means at most 24 calls/day, about 720/month, inside the anonymous Free controls of 100 requests/hour and 5,000/month per IP.
Expected output
{ "symbol": "XAU", "quote_currency": "USD", "unit": "troy_ounce", "contract_type": "spot", "price": "4012.75", "is_stale": false, "computed_at": "2026-10-23T07:00:18Z"}GOLDPRICE shows the price field above as text (e.g. "4012.75"). GOLDPRICEDELTA spills signed price and decimal percentage deltas into two cells; format the second as Percent. GOLDHISTORY fills [date, close] rows available for the requested range.
Common errors
| Code | What you see | Fix |
|---|---|---|
| 401 | Cell shows #ACCESS_ERROR: use a server-side proxy | The anonymous request was not accepted. Check Apps Script → Executions first. If the workbook needs authenticated capacity, send the request through a server-side proxy; do not paste a key into the bound script. |
| 403 | Cell shows #PLAN_GATED: symbol not on your tier | Free covers gold (XAU), silver (XAG), and copper (HG) spot. Check the symbol; platinum and palladium aren’t offered. See /pricing. |
| 429 | Cell shows #RATE_LIMIT: wait a minute and retry | The shared IP reached a Free control: 10 requests/minute, 100/hour, or 5,000/month. The cache should normally prevent this; raise the TTL constants, reduce distinct formulas, or move authenticated requests behind a server-side proxy. |
| N/A | GOLDHISTORY shows #BAD_DATE: put real dates in B2/B3 | B2 or B3 holds text Apps Script can’t parse as a date (e.g. a label instead of a value). Enter actual dates, or reference cells formatted as Date via Format → Number → Date. |
| N/A | GOLDPRICEDELTA shows #BAD_REFERENCE: enter a positive price | C1 is blank, text, zero, or negative. Enter a positive reference price using the same currency and per-troy-ounce unit as the live spot formula. |
| N/A | Cell shows Loading… forever | Apps Script needs external-fetch authorization. Click a function name in the editor toolbar and run it once manually to trigger the auth prompt, then accept. |
Going further
- Build a price-alert formula:
=IF(GOLDPRICE("XAU","USD",$B$1) > 4800, "ALERT", "OK") - For older daily history, use a keyed server-side proxy: Basic covers 1 year; Plus and Pro cover full history
- Track silver alongside gold on Free, using the same refresh token cell for both
- Feed the chart-ready GOLDHISTORY rows into a backtest, see backtesting a gold strategy on OHLC data
- Reference
=GOLDPRICE(...)from a valuation or cost model so it updates from live data instead of a hardcoded figure