How to get live gold prices in Google Sheets
Pull live and historical gold prices into Google Sheets, compare a reference quote, and refresh safely every 4 hours.
Updated
To get live gold prices in Google Sheets, add three Apps Script custom functions backed by goldprice.dev: GOLDPRICE() calls api.goldprice.dev/v1/spot/XAU-USD-SPOT; GOLDHISTORY() returns 30-day daily closes; and GOLDPRICEDELTA() compares live spot with a normalized reference cell and returns signed price and percentage deltas. Run installGoldRefresh() once to bind one refresh cell and create a quota-safe four-hour trigger. Copy the code below and run the installer.
1.Get your goldprice.dev API key
Sign up for a free account at goldprice.dev/onboarding. After email confirmation, your dashboard shows a key starting with
ga_live_. Copy it: you will paste it into Apps Script in the next step.The free tier includes 1,000 calls/month and covers gold (XAU) spot prices in USD and other currencies. No credit card required.
Get your free API key1,000 calls/mo, no credit cardSign up free →2.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 definesGOLDPRICE(metal, currency, refreshToken)for current spot,GOLDHISTORY(fromDate, toDate, refreshToken)for chart-ready daily closes, andGOLDPRICEDELTA(referencePrice, metal, currency, refreshToken)for signed price and percentage deltas. The functions cache identical requests viaCacheServiceand return plain error strings on 401, 403, or 429 instead of throwing.JAVASCRIPT · Code.gs// google-sheets: Apps Script Code.gs // Replace API_KEY with your goldprice.dev key from /onboarding const API_KEY = 'ga_live_replace_with_your_key_here'; const SPOT_CACHE_TTL = 55; // seconds — just under Sheets' fastest recalc cadence const BARS_CACHE_TTL = 300; // seconds — a day's bars don't change within 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, { headers: { Authorization: 'Bearer ' + API_KEY }, muteHttpExceptions: true, }); const code = resp.getResponseCode(); if (code === 401) return '#AUTH_ERROR: check API_KEY'; 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, { headers: { Authorization: 'Bearer ' + API_KEY }, muteHttpExceptions: true, }); const code = resp.getResponseCode(); if (code === 401) return [['#AUTH_ERROR: check API_KEY']]; 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); }3.Wire up price, history, and reference cells
Put a refresh token in
B1and an optional USD-per-troy-ounce reference quote inC1. LeaveB2andB3blank 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 passNOW()orTODAY()into a custom-function argument: the rolling dates are calculated insideGOLDHISTORY()whenB2/B3are blank.JAVASCRIPT · Sheets formulas · B1/B2/B3/C1 wiring// B1: refresh token — written by refreshGoldData(), see step 5 // 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)4.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.5.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
refreshGoldDataentry from Apps Script → Triggers. Then runinstallGoldRefreshonce and authorize it. The installer bindsB1and 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 threeGOLDPRICE_*entries in Project Settings → Script Properties before reinstalling; the shared interval guard still prevents duplicate API calls. Four formulas × six refreshes means at most 24 calls/day, about 720/month, inside the free tier's 1,000/month cap.
Expected output
The API returns this shape:
{
"symbol": "XAU",
"quote_currency": "USD",
"unit": "troy_ounce",
"contract_type": "spot",
"price": "3980.42",
"bid": "3982.18",
"ask": "3978.65",
"is_stale": false,
"computed_at": "2026-07-16T18:38:22Z"
}GOLDPRICE shows the price field above as text (e.g. "3980.42"). 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 | Symptom | Fix |
|---|---|---|
| 401 | Cell shows #AUTH_ERROR: check API_KEY | API key missing or invalid. Re-copy from your dashboard and paste into API_KEY at the top of Code.gs. |
| 403 | Cell shows #PLAN_GATED: symbol not on your tier | Free covers XAU spot only. Silver (XAG) and copper (HG) need Pro; platinum and palladium aren’t offered on any tier today. See /pricing. |
| 429 | Cell shows #RATE_LIMIT: wait a minute and retry | You hit the free tier's per-minute cap. The cache in Code.gs should normally prevent this; if many cells or a chatty trigger call the functions at once, raise the TTL constants at the top of the script. |
| 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. |
FAQ
How often does the price update?
The spot endpoint refreshes every 60 seconds — live oracle + continuous spot reference + futures settlement aggregation. The cache in GOLDPRICE respects that cadence, so repeated calls within its TTL return the same value instead of spending a fresh API call.
Will a 4-hour refresh fit the free tier?
Yes. Four formulas are wired to the $B$1 refresh cell: two GOLDPRICE calls, one GOLDPRICEDELTA call, and one GOLDHISTORY call. At worst, a 4-hour trigger fires 6 times a day × 4 calls = 24 calls/day, about 720/month, against the free tier's 1,000/month quota. Cache hits normally reduce the total because GOLDPRICEDELTA reuses the USD spot request. An hourly trigger can exceed the free quota, so don't tighten the interval without reducing formulas or upgrading.
Why not use GOOGLEFINANCE for the gold price?
GOOGLEFINANCE is built for supported securities and currency data, not a documented XAU/USD spot API. Google says quotes may be delayed up to 20 minutes, not all futures are supported, and historical data cannot be accessed through Apps Script. Use GOLDPRICE() for spot, GOLDHISTORY() for script-readable history, and GOLDPRICEDELTA() to measure any normalized reference quote rather than assuming the two instruments are equivalent.
Why does the trigger target refreshGoldData instead of GOLDPRICE?
Apps Script passes a clock event object to the selected handler, but it cannot supply the metal, currency, date-range, or reference arguments the sheet formulas require. installGoldRefresh() therefore creates a trigger for refreshGoldData(event), which only writes a fresh timestamp into the named B1 range and makes all four formulas recalculate.
Can I share this Sheet with other editors?
Only with people you trust with the API key. Google states that editors can open a Sheet’s bound Apps Script, while viewers can copy the workbook and inspect the copied script. Either path exposes the API_KEY constant. Mint a dedicated workbook key where possible, and revoke or rotate it from your account whenever sharing changes.
Can I use this commercially?
The Free tier is for personal use. For commercial use (apps, dashboards, products you sell), upgrade to Pro ($30/mo) — monthly billing, cancel anytime, no annual lock-in. See /pricing.
What metals are supported?
Gold (XAU) spot is on every tier, including Free. Silver (XAG) and copper (HG) spot need Pro. Platinum and palladium are not offered on any tier. Full details in the API reference.
Is the free tier really free?
Yes, 1,000 calls/mo, no credit card, no expiration. Get the key at /onboarding.
Can an AI assistant like Claude write this for me?
Yes. Point Claude, Cursor, or ChatGPT at the goldprice.dev MCP server (or its llms.txt) and ask: Write Google Sheets custom functions backed by api.goldprice.dev, plus an idempotent installer that binds a named refresh cell and creates one four-hour clock trigger. Because it reads the live schema, the generated code uses the correct field names and error codes on the first try.
Does Apps Script work with Excel?
No. For Excel see the Excel tutorial — Excel uses Power Query M instead of Apps Script.
Going further
- Build a price-alert formula:
=IF(GOLDPRICE("XAU","USD",$B$1) > 4800, "ALERT", "OK") - Widen B2 once you're on Physical or Pro: those tiers unlock more than 30 days of daily history behind the same GOLDHISTORY call
- Track silver alongside gold once you're on Pro, 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
Next steps
Try the same setup in a different platform: