Build a project

Add a Property Lookup Form to Your Website

Build a server-backed address form with success, review and unavailable states. Keep the API key on the server, confirm ambiguous matches, and cap how many paid lookups visitors can start.

10 min read

A lookup form your visitors can use

A property lookup form lets a visitor type an address and see the home's details on your site: a lead form for an agency, an intake step for a property manager, or a tool page for an investor audience. Unlike a dashboard built from a saved snapshot, a form runs a live lookup on demand, for someone you do not know. That changes the design priorities.

Three things matter most. The API key must never reach the browser. Every outcome needs a clear state, including the cases where the match is uncertain or the lookup is unavailable. And because a successful live lookup costs a credit, the form has to limit how many lookups strangers can start. The example below is a single Node file with no dependencies that does all three.

Keep the key on the server

The browser posts the address to your own server. Your server calls the property endpoint with the key from an environment variable, then returns finished HTML. The visitor never sees the key, the API URL or the raw response, and no JavaScript bundle contains anything secret.

Do not call the API from client-side code, even with a key you consider low-value. Anyone can copy a key from the browser and use your credits from anywhere. If you prefer a framework, the same pattern works in a server route or server action: the form submits to code that runs on your backend, and only that code holds the key.

  • Browser to your server: the address and a hidden honeypot field.
  • Your server to the API: the address, the x-api-key header and a long timeout.
  • Your server to the browser: escaped HTML for one of four states.

Design the four states first

Most broken lookup forms fail in the same way: they assume success. Write the copy for each state before you write code, and test every one of them.

StateWhenWhat the visitor sees
SuccessThe lookup returned a propertyAddress, status, a price labeled by status, basic facts, the Zestimate labeled as Zillow's, and the collection time
ReviewThe API returned address_ambiguous with a candidate linkThe difference, a link to the suggested home, and a choice to confirm it or edit the address
Check inputThe address failed validation locally or as bad_paramA request for a full address with a house number, city and state or ZIP
Unavailablenot_found, rate limits, upstream errors, timeouts, or your own capA plain message and a way back, never an empty card or a zero price

Make the review state a real decision

When the closest match for an address is a different home, for example a different unit or house number, the API answers address_ambiguous instead of returning that home. Nothing is billed. The error details include the candidate's Zillow link and a short description of what differed.

Show both to the visitor and let them decide. If they confirm, your server looks up the candidate by its URL, which is a normal billed lookup. The example stores the candidate link on the server under a random one-time token, so the confirm button cannot be edited into a lookup of some other URL. If they decline, send them back to the form to add the unit, ZIP or city. Never confirm on their behalf by fetching the candidate automatically.

Restrict expensive requests

A public form is an open door to your credit balance. The OWASP API security project lists unrestricted resource consumption, including spending on paid third-party services, as a top risk and recommends rate limits and spending limits. The example applies several cheap layers before any paid call.

Input is validated before a lookup starts: the address must have a reasonable length, start with a house number and include a comma before a city or ZIP. The API applies its own validation too, and rejects bad input without billing, but checking locally saves a round trip. A hidden honeypot field catches simple bots. A same-origin check refuses posts from other sites. A per-visitor window and a daily budget stop a single visitor or a flood of visitors from starting more lookups than you planned.

On the account side, you can also set an optional monthly spending limit in your dashboard on a paid plan. When it is reached, lookups return spend_limit_reached and are not billed. That limit is a backstop, not a substitute for limits in your own form.

  • Tune the per-visitor and daily numbers to your traffic and budget.
  • Behind a proxy or load balancer, read the visitor address from the header your proxy sets, not from the socket.
  • The in-memory maps reset on restart. Use a shared store if you run several instances.
  • Add a CAPTCHA or sign-in step if the form attracts automated abuse.

The complete server

Save this as server.mjs, set PROPERTIESAPI_KEY in the environment and run it with Node. Every value from the response passes through an escaping function before it is written into HTML. The fetch uses a long timeout because live lookups can take a while, and redirects are refused so the key header is never forwarded to another host.

The success view labels the price by homeStatus: an asking price for listed homes, a monthly rent for rentals and a sale price for recently sold homes. Missing values show as not shown rather than zero.

