Zillow API for Google Sheets

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.

Step 1

Open Apps Script

In your sheet, open Extensions → Apps Script. Delete what's in the editor.

Step 2

Paste and save

Copy the script below, paste it in, and save. Then reload the spreadsheet.

Step 3

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.gs (Apps Script): use Copy for the whole script
/**
 * 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.

Google Sheets formulas for Zillow data
FormulaReturns
=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)
What your sheet looks like
      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 down

Field names

The second argument of =ZILLOW. These short names map to API fields; any other API field name works as is.

Field names for =ZILLOW
You typeAPI field
priceprice
statushomeStatus
bedsbeds
bathsbaths
sqftlivingArea
lotlotSize
year_builtyearBuilt
zestimatezestimate
rent_zestimaterentZestimate
days_on_zillowdaysOnZillow
zpidzpid
urldetailUrl
addressaddress

What the cell tells you

Problems show as text in the cell, so you can see which row needs attention.

Messages the formulas return
The cell saysWhat to do
Error: no API keyUse the menu PropertiesAPI > Set API key.
Error: invalid API keyPaste the key again with PropertiesAPI > Set API key, or create a new one in your dashboard.
Error: out of creditsYour lookups are used up. The message links to your dashboard's Plan & billing section.
Error: address ambiguousZillow'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 foundZillow has no home matching the input. Not billed.
Not looked up yetGoogle'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.

Get a free API key →

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.

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.