For proptech teams

Zillow API for proptech products

Ship Zillow listing data inside your product without building a scraper: a thin backend service, your own cache keyed on zpid, clear handling for every error code, and credit tracking from the response headers.

The job

Product teams usually meet Zillow data as a feature request: let users paste a Zillow link and fill in the property. The hard part isn't the request; it is everything around it: a lookup that can take seconds, addresses that match the wrong home, values that are missing, and a bill that grows with every page view.

The pattern below keeps those concerns in one service. Your app never calls the API from the browser, never waits on a live lookup in a page request it doesn't have to, and never pays twice for a home it looked up recently.

The workflow

  1. Wrap the API in one backend service. Keep the key server-side. Expose your own endpoint that takes a link or address and returns your own shape, so the rest of the app doesn't depend on ours.
  2. Store by zpid and detailUrl. Save the response with zpid (or detailUrl for apartment buildings) as the key and fetched_at as the timestamp, and decide your own freshness window on top of our short cache.
  3. Map every error to a product state. not_found and address_ambiguous are user-facing states; rate_limited and upstream_busy are retries; credits_exhausted is an alert for you, not your user.
  4. Refresh in the background. Re-check saved homes with the batch endpoint from a job, and track credits from the x-credits-remaining header so you see spend before it surprises you.

A backend lookup service

TypeScript for Node.js. One function your routes call: it reads your store first, calls the API on a miss, maps errors to states your UI understands and records credits left. Store, metrics and alerts stand for your own modules.

TypeScript
const BASE = "https://api.propertiesapi.com/v1";

type LookupInput = { url: string } | { address: string } | { zpid: string };
type LookupResult =
  | { state: "found"; home: Record<string, unknown>; fetchedAt: string; fromCache: boolean }
  | { state: "not_found" }
  | { state: "needs_confirmation"; candidateUrl: string | null }
  | { state: "invalid_input"; message: string }
  | { state: "try_later"; retryAfter: number };

export async function lookupHome(input: LookupInput, store: Store): Promise<LookupResult> {
  const saved = await store.find(input);
  if (saved && Date.now() - Date.parse(saved.fetchedAt) < store.maxAgeMs) return { state: "found", ...saved, fromCache: true };

  const url = new URL(`${BASE}/zillow/property`);
  for (const [k, v] of Object.entries(input)) url.searchParams.set(k, v);
  const res = await fetch(url, { headers: { "x-api-key": process.env.PROPERTIESAPI_KEY! }, signal: AbortSignal.timeout(120_000) });
  metrics.gauge("propertiesapi.credits_remaining", Number(res.headers.get("x-credits-remaining")));
  const body = await res.json();

  if (res.ok) {
    await store.save(body.data.zpid ?? body.data.detailUrl, body.data, body.meta.fetched_at);
    return { state: "found", home: body.data, fetchedAt: body.meta.fetched_at, fromCache: body.meta.cached };
  }
  const { code, message, details } = body.error;
  switch (code) {
    case "not_found": return { state: "not_found" };
    case "address_ambiguous": return { state: "needs_confirmation", candidateUrl: details?.candidate?.detailUrl ?? null };
    case "bad_param": case "missing_param": return { state: "invalid_input", message };
    case "rate_limited": case "upstream_busy": case "live_lookups_capped":
      return { state: "try_later", retryAfter: Number(res.headers.get("retry-after") ?? details?.retry_after ?? 30) };
    default:
      alerts.notify(`propertiesapi ${res.status} ${code}`); // credits_exhausted, invalid_api_key, upstream_* ...
      return { state: "try_later", retryAfter: 60 };
  }
}

Fields that matter here

Response fields for proptech teams
FieldWhy it matters
zpid, detailUrlStable identifiers to key your store on. zpid is null for an apartment building; use detailUrl there.
meta.cached, meta.credits, meta.fetched_atWhether the answer was a free cache hit, what it cost, and when the data was read.
x-credits-remaining headerCredits left on the account after this call, on every response.
error.code, error.detailsMachine-readable reasons, including the closest match's link for address_ambiguous and the wait for rate limits.
scrapedAtWhen the property data was collected, for your own freshness rules.
calculatedReady-made figures such as price per square foot and gross yield, so you don't reimplement them.

What it won't do

  • Lookups are one home at a time, or up to 50 items per batch request. There is no search, feed or streaming of new listings.
  • The data is what the public Zillow page shows, provided as-is. It isn't licensed MLS data.
  • No consumer reports or decisions covered by the FCRA, such as tenant screening, credit or insurance.
  • Photos are URLs to the listing's images. Don't re-host them in your product.

Proptech teams: questions

Can we call the API from our frontend?+

No. The key would be visible to every user. Call it from your backend and return your own response.

How fresh is the data?+

Each live lookup reads the property's public Zillow page when you ask. Repeat lookups of the same home within 24 hours come from our cache and are free. Use meta.fetched_at to apply your own freshness rules.

Is there an OpenAPI spec for code generation?+

Yes, the spec is linked from the docs and describes both endpoints, their parameters, the response fields and the error codes.

How do we keep costs predictable?+

Zillow links and addresses cost the same: 1 credit per successful lookup. Keep your own store, refresh on a schedule rather than per page view, and watch x-credits-remaining. Plans and prices are on the pricing page.

Try it on your own list

50 free lookups, no card, no approval.

Zillow is a trademark of Zillow, Inc. PropertiesAPI is an independent service and is not affiliated with, endorsed by or sponsored by Zillow.