server.mjs
// server.mjs: a property lookup form that keeps the API key on the server. Node 18 or newer.
// Run: PROPERTIESAPI_KEY=... node server.mjs   then open http://localhost:3000
import http from "node:http";
import { randomUUID } from "node:crypto";

const KEY = process.env.PROPERTIESAPI_KEY;
const API = "https://api.propertiesapi.com/v1/zillow/property";
const PORT = Number(process.env.PORT || 3000);
const PER_VISITOR = 5, WINDOW_S = 600;   // lookups one visitor may start per window
const DAILY_BUDGET = 100;                // lookups this site may start per UTC day
if (!KEY) throw new Error("Set PROPERTIESAPI_KEY in the server environment");

const recent = new Map();       // visitor -> start times
const candidates = new Map();   // one-time token -> { url, expires }
let budgetDay = "", startedToday = 0;

function admit(visitor) {
  const now = Date.now(), today = new Date().toISOString().slice(0, 10);
  if (today !== budgetDay) { budgetDay = today; startedToday = 0; }
  const times = (recent.get(visitor) || []).filter((t) => now - t < WINDOW_S * 1000);
  if (times.length >= PER_VISITOR || startedToday >= DAILY_BUDGET) return false;
  times.push(now); recent.set(visitor, times); startedToday++;
  return true;
}

const esc = (s) => String(s ?? "").replace(/[&<>"']/g, (c) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" })[c]);
const money = (v) => (v == null ? "Not shown" : "$" + Number(v).toLocaleString("en-US"));
const looksLikeAddress = (a) => a.length >= 5 && a.length <= 200 && /^\d/.test(a) && a.includes(",");

async function lookup(params) {
  try {
    const res = await fetch(`${API}?${new URLSearchParams(params)}`, {
      headers: { "x-api-key": KEY }, redirect: "error", signal: AbortSignal.timeout(130_000),
    });
    return { status: res.status, body: await res.json().catch(() => null) };
  } catch (e) {
    return { status: 0, body: null, timedOut: e?.name === "TimeoutError" };
  }
}

function success({ data: d, meta: m }) {
  const label = { FOR_RENT: "Monthly asking rent", RECENTLY_SOLD: "Sale price" }[d.homeStatus] ||
    (["FOR_SALE", "PENDING", "COMING_SOON", "FORECLOSURE"].includes(d.homeStatus) ? "Asking price" : "Price");
  const url = d.detailUrl?.startsWith("https://www.zillow.com/") ? d.detailUrl : null;
  return `<h2>${esc(d.address)}</h2><dl>
    <dt>Status</dt><dd>${esc(d.statusText || d.homeStatus || "Not shown")}</dd>
    <dt>${label}</dt><dd>${money(d.price)}</dd>
    <dt>Beds / baths / sqft</dt><dd>${esc(d.beds ?? "?")} / ${esc(d.baths ?? "?")} / ${esc(d.livingArea ?? "?")}</dd>
    <dt>Zestimate (Zillow's estimate)</dt><dd>${money(d.zestimate)}</dd></dl>
    <p class="small">Collected ${esc(m.fetched_at)}. ${url ? `<a href="${esc(url)}" rel="noopener">Source page</a>. ` : ""}
    Data as shown on the public Zillow page; not affiliated with Zillow.</p>`;
}

function result(r) {
  const err = r.body?.error;
  if (r.status === 200 && r.body?.data) return success(r.body);
  if (err?.code === "address_ambiguous" && err.details?.candidate?.detailUrl?.startsWith("https://www.zillow.com/")) {
    const token = randomUUID();
    candidates.set(token, { url: err.details.candidate.detailUrl, expires: Date.now() + 600_000 });
    return `<h2>Is this the home you meant?</h2>
      <p>The closest match differs from what you typed (${esc(err.details.mismatch ?? "details differ")}).</p>
      <p><a href="${esc(err.details.candidate.detailUrl)}" rel="noopener" target="_blank">Open the suggested property</a></p>
      <form method="post" action="/confirm"><input type="hidden" name="token" value="${token}">
      <button>Yes, look up this property</button></form><p><a href="/">No, edit the address</a></p>`;
  }
  if (err?.code === "not_found") return `<h2>No matching property found</h2><p>Check the house number, unit and ZIP code. <a href="/">Try again</a></p>`;
  if (err?.code === "bad_param" || err?.code === "missing_param") return `<h2>Please check the address</h2><p>Use a full US address with a house number, city and state or ZIP. <a href="/">Try again</a></p>`;
  return `<h2>Lookup unavailable right now</h2><p>${r.timedOut ? "The lookup took too long." : "Please try again later."} <a href="/">Back</a></p>`;
}

