How to get live gold prices in Google Sheets

Pull live and historical gold prices into Google Sheets without storing an API key, 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’s anonymous endpoints: 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. No API key is stored in the spreadsheet. Run installGoldRefresh() once to bind one refresh cell and create a quota-safe four-hour trigger.

  1. 1.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: 30 requests per minute, 100 per hour, and 1,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 1,000-call monthly allowance under your account rather than a shared IP.

  2. 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 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.

    JAVASCRIPT · Code.gs
    // google-sheets: Apps Script Code.gs
    const 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);
    }
  3. 3.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.

    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. 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. 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 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 1,000/month per IP.

Expected output

The API returns this shape:

JSON · GET /v1/spot/XAU-USD-SPOT
{
  "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

CodeSymptomFix
401Cell shows #ACCESS_ERROR: use a server-side proxyThe 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.
403Cell shows #PLAN_GATED: symbol not on your tierFree covers XAU spot only. Silver (XAG) and copper (HG) need Pro; platinum and palladium aren’t offered on any tier today. See /pricing.
429Cell shows #RATE_LIMIT: wait a minute and retryThe shared IP reached a Free control: 30 requests/minute, 100/hour, or 1,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/AGOLDHISTORY shows #BAD_DATE: put real dates in B2/B3B2 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/AGOLDPRICEDELTA shows #BAD_REFERENCE: enter a positive priceC1 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/ACell shows Loading… foreverApps 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. Cache hits normally reduce the total because GOLDPRICEDELTA reuses the USD spot request. That workload stays below the anonymous limits of 30 requests/minute, 100/hour, and 1,000/month per IP.

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?

Yes. This example uses anonymous access and stores no API key, so editors can inspect or copy the bound script without receiving a credential. If the workbook later needs authenticated capacity, keep the bearer token in a server-side proxy and let the Sheet call that service.

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. Anonymous XAU spot and 30-day daily history work without a key under the Free controls: 30 requests/minute, 100/hour, and 1,000/month per IP. A free account key carries the same monthly allowance, no card, and no expiration when you need an account-level bucket behind a server-side integration.

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:

Browse all 11 tutorials →