Zillow API for Google Sheets
Zillow data in your spreadsheet with one formula. Paste a column of Zillow links, zpids or addresses, type =ZILLOW(A2) or =ZESTIMATE(A2), and fill down. A copy-paste Apps Script does the work.
Install it
Copy-paste, once per spreadsheet. No add-on store needed.
Open Apps Script
In your sheet, open Extensions → Apps Script. Delete what's in the editor.
Paste and save
Copy the script below, paste it in, and save. Then reload the spreadsheet.
Set your key
Use the new menu PropertiesAPI → Set API key and paste the key from your dashboard. Google asks you to authorize the script the first time.
/**
* PropertiesAPI for Google Sheets: Zillow data by formula.
* Docs: https://propertiesapi.com/zillow-api/google-sheets
* PropertiesAPI is an independent service, not affiliated with Zillow.
*
* Setup: Extensions > Apps Script, paste this file, save, reload the sheet,
* then PropertiesAPI > Set API key. The key is kept in your own user
* properties for this script, never in a cell.
*/
var PROPERTIESAPI_BASE = "https://api.propertiesapi.com/v1";
var PROPERTIESAPI_DASHBOARD = "https://propertiesapi.com/dashboard";
var PA_CACHE_SECONDS = 21600; // found homes: 6 hours
var PA_MISS_SECONDS = 3600; // not found / ambiguous: 1 hour
var PA_PER_REQUEST = 2; // homes per batch call
var PA_START_BEFORE_MS = 8000; // no new call after this (Google stops formulas at about 30 s)
var PA_KEY_PROP = "PROPERTIESAPI_KEY";
var PA_ROW = [["Address","address"],["Price","price"],["Status","homeStatus"],["Beds","beds"],["Baths","baths"],["Sqft","livingArea"],["Lot sqft","lotSize"],["Year built","yearBuilt"],["Zestimate","zestimate"],["Rent Zestimate","rentZestimate"],["Days on Zillow","daysOnZillow"],["zpid","zpid"],["Zillow link","detailUrl"]];
var PA_ALIASES = [["price","price"],["status","homeStatus"],["beds","beds"],["baths","baths"],["sqft","livingArea"],["lot","lotSize"],["year_built","yearBuilt"],["zestimate","zestimate"],["rent_zestimate","rentZestimate"],["days_on_zillow","daysOnZillow"],["zpid","zpid"],["url","detailUrl"],["address","address"]];
function onOpen() {
SpreadsheetApp.getUi()
.createMenu("PropertiesAPI")
.addItem("Set API key", "paSetApiKey")
.addItem("Remove API key", "paRemoveApiKey")
.addItem("Clear cached results", "paClearCache")
.addToUi();
}
function onInstall(e) {
onOpen(e);
}
function paSetApiKey() {
var ui = SpreadsheetApp.getUi();
var res = ui.prompt("PropertiesAPI", "Paste your API key (from " + PROPERTIESAPI_DASHBOARD + "). It is stored in your user properties for this script, not in the sheet.", ui.ButtonSet.OK_CANCEL);
if (res.getSelectedButton() !== ui.Button.OK) return;
var key = String(res.getResponseText() || "").trim();
if (!/^pa_live_[A-Za-z0-9_-]{43}$/.test(key)) {
ui.alert("That doesn't look like a PropertiesAPI key (it starts with pa_live_). Nothing was saved.");
return;
}
PropertiesService.getUserProperties().setProperty(PA_KEY_PROP, key);
paClearCache();
ui.alert("Saved. Your formulas use this key from now on.");
}
function paRemoveApiKey() {
PropertiesService.getUserProperties().deleteProperty(PA_KEY_PROP);
SpreadsheetApp.getUi().alert("Removed. Formulas will ask for a key until you set one again.");
}
/** Starts a fresh cache generation, so the next recalculation looks homes up again. */
function paClearCache() {
var props = PropertiesService.getUserProperties();
props.setProperty("PROPERTIESAPI_CACHE_GEN", String(Number(props.getProperty("PROPERTIESAPI_CACHE_GEN") || 0) + 1));
}
/**
* Zillow data for a home: the price, or the field you name.
*
* @param {string} input A Zillow link, a zpid or an address (or a range of them).
* @param {string} field Optional: price (default), status, beds, baths, sqft, lot, year_built, zestimate, rent_zestimate, days_on_zillow, zpid, url, address, or any API field name.
* @return The value, or a message when the home can't be looked up.
* @customfunction
*/
function ZILLOW(input, field) {
var name = paField_(field);
if (name.error) return name.error;
return paMap_(input, function (r) {
return r.data ? paValue_(r.data[name.field]) : r.message;
});
}
/**
* The main fields for a home as one row: address, price, status, beds, baths, sqft, lot, year built, Zestimate, rent Zestimate, days on Zillow, zpid and link.
*
* @param {string} input A Zillow link, a zpid or an address (or a column of them).
* @return One row per home.
* @customfunction
*/
function ZILLOW_ROW(input) {
var inputs = paFlatten_(input);
var results = paLookupAll_(inputs);
return results.map(function (r) {
if (!r.data) {
var row = PA_ROW.map(function () { return ""; });
row[0] = r.message;
return row;
}
return PA_ROW.map(function (f) { return paValue_(r.data[f[1]]); });
});
}
/**
* The header row that matches =ZILLOW_ROW.
*
* @return The column names.
* @customfunction
*/
function ZILLOW_HEADERS() {
return [PA_ROW.map(function (f) { return f[0]; })];
}
/**
* Zillow's Zestimate for the home, when Zillow shows one.
*
* @param {string} input A Zillow link, a zpid or an address (or a range of them).
* @return The Zestimate in dollars.
* @customfunction
*/
function ZESTIMATE(input) {
return ZILLOW(input, "zestimate");
}
// ---------------------------------------------------------------- internals
function paField_(field) {
var f = String(field == null || field === "" ? "price" : field).trim();
for (var i = 0; i < PA_ALIASES.length; i++) if (PA_ALIASES[i][0] === f.toLowerCase()) return { field: PA_ALIASES[i][1] };
if (/^[A-Za-z][A-Za-z0-9]*$/.test(f)) return { field: f };
return { error: "Error: unknown field \"" + f + "\". Try price, status, beds, baths, sqft, lot, year_built, zestimate, rent_zestimate, days_on_zillow, zpid, url or address." };
}
function paValue_(v) {
if (v == null) return "";
if (Array.isArray(v)) {
if (v.every(function (x) { return x == null || typeof x !== "object"; })) return v.join(", ");
return JSON.stringify(v).slice(0, 50000);
}
if (typeof v === "object") return JSON.stringify(v).slice(0, 50000);
return v;
}
function paInput_(v) {
if (v == null) return "";
if (typeof v === "number") return isFinite(v) ? String(Math.trunc(v)) : "";
return String(v).trim();
}
function paFlatten_(input) {
if (!Array.isArray(input)) return [paInput_(input)];
return input.map(function (row) { return paInput_(Array.isArray(row) ? row[0] : row); });
}
/** Runs fn over each cell of input (a value or a range) and returns the same shape. */
function paMap_(input, fn) {
if (!Array.isArray(input)) return fn(paLookupAll_([paInput_(input)])[0]);
var flat = [];
input.forEach(function (row) { (Array.isArray(row) ? row : [row]).forEach(function (c) { flat.push(paInput_(c)); }); });
var results = paLookupAll_(flat);
var k = 0;
return input.map(function (row) {
return (Array.isArray(row) ? row : [row]).map(function () { return fn(results[k++]); });
});
}
function paCacheKey_(gen, input) {
var digest = Utilities.computeDigest(Utilities.DigestAlgorithm.SHA_256, input.toLowerCase(), Utilities.Charset.UTF_8);
return "pa" + gen + ":" + Utilities.base64EncodeWebSafe(digest);
}
/** Looks up each input (with the cache), returning one { data } or { message } per input, in order. */
function paLookupAll_(inputs) {
var t0 = Date.now();
var out = inputs.map(function (x) { return x ? null : { message: "" }; });
var key = PropertiesService.getUserProperties().getProperty(PA_KEY_PROP);
if (!key) {
return out.map(function (r) { return r || { message: "Error: no API key. Use the menu PropertiesAPI > Set API key." }; });
}
var gen = PropertiesService.getUserProperties().getProperty("PROPERTIESAPI_CACHE_GEN") || "0";
var cache = CacheService.getUserCache();
// distinct inputs not answered yet, and what the cache already has
var todo = [];
var where = {};
inputs.forEach(function (x, i) {
if (!x) return;
if (!where[x]) { where[x] = []; todo.push(x); }
where[x].push(i);
});
var keys = todo.map(function (x) { return paCacheKey_(gen, x); });
var hits = keys.length ? cache.getAll(keys) : {};
var miss = [];
todo.forEach(function (x, j) {
var hit = hits[keys[j]];
if (hit) {
var r = JSON.parse(hit);
where[x].forEach(function (i) { out[i] = r; });
} else {
miss.push({ input: x, cacheKey: keys[j] });
}
});
for (var s = 0; s < miss.length; s += PA_PER_REQUEST) {
if (s > 0 && Date.now() - t0 > PA_START_BEFORE_MS) break; // leave the rest for the next run of the formula
var part = miss.slice(s, s + PA_PER_REQUEST);
var answers = paBatch_(key, part.map(function (m) { return m.input; }));
part.forEach(function (m, j) {
var r = answers[j];
where[m.input].forEach(function (i) { out[i] = r; });
if (r.cacheFor) {
var text = JSON.stringify({ data: r.data, message: r.message });
if (text.length > 90000 && r.data) text = JSON.stringify({ data: paSmall_(r.data) });
if (text.length <= 90000) cache.put(m.cacheKey, text, r.cacheFor);
}
});
}
return out.map(function (r) {
return r ? { data: r.data, message: r.message } : { message: "Not looked up yet (Google's time limit for one formula). Enter the formula again to continue; homes already found come from the cache." };
});
}
/** The fields =ZILLOW_ROW needs, for a record too large to cache whole. */
function paSmall_(d) {
var o = {};
PA_ROW.forEach(function (f) { o[f[1]] = d[f[1]]; });
return o;
}
/** One POST /v1/zillow/batch call. Returns one { data, cacheFor } or { message, cacheFor? } per item. */
function paBatch_(key, items) {
var res;
try {
res = UrlFetchApp.fetch(PROPERTIESAPI_BASE + "/zillow/batch", {
method: "post",
contentType: "application/json",
headers: { "x-api-key": key },
payload: JSON.stringify({ items: items }),
muteHttpExceptions: true,
});
} catch (e) {
return items.map(function () { return { message: "Error: couldn't reach PropertiesAPI. Try again shortly." }; });
}
var code = res.getResponseCode();
var body = null;
try { body = JSON.parse(res.getContentText()); } catch (e) { body = null; }
if (code !== 200 || !body || !Array.isArray(body.data)) {
var err = (body && body.error) || { code: "http_" + code, message: "PropertiesAPI answered HTTP " + code + "." };
var msg = paError_(err, code);
return items.map(function () { return { message: msg }; });
}
return items.map(function (_, i) {
var r = body.data[i];
if (r && r.ok) return { data: r.data, cacheFor: PA_CACHE_SECONDS };
var e = (r && r.error) || { code: "unknown", message: "No answer for this item." };
return { message: paError_(e, 0), cacheFor: e.code === "not_found" || e.code === "address_ambiguous" ? PA_MISS_SECONDS : 0 };
});
}
/** The message a cell shows for an API error. */
function paError_(e, status) {
var d = e.details || {};
switch (e.code) {
case "missing_api_key":
case "invalid_api_key":
return "Error: invalid API key. Set it again with PropertiesAPI > Set API key.";
case "revoked_api_key":
return "Error: this API key was revoked. Create a new one at " + PROPERTIESAPI_DASHBOARD + " and set it with PropertiesAPI > Set API key.";
case "credits_exhausted":
return "Error: out of credits. Upgrade or check your usage at " + (d.upgrade_url || PROPERTIESAPI_DASHBOARD + "#billing");
case "spend_limit_reached":
return "Error: your monthly spending limit is reached. Raise or remove it at " + (d.dashboard_url || PROPERTIESAPI_DASHBOARD);
case "address_ambiguous":
return "Error: address ambiguous. Zillow's closest match is a different home" + (d.candidate && d.candidate.detailUrl ? ": " + d.candidate.detailUrl + " (use that link if it's the one)" : "") + ". Or add the unit, city or ZIP.";
case "not_found":
return "Not found";
case "bad_param":
case "missing_param":
return "Error: not a Zillow link, zpid or address (" + e.message + ")";
case "rate_limited":
return "Error: too many lookups this minute. Try again in " + (d.retry_after || 60) + " seconds.";
default:
return "Error: " + (e.message || e.code || "HTTP " + status);
}
}
The formulas
Each one takes a Zillow link, a zpid or an address, or a range of them.
| Formula | Returns |
|---|---|
| =ZILLOW(A2) | The listing price for the Zillow link, zpid or address in A2 |
| =ZILLOW(A2, "beds") | Any field: price, status, beds, baths, sqft, lot, year_built, zestimate, rent_zestimate, days_on_zillow, zpid, url, address, or any field name from the API response |
| =ZILLOW_ROW(A2) | The main fields across one row: Address, Price, Status, Beds, Baths, Sqft, Lot sqft, Year built, Zestimate, Rent Zestimate, Days on Zillow, zpid, Zillow link |
| =ZILLOW_HEADERS() | The header row that matches =ZILLOW_ROW |
| =ZESTIMATE(A2) | Zillow's Zestimate for the home, when Zillow shows one |
| =ZILLOW(A2:A9, "price") | Ranges work too: one answer per cell, and repeats of the same home are looked up once. A big range fills in over several runs of the formula (see below) |
A B C D
1 Zillow link, zpid or address Price Zestimate Beds
2 https://www.zillow.com/homedetails/…_zpid/ =ZILLOW(A2) =ZESTIMATE(A2) =ZILLOW(A2, "beds")
3 2640 Steiner St, San Francisco, CA 94115 =ZILLOW(A3) =ZESTIMATE(A3) =ZILLOW(A3, "beds")
4 …fill downField names
The second argument of =ZILLOW. These short names map to API fields; any other API field name works as is.
| You type | API field |
|---|---|
| price | price |
| status | homeStatus |
| beds | beds |
| baths | baths |
| sqft | livingArea |
| lot | lotSize |
| year_built | yearBuilt |
| zestimate | zestimate |
| rent_zestimate | rentZestimate |
| days_on_zillow | daysOnZillow |
| zpid | zpid |
| url | detailUrl |
| address | address |
What the cell tells you
Problems show as text in the cell, so you can see which row needs attention.
| The cell says | What to do |
|---|---|
| Error: no API key | Use the menu PropertiesAPI > Set API key. |
| Error: invalid API key | Paste the key again with PropertiesAPI > Set API key, or create a new one in your dashboard. |
| Error: out of credits | Your lookups are used up. The message links to your dashboard's Plan & billing section. |
| Error: address ambiguous | Zillow's closest match is a different home. The message includes that home's Zillow link: use it if it's the one, or add the unit, city or ZIP. |
| Not found | Zillow has no home matching the input. Not billed. |
| Not looked up yet | Google's time limit for one formula was reached. Enter the formula again: homes already found come from the cache. |
- Lookups go through the batch endpoint, 2 homes per request, and each result is cached in Google for 6 hours. Repeats of the same home in a range are sent once.
- A new home takes typically 6 seconds, up to about 20. Repeats within 24 hours come from cache, fast and free; after that a recalculation is a new lookup.
- A blank cell means Zillow doesn't show that field for the home, for example no price on an off-market home.
- You're billed only for homes found: "Not found", ambiguous addresses and errors cost nothing.
A long list? Upload it to the dashboard's Bulk lookup instead. Prefer code? The Python tutorial batches a CSV into pandas.
Try it on your own list
50 free lookups, no card, no approval.
Google Sheets questions
Is there a Zillow API for Google Sheets?+
Zillow doesn't offer one. This page gives you an Apps Script that adds =ZILLOW() and similar formulas by calling PropertiesAPI, an independent service that is not affiliated with Zillow. The data is what the public Zillow page shows, provided as-is.
Where is my API key stored?+
In your own user properties for this script (PropertiesService), set from the PropertiesAPI menu. It is never written to a cell, so sharing the sheet doesn't share the key. Each person who uses the sheet sets their own key.
Can I use an address instead of a Zillow link?+
Yes. Put a Zillow link, a zpid or an address in the cell. Addresses need a house number plus the city and state, or the ZIP.
Why do cells show Loading?+
A new home is a live lookup, which takes typically 6 seconds, up to about 20. Google stops a custom function after about 30 seconds, so the script sends 2 homes per request and starts a request only while one can still finish in time: none after 8 seconds. So a big range fills in over several recalculations: homes it didn't reach say "Not looked up yet", and entering the formula again continues, with the homes already found coming from the cache. An occasional lookup takes longer than Google allows; then the cell shows Google's error, and entering the formula again gets the home from our cache, free.
Will recalculating the sheet use up my lookups?+
Not within 24 hours of a lookup: the script caches each result for 6 hours, and on our side repeat lookups of the same home within 24 hours come from our cache and are free. After that, a recalculation is a new lookup and uses a credit. Lookups that fail, such as a link we can't find, aren't billed.
What about thousands of rows?+
Use the dashboard's Bulk lookup instead: upload a CSV of up to 5,000 rows and download it back with the main fields added. It uses the same credits.
How many rows can I run?+
50 free lookups, with no card. Paid plans (lookups a month): Starter $29/mo (4,000); Pro $79/mo (11,500); Business $199/mo (30,000); Scale $499/mo (80,000). Overage is your plan's rate per 1,000 lookups, shown on each plan. Or pay as you go: $10 per 1,000 lookups, prepaid, in packs from $20 for 2,000 lookups up to $250 for 25,000 lookups. No subscription; credits last 12 months, and on a plan they are used after its included lookups. Buying a pack ends the free trial: from then on lookups use your prepaid credits. Zillow links and addresses cost the same: 1 credit per successful lookup.
Related guides
- Enrich a Property Address List with Python and Export to CSV
Turn an address spreadsheet into property data with a downloadable Python script. Keep prices, Zillow estimates, collection times, and every failed row.
- Asking Rent vs. Rent Zestimate: Build a Rental Comparison Spreadsheet
What the rent Zestimate means, how it differs from the asking rent, and a Python report that compares the two for a list of rental properties.
- Merge Duplicate Properties Across Spreadsheets Without Losing Provenance
Deduplicate property address spreadsheets by confirmed property identity. Keep every original row, alias, warning and collection time, and leave unresolved inputs alone.
Your spreadsheet, filled in
50 free lookups, no card.
Zillow is a trademark of Zillow, Inc. PropertiesAPI is an independent service and is not affiliated with, endorsed by or sponsored by Zillow.