Zillow API in Rust
Fetch Zillow property and listing data in Rust with reqwest and serde, with Option for every value Zillow may not show and a typed error for every failure.
Base URL https://api.propertiesapi.com/v1 · auth header x-api-key. PropertiesAPI is an independent service that returns Zillow property and listing data as JSON, provided as-is.
How Rust fits
serde makes the null question explicit: a field declared as an Option of a float becomes None when Zillow doesn't show the value, and the compiler makes you handle it. Declare only the fields you read; serde ignores the rest of the response.
reqwest's blocking client keeps the example short. In an async service, use reqwest::Client with tokio and the same types. Either way, set a timeout well above a live lookup, which takes typically 8 seconds, up to about 20 seconds.
Batch results have two shapes, a success with data and a failure with error. One struct with two Option fields handles both without a custom deserializer.
Dependencies
reqwest with the blocking and json features, and serde for the types.
[dependencies]
reqwest = { version = "0.12", features = ["blocking", "json"] }
serde = { version = "1", features = ["derive"] }
serde_json = "1"Types
Option for every nullable field; the batch item covers both success and failure.
use serde::Deserialize;
use std::{env, time::Duration};
const BASE: &str = "https://api.propertiesapi.com/v1";
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
struct Home {
zpid: Option<String>,
detail_url: String,
address: String,
home_status: Option<String>,
price: Option<f64>,
zestimate: Option<f64>,
rent_zestimate: Option<f64>,
living_area: Option<f64>,
calculated: Calculated,
}
#[derive(Debug, Deserialize)]
struct Calculated {
gross_yield_pct: Option<f64>,
price_drops_count: Option<u32>,
}
#[derive(Debug, Deserialize)]
struct Meta { credits: u32, cached: bool }
#[derive(Debug, Deserialize)]
struct PropertyResponse { data: Home, meta: Meta }
#[derive(Debug, Deserialize)]
struct ApiError { code: String, message: String, details: Option<serde_json::Value> }
#[derive(Debug, Deserialize)]
struct ErrorBody { error: ApiError }
#[derive(Debug, Deserialize)]
struct BatchItem { index: usize, ok: bool, data: Option<Home>, error: Option<ApiError> }
#[derive(Debug, Deserialize)]
struct BatchResponse { data: Vec<BatchItem> }Look up a home
reqwest encodes the query for you. A non-success status is turned into the API's error.
fn client() -> reqwest::blocking::Client {
reqwest::blocking::Client::builder().timeout(Duration::from_secs(320)).build().unwrap()
}
fn property(param: &str, value: &str) -> Result<PropertyResponse, Box<dyn std::error::Error>> {
let res = client()
.get(format!("{BASE}/zillow/property"))
.header("x-api-key", env::var("PROPERTIESAPI_KEY")?)
.query(&[(param, value)])
.timeout(Duration::from_secs(120))
.send()?;
if !res.status().is_success() {
let status = res.status().as_u16();
let e: ErrorBody = res.json()?;
return Err(format!("{status} {}: {}", e.error.code, e.error.message).into());
}
Ok(res.json()?)
}
fn main() -> Result<(), Box<dyn std::error::Error>> {
let r = property("address", "2640 Steiner St, San Francisco, CA 94115")?;
println!("{} {:?}", r.data.address, r.data.home_status);
match r.data.price {
Some(p) => println!("list price {p}"),
None => println!("no list price shown"),
}
println!("gross yield %: {:?}", r.data.calculated.gross_yield_pct);
println!("cached: {}, credits: {}", r.meta.cached, r.meta.credits);
Ok(())
}Batch with partial success
Up to 50 items. A failed item doesn't fail the request; it comes back with ok false and its error.
fn batch(items: &[&str]) -> Result<BatchResponse, Box<dyn std::error::Error>> {
let res = client()
.post(format!("{BASE}/zillow/batch"))
.header("x-api-key", env::var("PROPERTIESAPI_KEY")?)
.json(&serde_json::json!({ "items": items }))
.send()?
.error_for_status()?;
Ok(res.json()?)
}
for item in batch(&["https://www.zillow.com/homedetails/2640-Steiner-St-San-Francisco-CA-94115/15073305_zpid/", "742 Evergreen Ter, Springfield, IL 62704"])?.data {
match (item.ok, item.data, item.error) {
(true, Some(home), _) => println!("{}: {} {:?}", item.index, home.address, home.price),
(_, _, Some(err)) => println!("{}: {} ({})", item.index, err.code, err.message),
_ => {}
}
}Rust gotchas
- error_for_status() throws away the error body. For single lookups, read the body first, as property() does, so you keep the error code.
- Use a float or a decimal crate for money as you prefer, but keep it inside Option.
- Rate limits and busy answers carry retry_after inside error.details. Sleep that long before retrying.
- Set a client timeout of at least 120 seconds: an occasional lookup takes longer.
Zillow API and Rust: questions
Is there a Zillow API crate?+
Not from us. The API is plain HTTPS and JSON, so reqwest and serde are a complete client.
Can I use it from async Rust?+
Yes. Swap the blocking client for reqwest::Client in a tokio runtime; the request and types stay the same.
Why is the zpid an Option?+
An apartment-building link returns the building, which has no zpid of its own; its detailUrl is the identifier instead.
What does a lookup cost?+
Zillow links and addresses cost the same: 1 credit per successful lookup. Repeat lookups of the same home within 24 hours come from our cache and are free. You start with 50 free lookups.
Make your first call from Rust
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.