Reverse-Engineering Google Finance
19 undocumented RPC methods. One HTTP request. No API key. How the Google Finance frontend fetches data, and how to call it directly.
Last week I started working on a Google Finance scraper and learned a few things about how the site loads its data that I thought were worth sharing.
Not a surprise, Google Finance doesn't have a documented API. The usual approach is parsing HTML with Cheerio or spinning up a headless browser, but it's slow and fragile. I started there, but when I opened DevTools I realized the frontend doesn't use REST or GraphQL at all. It makes POST requests to a single endpoint using some internal RPC protocol.
So I started poking at it.
The thing nobody talks about
If you view source on any Google Finance page, there's an object called AF_dataServiceRequests sitting right in the HTML.
AF_dataServiceRequests = {'ds:0': {id:'YtbmEe', request:[[1],6,0]},'ds:1': {id:'HqGpWd', request:[[[null,["GOOGL","NASDAQ"]]]]},'ds:2': {id:'xh8wxf', request:[[[null,["GOOGL","NASDAQ"]]],1]},'ds:3': {id:'o6pODe', request:[[null,["GOOGL","NASDAQ"]]]},'ds:4': {id:'nBEQBc', request:[5,6,[[null,["GOOGL","NASDAQ"]]]]},'ds:5': {id:'nBEQBc', request:[2,6]},'ds:6': {id:'mKsvE', request:["GOOGL:NASDAQ"]},'ds:7': {id:'uwlMvd', request:[[[null,["GOOGL","NASDAQ"]]]]},'ds:8': {id:'SICF5d', request:[[null,["GOOGL","NASDAQ"]],18]},'ds:9': {id:'lvVhof', request:[18]},'ds:10': {id:'AiCwsd', request:[[[null,["GOOGL","NASDAQ"]]],1]},'ds:11': {id:'AiCwsd', request:[[[null,["GOOGL","NASDAQ"]]],3]},'ds:12': {id:'SICF5d', request:[[null,["GOOGL","NASDAQ"]]]},'ds:13': {id:'Pr8h2e', request:[[[null,["GOOGL","NASDAQ"]]]]},'ds:14': {id:'yYvDpf', request:[[null,["GOOGL","NASDAQ"]]]},'ds:15': {id:'QKZUzd', request:[1]},'ds:16': {id:'Xhdx2e', request:[null,1]},'ds:17': {id:'xh8wxf', request:[[[null,["GOOGL","NASDAQ"]]]]}};
I pulled the same object from the crypto page, home page, and market pages. Different pages use different methods. Between all of them: 19 methods.
What 19 methods get you
| RPC ID | Purpose | Request |
|---|---|---|
| xh8wxf | Quote | [[tuple], 1] or [[tuple]] |
| HqGpWd | Company info | [[tuple]] |
| uwlMvd | Classification | [[tuple]] |
| Pr8h2e | Financials / Estimates | [[tuple]] or [[tuple], 1] |
| AiCwsd | Chart | [[tuple], mode] |
| nBEQBc | News | [type, limit, [tuple]] |
| o6pODe | Analyst articles | [tuple] |
| SICF5d | Related stocks | [tuple, 18] or [tuple] |
| mKsvE | Stock context | ["GOOGL:NASDAQ"] |
| Xhdx2e | Market indices | [null, 1] |
| YtbmEe | Market movers | [[categories], count, offset] |
| lvVhof | Trending stocks | [18] |
| JFUMjd | Earnings calendar | [] |
| XqaYg | Category stocks | [category, offset] |
| QKZUzd | Top headline | [1] |
| mysBRb | Featured stocks | [] |
| qt5Q2d | Top stocks by metric | [6, 1] |
| sy8gqe | Category news | [category, offset] |
| yYvDpf | Unknown (empty) | [tuple] |
CONSENT=YES+ cookie (in case the request originates from the EU).The request/response encoding is a little unusual but not complicated once you see it:
function buildBody(requests) {const arr = requests.map((r, i) => [r.id,JSON.stringify(r.req),null,String(i + 1),]);return `f.req=${encodeURIComponent(JSON.stringify([arr]))}`;}function parseResponse(raw) {const stripped = raw.replace(/^\)\]\}'\n\n?/, "");const results = [];const lines = stripped.split("\n");let i = 0;while (i < lines.length) {if (/^[0-9a-fA-F]+$/.test(lines[i].trim()) && i + 1 < lines.length) {try {for (const entry of JSON.parse(lines[i + 1])) {if (entry[0] === "wrb.fr") {results.push({id: entry[1],data: JSON.parse(entry[2]),});}}} catch {}i += 2;} else {i++;}}return results;}
What do you get? Not just price quotes. Full income statements, balance sheets, cash flow. Quarterly and annual. Price charts at every interval from 1-minute to monthly. News with thumbnails and related tickers. Analyst estimates. Earnings calendar with conference call timestamps.
And it works across everything Google Finance supports. US stocks, Samsung on KRX, Toyota on TYO, crypto pairs, EUR-USD, the Dow. I tested 9 tickers across different asset types to make sure.
| Data | Stocks | ETFs | Indices | Crypto | FX |
|---|---|---|---|---|---|
| Quote | Yes | Yes | Yes | Yes | Yes |
| Company | Yes | Partial | Desc only | No | No |
| Financials | 40-57KB | Empty | Empty | Empty | Empty |
| Charts | Yes | Yes | Yes | Yes | Yes |
| News | Yes | Yes | Yes | Yes | Yes |
| Related | Yes | Yes | Yes | Yes | No |
| After-hours | Yes | Yes | No | No | No |
Stocks get everything. ETFs and indices get most of it. Crypto and currencies get quotes, charts, and news but no financials (obviously).
The only difference between asset types is how you identify the instrument. Stocks use [ticker, exchange]. Crypto uses [base, quote] at a different position in the array:
function tickerTuple(ticker) {if (ticker.includes("-") && !ticker.includes(":")) {const [base, quote] = ticker.split("-");return [null, null, [base, quote]]; // BTC-USD, EUR-USD}const [sym, exchange] = ticker.split(":");return [null, [sym, exchange]]; // GOOGL:NASDAQ, .DJI:INDEXDJX}
Positional arrays
This is the annoying part. Google doesn't return normal JSON. No field names. Everything is nested arrays where position determines meaning.
| Index | Field | Stocks | Crypto |
|---|---|---|---|
| [0] | KG MID | /m/07zln7n | /g/11bvvxp7st |
| [1] | [ticker, exchange] | ["GOOGL","NASDAQ"] | null |
| [2] | Name | Alphabet Inc Class A | Bitcoin (BTC / USD) |
| [3] | Type enum | 0 (stock) | 3 (crypto) |
| [5] | Price array | [339.32, 7.03, 2.12] | [77877, -341, -0.44] |
| [7] | Previous close | 332.29 | 78218.05 |
| [12] | Timezone | America/New_York | null |
| [15] | Crypto pair | N/A | ["BTC","USD","Bitcoin",...] |
| [16] | After-hours | [336.63, -2.69, ...] | null |
| [21] | Ticker string | GOOGL:NASDAQ | BTC-USD |
Price lives at [5][0]. Market cap at [7]. CEO name at index [5] of a completely different array. The quote array is 27 elements long for stocks but 25 for crypto, because crypto doesn't have an exchange field.
You figure out the mapping by opening the Google Finance page next to the raw response and matching numbers by hand. Tedious, but it only needs to be done once. I mapped every field I could find and cross-checked across the 9 tickers.
Probably why they use this format. Saves bandwidth and makes it harder for people like me to scrape.
Company info (HqGpWd)
| Index | Field | GOOGL | AAPL | SPY | .DJI |
|---|---|---|---|---|---|
| [2] | Description | Alphabet... | Apple Inc... | The SPDR.. | The Dow... |
| [3] | HQ | [MtnView,CA,US] | [Cupertino,..] | null | null |
| [5] | CEO | Sundar Pichai | Tim Cook | null | null |
| [6] | Employees | 190820 | 166000 | 2 | null |
| [7] | Market cap | 4.09e12 | 4.01e12 | 6.42e11 | null |
| [12] | 52-week high | 349.00 | 288.61 | 712.39 | 50512.79 |
| [16] | P/E ratio | 31.40 | 34.56 | null | null |
| [18] | Volume | 27200997 | 41339643 | 84110351 | null |
| [71] | Sector | Interactive.. | Computers.. | Capital.. | N/A |
Financials (Pr8h2e)
Only exists for stocks. Deeply nested 39-field arrays. I find them by recursive search: length >= 38, [0] > 1 billion (revenue), [2] < 100 (EPS).
| Index | Field |
|---|---|
| [0] | Revenue |
| [1] | Net income |
| [2] | EPS |
| [3] | Operating margin % |
| [4] | Operating income |
| [7] | EBITDA |
| [8] | Shares outstanding |
| [9] | EPS diluted |
| [11] | Revenue growth YoY |
| [16] | Currency |
| [17] | Fiscal period end [y, m, d] |
| [18] | P/E ratio |
| [23] | Total assets |
| [24] | Total liabilities |
| [25] | Total equity |
| [28] | Operating cash flow |
| [33] | Profit margin |
| [36] | Free cash flow |
| [38] | Capital expenditure |
Month 12 = annual. Anything else = quarterly.
The page source has the data too
The page also embeds all the actual data in AF_initDataCallback blocks. 18 per stock page, each a different data type:
AF_initDataCallback({key: 'ds:2',hash: '8291',data: [[[["/m/07zln7n", ["GOOGL", "NASDAQ"],"Alphabet Inc Class A", 0, "USD",[339.32, 7.03, 2.12], null, 332.29, ...]]],sideChannel: {}});// Extract all 18 blocks with one regex:const re = /AF_initDataCallback\(\{key:\s*'(ds:\d+)',\s*hash:\s*'\d+',\s*data:(.*?),\s*sideChannel:\s*\{/g;
So if you don't want to call the RPC endpoint, you can just fetch the HTML and parse these blocks. The RPC approach is faster though, and lets you pick exactly which data you want.
The script
I put together a Node.js script that calls the endpoint directly. No dependencies. One command, JSON output:
// google-finance.mjsconst BATCHEXECUTE_URL ="https://www.google.com/finance/_/GoogleFinanceUi/data/batchexecute";const HEADERS = {"User-Agent":"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 " +"(KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36",Accept: "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8","Accept-Language": "en-US,en;q=0.9","Accept-Encoding": "identity",Cookie: "CONSENT=YES+","Content-Type": "application/x-www-form-urlencoded;charset=UTF-8",};function tickerTuple(ticker) {if (ticker.includes("-") && !ticker.includes(":")) {const [base, quote] = ticker.split("-");return [null, null, [base, quote]];}const [sym, exchange] = ticker.split(":");return [null, [sym, exchange]];}function buildBody(requests) {const arr = requests.map((r, i) => [r.id,JSON.stringify(r.req),null,String(i + 1),]);return `f.req=${encodeURIComponent(JSON.stringify([arr]))}`;}function parseResponse(raw) {const stripped = raw.replace(/^\)\]\}'\n\n?/, "");const results = [];const lines = stripped.split("\n");let i = 0;while (i < lines.length) {if (/^[0-9a-fA-F]+$/.test(lines[i].trim()) && i + 1 < lines.length) {try {for (const entry of JSON.parse(lines[i + 1])) {if (entry[0] === "wrb.fr") {results.push({id: entry[1],data: JSON.parse(entry[2]),});}}} catch {}i += 2;} else {i++;}}return results;}async function scrape(ticker) {const t = tickerTuple(ticker);const isCrypto = ticker.includes("-") && !ticker.includes(":");const requests = [{ id: "xh8wxf", req: [[t], 1] },{ id: "HqGpWd", req: [[t]] },{ id: "Pr8h2e", req: [[t]] },{ id: "AiCwsd", req: [[t], 3] },{ id: "nBEQBc", req: [isCrypto ? 6 : 5, 3, [t]] },];const rpcids = [...new Set(requests.map((r) => r.id))].join(",");const url =`${BATCHEXECUTE_URL}?rpcids=${rpcids}` +`&source-path=/finance/quote/${ticker}&hl=en&gl=us&rt=c`;const res = await fetch(url, {method: "POST",headers: HEADERS,body: buildBody(requests),});const results = parseResponse(await res.text());const get = (id) => results.find((r) => r.id === id)?.data;const q = get("xh8wxf")?.[0]?.[0]?.[0];if (!q) {console.error(`No data for ${ticker}`);return;}const quote = {ticker: isCrypto ? q[21] : q[1]?.[0],exchange: isCrypto ? "" : q[1]?.[1],name: q[2],type: { 0: "stock", 1: "index", 3: "crypto", 5: "etf" }[q[3]] ?? "other",price: q[5][0],change: q[5][1],changePercent: q[5][2],previousClose: q[7],currency: q[4],timezone: q[12],};const info = get("HqGpWd")?.[0]?.[0];const company = info? {description: info[2] || null,ceo: info[5] || null,employees: info[6] || null,marketCap: info[7] || null,open: info[9] || null,high: info[10] || null,low: info[11] || null,fiftyTwoWeekHigh: info[12] || null,fiftyTwoWeekLow: info[13] || null,peRatio: info[16] || null,volume: info[18] || null,sector: info[71] || null,}: null;const chartRaw = get("AiCwsd")?.[0]?.[0];let chart = null;if (chartRaw) {const points = [];for (const period of chartRaw[3] || []) {for (const pt of period?.[1] || []) {if (!Array.isArray(pt?.[0]) || !Array.isArray(pt?.[1])) continue;const [y, m, d] = pt[0];points.push({date: `${y}-${String(m).padStart(2, "0")}-${String(d).padStart(2, "0")}`,price: pt[1][0],volume: pt[2] ?? null,});}}chart = { previousClose: chartRaw[6], points };}const newsRaw = get("nBEQBc");let news = null;if (Array.isArray(newsRaw?.[0])) {news = newsRaw[0].filter((a) => Array.isArray(a) && a[1]).map((a) => ({title: a[1],source: a[2],url: a[0],timestamp: a[4],}));}console.log(JSON.stringify({ ...quote, company, chart, news }, null, 2));}const ticker = process.argv[2];if (!ticker) {console.error("Usage: node google-finance.mjs GOOGL:NASDAQ");process.exit(1);}scrape(ticker);
Usage:
node google-finance.mjs GOOGL:NASDAQnode google-finance.mjs BTC-USDnode google-finance.mjs .DJI:INDEXDJXnode google-finance.mjs 005930:KRX