# Zillapi: Zillow property data API (full reference for LLM context) A REST API for U.S. real estate data. Built to be consumed by AI agents and SaaS products. White-label: customers never see vendor names in responses, error messages, or job records. Base URL: `https://api.zillapi.com` Auth header: `Authorization: Bearer zk_…` OpenAPI 3.1 spec: `https://zillapi.com/openapi.json` MCP server: `https://api.zillapi.com/mcp` (streamable-http) Agent skills: `https://github.com/ZeroPointRepo/zillow-skills` (MIT-0) Get a key in 30 seconds at https://zillapi.com/signup — 100 credits free, no card required. Same key authenticates REST, MCP, and skills. ================================================================ # Pricing Three plans. Current prices: https://zillapi.com/pricing | Plan | Credits | Rate limit | Top-ups | |---|---:|---:|---| | Free | 100 (one-time) | 20/min | not available | | Monthly | 1,000/month | 200/min | available | | Annual | 12,000/year | 300/min | available | One credit equals one property record returned. Failed calls do not consume credits. Caching (fresh = ≤ 24h old) on `/v1/properties/{zpid}` and sub-resources is a transparent optimization — same price, faster responses; every successful lookup costs 1 credit. Top-ups are available on Monthly and Annual plans only. On Monthly, top-up credits are valid for at least 30 days (through the end of your current billing period). On Annual, top-up credits stay valid through the end of your 12-month term (up to a full year), so you can stock up early in the term and use them across the whole year. Top-up credits are usable while your plan is active. Free does not auto-renew — it's a one-time grant of 100 credits at signup. To keep going, upgrade to Monthly or Annual at https://zillapi.com/pricing/. ================================================================ # Authentication API keys are issued through the dashboard. Format: `zk_<43-char-base64url>`. The plaintext key is shown ONCE at creation; only the SHA-256 hash is stored server-side. To rotate, create a new key, switch traffic, then revoke the old one. Revocation takes effect within seconds. Get your first key at https://zillapi.com/signup — no credit card required. Errors: - 401 `missing_api_key` — no Authorization header - 401 `invalid_api_key` — bad format / unknown / revoked - 403 `account_suspended` — plan inactive - 429 `quota_exceeded` — monthly result quota hit - 429 `rate_limited` — per-minute rate limit hit ================================================================ # Errors Universal envelope: ``` { "error": { "code": "...", "message": "...", "request_id": "..." } } ``` `error.code` is stable; `error.message` may evolve. Match on code, never prose. Always include `request_id` in support tickets. HTTP code mapping: - 200 success · 201 created · 202 accepted (async) · 204 no content - 400 client validation · 401 auth · 403 suspended · 404 not found - 409 wrong job state · 429 rate/quota · 502 upstream · 504 upstream timeout Common codes: `missing_api_key`, `invalid_api_key`, `quota_exceeded`, `rate_limited`, `invalid_url`, `invalid_address`, `invalid_zpid`, `missing_input`, `invalid_filters`, `invalid_search_url`, `invalid_status`, `invalid_extract_units`, `not_found`, `job_not_ready`, `job_not_found`, `upstream_timeout`, `upstream_error`, `invalid_json`. Search-specific validation (all `400`): `missing_input` — `POST /v1/search` with neither `filters` nor `searchUrls`; `invalid_filters` — `filters` present but no `bbox` (a free-text `location`/city alone is not enough), or a malformed `bbox`/range; `invalid_search_url` — a `searchUrls[].url` that lacks a `?searchQueryState=…` query param (pretty URLs like `/austin-tx/houses/` are rejected). ================================================================ # Rate limits & quotas Three independent limits per API key: 1. Per-minute rate (sliding window). 2. Concurrency = 1 — one in-flight request per key. Extra parallel calls serialize behind the one in flight; they are not rejected. Scale by raising your plan, not by parallelism. 3. Credit balance — drawn down per successful call. Failed calls do not consume credits. | Plan | Rate (req/min) | Concurrency | Credits | |---|---:|---:|---:| | Free | 20 | 1 | 100 (one-time grant at signup) | | Monthly | 200 | 1 | 1,000 per cycle | | Annual | 300 | 1 | 12,000/year (1,000/month) | Top-ups are available on Monthly and Annual plans; see https://zillapi.com/pricing for current top-up rates. Free does not support top-ups — upgrade to keep going. Caching (fresh = ≤ 24h old) on `/v1/properties/{zpid}` and sub-resources is a transparent optimization — same price, faster responses; every successful lookup costs 1 credit. Result caps (one table, reconciling "≤50" / "up to 50" / "1–500"): | Endpoint | Field | Bounds | Sync ↔ async | |---|---|---|---| | `POST /v1/search`, `POST /v1/listings/{for-sale,for-rent,sold}` | `maxItems` | 1–~820 (PAGINATION); ~500 (MAP_MARKERS) | `maxItems ≤ 50` sync; **`maxItems ≥ 51` async**; `PAGINATION_WITH_ZOOM_IN`/`async:true` always async | | `POST /v1/search/with-details` | `maxItems` | 1–~820 | always async (two chained stages) | | `GET /v1/listings` | `max_items` | 1–50 | sync only — for >50 use `POST /v1/search` | | `POST /v1/properties/batch` | entries (`urls`+`addresses`) | up to 500/job | always async | | `GET /v1/buildings/by-url` | — | — | sync default; `sync=false` for large buildings | | `GET /v1/jobs` | `limit` | 1–500 (default 50) | n/a | | `GET /v1/jobs/{id}/results` | `limit` | 1–1000 (default 100) | n/a | | `GET /v1/usage` | `limit` | 1–1000 (default 100) | n/a | 50 is the sync-search cap, 500 the batch-entry cap, 1000 the job-results page cap — different knobs, not a conflict. ================================================================ # Output formats & projection `?format=json` (default), `?format=csv`, `?format=ndjson` — also driven by `Accept` header. - NDJSON: one JSON object per line, no envelope. - CSV: nested fields flattened with dot-notation keys; arrays JSON-stringified. Field projection on detail endpoints: `?fields=zpid,address.streetAddress,price,priceHistory[0].price`. Supports dotted paths and `[n]` array indexing. ================================================================ # Async jobs Endpoints that return `202 { data: { job_id, status } }`: - `POST /v1/properties/batch` (always async) - `POST /v1/search` (when `maxItems > 50` or `extractionMethod=PAGINATION_WITH_ZOOM_IN` or `async: true`) - `POST /v1/search/with-details` (always async, two-stage chained) - `GET /v1/buildings/by-url?sync=false` Track via: 1. Polling `GET /v1/jobs/{id}` (status terminal: succeeded, failed, timed_out, aborted). 2. Webhook (recommended for production). Fetch results: `GET /v1/jobs/{id}/results?limit=&offset=&format=`. Caps: limit max 1000, offset unbounded. ================================================================ # Webhooks (outbound) Customer registers a URL via `POST /v1/webhooks`. We POST signed events when a job becomes terminal. Headers we send: - `X-Zillow-Signature: t=,v1=` — HMAC-SHA256 over `.` with the webhook secret. - `X-Zillow-Event: job.{succeeded|failed|timed_out|aborted}` - `Content-Type: application/json` - `User-Agent: zillow-api-platform/1.0` Payload: ``` { "event": "job.succeeded", "delivered_at": "...", "data": { "job": { "id": "...", "type": "...", "status": "...", "result_count": 213, "..." } } } ``` Verification: regex-match the header, check timestamp skew < 5 min, recompute HMAC, constant-time compare. See `/recipes/verify-webhook/` for code in Node, Python, Go. Delivery: 8s timeout, up to 3 attempts (initial + 2 retries) with quadratic backoff. Each attempt logged in `/v1/webhooks/{id}/deliveries`. ================================================================ # API REFERENCE ## Properties Credits at point of use: `by-address` = **3**, `by-url` = **1**, `{zpid}` and sub-resources = **1** upstream / **0** on a fresh cache hit (≤24h), `batch` = **1 per record**. Failed calls are always free. ### GET /v1/properties/by-url (1 credit) Query: `url` (required), `status` (FOR_SALE|RECENTLY_SOLD|FOR_RENT, default FOR_SALE), `extract_units` (disabled|all|for_sale|recently_sold|for_rent|off_market, default disabled), `fields`. Response: `{ data: , request_id }`. If extract_units != disabled and the URL is multi-unit, `data` is an array. Envelope has NO `cached`/`fetched_at` (unlike `{zpid}`). ### GET /v1/properties/by-address (3 credits — we geocode/resolve upstream) Query: `address` (required), `status`, `fields`. Response: `{ data: , request_id }`. Envelope has NO `cached`/`fetched_at`. ### GET /v1/properties/{zpid} (0 credits on a fresh cache hit, else 1) Cache-first (24h TTL). Response: `{ data, cached, fetched_at, request_id }` — `cached`/`fetched_at` appear only on this zpid envelope, not on by-url/by-address. ### Sub-resources (1 credit; 0 on fresh cache hit) - GET /v1/properties/{zpid}/photos → photos array + counts + has_3d/has_video - GET /v1/properties/{zpid}/price-history → priceHistory array - GET /v1/properties/{zpid}/tax-history → taxHistory array - GET /v1/properties/{zpid}/schools → schools array - GET /v1/properties/{zpid}/nearby → nearbyHomes array - GET /v1/properties/{zpid}/agent → agent + broker contact - GET /v1/properties/{zpid}/zestimate → zestimate, rent_zestimate, tax_assessed_value, last_sold_price, currency - GET /v1/properties/{zpid}/open-houses → schedule + tour_eligibility - GET /v1/properties/{zpid}/facts → resoFacts (full MLS attribute set) ### POST /v1/properties/batch (1 credit per record returned; settled when the job completes) Body: `{ urls?: string[], addresses?: string[], propertyStatus?, extractBuildingUnits?, maxItems? }`. Up to 500 entries. Always async. Response: 202 `{ data: { job_id, status } }`. ## Buildings ### GET /v1/buildings/by-url (1 credit per unit returned) Query: `url` (required, must be /b/, /apartments/, /community/), `include_units` (default all), `sync` (default true). Sync response: `{ data: { units: [...] }, meta: { count, include_units } }`. Async response: 202 `{ data: { job_id, status } }`. ## Listings (status-preset sugar over /v1/search) Credits: 1 per listing returned (minimum 1). Failed calls are free. `bbox` is required just like `/v1/search`. POST /v1/listings/for-sale POST /v1/listings/for-rent POST /v1/listings/sold Body: same as /v1/search (needs `filters.bbox` or `searchUrls`). Response: same as /v1/search (sync 200 or async 202). ### GET /v1/listings (REST wrapper) (1 credit per listing returned) Query: `status` (default for_sale), `bbox` (w,s,e,n — **required**), `location` (optional, decorative `usersSearchTerm` only — NOT a substitute for `bbox`; on its own → `400 invalid_filters`), `price_min/max`, `beds_min/max`, `baths_min/max`, `sqft_min/max`, `year_built_min/max`, `home_types` (comma-separated string: house,condo,townhouse,multi_family,manufactured,lot,apartment — POST equivalent is the `homeTypes` array), `days_on_zillow`, `max_items` (≤50), `format`. Sync only. ## Search Credits: `/v1/search` = 1 per result returned (min 1); `/v1/search/with-details` = 1 per search result + 1 per detail record (two stages billed separately). Failed calls are free. **A search is anchored by a bounding box, never by a place name.** Provide EITHER `filters.bbox` (option A) OR `searchUrls` (option B). A free-text `location`/city/ZIP/neighborhood alone → `400 invalid_filters`. To get a `bbox`: open the area on zillow.com's map, apply filters, read `west,south,east,north` off the map URL — or paste that whole URL into `searchUrls` (it must contain `searchQueryState=`). ### POST /v1/search Body option A (recommended) — `filters.bbox` REQUIRED: ``` { "filters": { "status": "for_sale|for_rent|sold", "bbox": { "west": -..., "south": ..., "east": ..., "north": ... }, // REQUIRED "location": "City, ST", // optional, decorative usersSearchTerm only — not a substitute for bbox "price": { "min": 0, "max": 0 }, "beds": { "min": 0, "max": 0 }, "baths": { "min": 0, "max": 0 }, "sqft": { "min": 0, "max": 0 }, "yearBuilt": { "min": 0, "max": 0 }, "homeTypes": ["house","condo","townhouse","multi_family","manufactured","lot","apartment"], "daysOnZillow": "1|7|14|30|90|6m|12m|24m|36m", "hasPool": false, "hasGarage": false, "hasAirConditioning": false, "hasBasement": false, "isWaterfront": false }, "extractionMethod": "PAGINATION|MAP_MARKERS|PAGINATION_WITH_ZOOM_IN", "maxItems": 50, "async": false } ``` Body option B (advanced): `{ "searchUrls": [{ "url": "https://www.zillow.com/...?searchQueryState=..." }] }`. The URL MUST contain a `?searchQueryState=…` query parameter; pretty URLs like `https://www.zillow.com/austin-tx/houses/` → `400 invalid_search_url`. Response: sync 200 (`maxItems ≤ 50`) or async 202 (`maxItems ≥ 51`, `PAGINATION_WITH_ZOOM_IN`, or `async:true`). ### POST /v1/search/with-details Same body as /v1/search (still bbox-or-searchUrls) plus `propertyStatus` and `extractBuildingUnits`. Always async. Returns `{ data: { job_id, status, stage: "search" } }`. Final results are detail rows, not search rows. ## Jobs Credits: control-plane — all `/v1/jobs*` reads are **free** (result data was billed once when the job completed). ### GET /v1/jobs Query: `status`, `type`, `since`, `limit` (≤500), `offset`. Returns list of job rows with id, type, status, result_count, error, chain_stage, timestamps. ### GET /v1/jobs/{id} Single job row. ### GET /v1/jobs/{id}/results Query: `limit` (≤1000), `offset`, `format` (json|csv|ndjson). 409 if status != succeeded. ## Webhooks Credits: control-plane — managing webhooks is **free**. ### POST /v1/webhooks Body: `{ url, events?, description? }`. Default events: all four. Response 201 includes `secret` field — plaintext, shown ONCE. ### GET /v1/webhooks Returns array of `{ id, url, events, active, description, created_at, revoked_at }`. Never returns secret. ### DELETE /v1/webhooks/{id} Soft-revoke. 204. ### GET /v1/webhooks/{id}/deliveries Per-attempt log: `{ id, job_id, event, attempt, status_code, delivered, attempted_at, response_preview }`. Useful for debugging delivery failures. ## Account Credits: control-plane — `/v1/me` and `/v1/usage` are **free**. ### GET /v1/me Returns `{ data: { id, email, plan_id, status, current_period_start, plan: { credits_per_cycle, rate_limit_per_minute }, credits: { balance, granted_this_cycle } }, request_id }`. There is NO `usage: { this_period, remaining }` object — your remaining-quota signal is `data.credits.balance` (credits left this cycle); `granted_this_cycle` is the cycle's starting grant. ### GET /v1/usage Query: `since`, `limit` (≤1000). Returns recent `{ id, endpoint, actor, units, status_code, created_at }` ledger rows (this is a usage log, not a `remaining` counter). ================================================================ # Property object — key fields Top-level: `zpid`, `address`, `bedrooms`, `bathrooms`, `price`, `homeType` (SINGLE_FAMILY|CONDO|TOWNHOUSE|MULTI_FAMILY|APARTMENT|MANUFACTURED|LOT), `homeStatus` (FOR_SALE|RECENTLY_SOLD|FOR_RENT|...), `latitude`, `longitude`, `livingArea`, `lotSize`, `yearBuilt`, `zestimate`, `rentZestimate`. Nested: `priceHistory[]`, `taxHistory[]`, `schools[]`, `nearbyHomes[]`, `responsivePhotos[]` (with width/height), `openHouseSchedule[]`, `resoFacts` (large MLS attribute object), `tourEligibility`. Agent/broker: `agentName`, `agentEmail`, `agentPhoneNumber`, `agentLicenseNumber`, `brokerName`, `brokerPhoneNumber`, `attributionInfo`. Search results have a different shape from the detail Property object: a search **row** is a compact card — `zpid`, `addressStreet/City/State/Zipcode`, `price`/`unformattedPrice`, `beds`, `baths`, `area`, `latLong: { latitude, longitude }`, `statusType`, plus `hdpData.homeInfo` containing `homeStatus`, `daysOnZillow`, `listing_sub_type`, etc. — NOT the 300+ detail fields. Take a row's `zpid` and call `GET /v1/properties/{zpid}` for the full record. Casing is intentional, not accidental: fields that pass through from Zillow keep their native **camelCase** (`unformattedPrice`, `latLong`, `daysOnZillow`, `hdpData`, `zpid`), while fields the platform synthesizes use **snake_case** (envelope `request_id`, `fetched_at`, `cached`; detail sub-resource fields like `rent_zestimate`, `tax_assessed_value`). `zpid` is always a **string** in responses. ================================================================ # Agent etiquette - Cache responses for 24h on your side too. - One in-flight request per key (concurrency = 1) — scale by raising plan, not parallelism. - Back off on 429 with exponential jitter, minimum 2s between retries. - Identify with a clear `User-Agent` like `MyAgent/1.2 (+https://yourdomain.example)`. - Use sub-resource endpoints when you only need part of the detail blob — the full record is 300+ fields. ================================================================ # White-label guarantee The platform is fully white-labeled. Customer-facing responses, errors, and job records strip vendor names (e.g. provider run ids, dataset ids). The only id customers see is our `request_id`. Field names in upstream payloads (e.g. `hdpData`, `zpid`) come from Zillow itself, not from any wrapping provider.