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. 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 card
    Sign up free →
  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 and 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. 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 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:

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 #AUTH_ERROR: check API_KEYAPI key missing or invalid. Re-copy from your dashboard and paste into API_KEY at the top of Code.gs.
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 retryYou 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/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, 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:

Browse all 11 tutorials →