const page = (inner) => `<!doctype html><html lang="en"><meta charset="utf-8"><meta name="viewport" content="width=device-width">
<title>Property lookup</title><style>body{font:16px system-ui;max-width:36rem;margin:2rem auto;padding:0 1rem}.small{font-size:.85em;color:#555}
dt{font-weight:600}dd{margin:0 0 .5rem}</style>${inner}</html>`;
const FORM = page(`<h1>Look up a property</h1><form method="post" action="/lookup">
<label for="a">Full address, including any unit</label><br><input id="a" name="address" required minlength="5" maxlength="200"
 placeholder="2640 Steiner St, San Francisco, CA 94115" style="width:100%"><input name="website" tabindex="-1" autocomplete="off" hidden>
<p><button>Look up</button></p><p class="small">A lookup can take a while. Please submit once.</p></form>`);

async function readForm(req) {
  let raw = "";
  for await (const chunk of req) { raw += chunk; if (raw.length > 2048) throw new Error("body too large"); }
  return new URLSearchParams(raw);
}

http.createServer(async (req, res) => {
  const send = (code, html) => { res.writeHead(code, { "content-type": "text/html; charset=utf-8", "cache-control": "no-store" }); res.end(html); };
  try {
    if (req.method === "GET" && req.url === "/") return send(200, FORM);
    if (req.method !== "POST" || !["/lookup", "/confirm"].includes(req.url)) return send(404, page("<h1>Not found</h1>"));
    const origin = req.headers.origin;
    if (origin && new URL(origin).host !== req.headers.host) return send(403, page("<h1>Forbidden</h1>"));
    const form = await readForm(req);
    if (form.get("website")) return send(200, FORM);                  // honeypot: bots fill hidden fields
    let params;
    if (req.url === "/confirm") {
      const c = candidates.get(form.get("token") || "");
      candidates.delete(form.get("token") || "");                    // one use only
      if (!c || c.expires < Date.now()) return send(400, page(`<h2>That confirmation expired.</h2><a href="/">Start again</a>`));
      params = { url: c.url };
    } else {
      const address = (form.get("address") || "").trim();
      if (!looksLikeAddress(address)) return send(400, page(result({ status: 400, body: { error: { code: "bad_param" } } })));
      params = { address };
    }
    if (!admit(req.socket.remoteAddress || "unknown")) return send(429, page(`<h2>Too many lookups</h2><p>Please try again later.</p>`));
    return send(200, page(result(await lookup(params))));
  } catch {
    return send(400, page(`<h2>Something went wrong.</h2><a href="/">Start again</a>`));
  }
}).listen(PORT, () => console.log(`http://localhost:${PORT}`));

Handle waiting and timeouts

A live lookup typically takes 8 seconds, up to about 20. Set a client timeout of at least 120 seconds: an occasional lookup takes longer. A plain form submission keeps the browser's loading indicator running while it waits, which is honest feedback. If you switch to a script-driven form, disable the button after the first click and show a waiting message, so impatient visitors do not start a second paid lookup.

Repeat lookups of the same home within 24 hours come from our cache and are free. That helps when a visitor refreshes the result or several visitors check the same address.

Check it before you launch

Test each state on purpose. Submit an address without a house number, an address with a wrong unit, an address that does not exist and a valid one. Exceed the per-visitor limit. Stop the network to see the unavailable state. Confirm that viewing the page source never shows the key, and that a property description containing angle brackets renders as text.

Then read the result page as a visitor would. Is the Zestimate clearly labeled as Zillow's estimate? Is a rent obviously a monthly figure? Is there a collection time? A form that answers those questions earns more trust than one that shows a bare number.

Sources and documentation

Product details checked against the linked documentation on October 22, 2026. Code and sample inputs are illustrative unless stated otherwise.

Written by PropertiesAPI, an independent property data service. We are not affiliated with Zillow. Source data may be incomplete or change over time.