Zillow API Ruby example

Zillow API in Ruby

Get Zillow listing and property data into Ruby with Net::HTTP and JSON from the standard library, then move the lookups into a background job for Rails.

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 Ruby fits

Ruby's standard library already has everything the API needs: Net::HTTP for the request, URI for encoding the Zillow link or address, and JSON for the response. The client below is a small module you can drop into a Rails app's lib folder or a one-off script.

The main thing to get right is the timeout. Net::HTTP's default read timeout is lower than the timeout we recommend, and a live lookup takes typically 8 seconds, up to about 20 seconds. Set read_timeout explicitly, and in Rails run lookups in a background job rather than inside a web request.

Errors are raised as one exception class with the HTTP status and the API's error code, so a job can decide quickly whether to retry, give up or ask the user for a better address.

A small client module

Reads PROPERTIESAPI_KEY from the environment and returns the parsed body as a Hash.

Ruby
require "net/http"
require "json"
require "uri"

module PropertiesAPI
  BASE = "https://api.propertiesapi.com/v1"

  class Error < StandardError
    attr_reader :status, :code, :details
    def initialize(status, code, message, details)
      @status, @code, @details = status, code, details || {}
      super("#{status} #{code}: #{message}")
    end
  end

  def self.request(method, path, query: nil, body: nil, timeout: 120)
    uri = URI("#{BASE}#{path}")
    uri.query = URI.encode_www_form(query) if query
    req = method == :post ? Net::HTTP::Post.new(uri) : Net::HTTP::Get.new(uri)
    req["x-api-key"] = ENV.fetch("PROPERTIESAPI_KEY")
    if body
      req["content-type"] = "application/json"
      req.body = JSON.generate(body)
    end
    res = Net::HTTP.start(uri.host, uri.port, use_ssl: uri.scheme == "https", read_timeout: timeout) { |http| http.request(req) }
    json = JSON.parse(res.body) rescue {}
    unless res.is_a?(Net::HTTPSuccess)
      e = json["error"] || {}
      raise Error.new(res.code.to_i, e["code"] || "unknown", e["message"] || "request failed", e["details"])
    end
    json
  end

  def self.property(**input) = request(:get, "/zillow/property", query: input)
  def self.batch(items) = request(:post, "/zillow/batch", body: { items: items }, timeout: 320)
end

Look up a home

Pass url:, zpid: or address:. data is a Hash with string keys; meta tells you whether the answer came from the cache.

Ruby
body = PropertiesAPI.property(address: "2640 Steiner St, San Francisco, CA 94115")
home = body["data"]

puts "#{home["address"]} (#{home["homeStatus"]})"
puts "List price: #{home["price"] || "not shown"}"
puts "Zestimate: #{home["zestimate"] || "not shown"}, rent Zestimate: #{home["rentZestimate"] || "not shown"}"
puts "Price drops this listing: #{home.dig("calculated", "price_drops_count") || "n/a"}"
puts body["meta"]["cached"] ? "cache hit (free)" : "live lookup"

Retry only what is worth retrying

Rate limits and busy answers carry a retry_after wait in error.details. A not_found or address_ambiguous answer won't change on retry.

Ruby
def find_home(**input)
  attempts = 0
  begin
    PropertiesAPI.property(**input)["data"]
  rescue PropertiesAPI::Error => e
    case e.code
    when "not_found" then nil                     # not billed
    when "address_ambiguous"
      warn "closest match: #{e.details.dig("candidate", "detailUrl")}"
      nil
    else
      raise unless [429, 502, 503, 504].include?(e.status) && (attempts += 1) < 4
      sleep(e.details["retry_after"] || 2**attempts)
      retry
    end
  end
end

Batch inside a background job

Send up to 50 items per batch request and save each result as it comes back. Failed items cost nothing; leave them for a later retry.

Ruby (Rails job)
class RefreshHomesJob < ApplicationJob
  def perform(listing_ids)
    listings = Listing.where(id: listing_ids).to_a
    listings.each_slice(50) do |slice|
      body = PropertiesAPI.batch(slice.map(&:zillow_url))
      body["data"].each do |r|
        listing = slice[r["index"]]
        if r["ok"]
          listing.update!(price: r["data"]["price"], zestimate: r["data"]["zestimate"],
                          home_status: r["data"]["homeStatus"], refreshed_at: Time.current)
        else
          listing.update!(last_error: r["error"]["code"])
        end
      end
    end
  end
end

Ruby gotchas

  • Net::HTTP raises Net::ReadTimeout when read_timeout is too short; that is a client-side timeout, and the lookup may still finish and be cached on our side. Retrying soon after is usually a free cache hit.
  • JSON.parse gives string keys. Use home["price"], or pass symbolize_names: true if you prefer symbols.
  • Fields Zillow doesn't show for a home come back as null, so never read null as zero.
  • Set a client timeout of at least 120 seconds: an occasional lookup takes longer.

Zillow API and Ruby: questions

Is there a Zillow API gem?+

Gems written for Zillow's retired ZWSID API no longer work. With PropertiesAPI you don't need a gem: the standard library module above is the whole client.

Can I use Faraday or HTTParty instead?+

Yes. Send the same request: the x-api-key header, one of url, zpid or address as a query parameter, and a long read timeout.

Should lookups run inside a Rails controller?+

Better not. A live lookup can take longer than you want a web request to wait. Run it in a background job and show the saved result.

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 Ruby

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.