Skip to content
/ pyfunda Public

Reverse Engineering funda (funda.nl) mobile APIs

License

Notifications You must be signed in to change notification settings

0xMH/pyfunda

Folders and files

NameName
Last commit message
Last commit date

Latest commit

 

History

29 Commits
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

pyfunda

PyPI version Python versions License

The only working real Python API for Funda (funda.nl) — the Netherlands' largest real estate platform.

If you find this useful, consider giving it a star — it helps others discover the project.

Star History Chart

Why I'm open-sourcing this?

After pyfunda, I got messages asking why I'd give this away when aggregators will just take it and sell it. They're right, every week there's a new "revolutionary AI-powered housing finder" charging €40/month or a €250 "success fee.". They all pull from the same one or two sources and wrap it in a fancy UI completely built with AI.

That's exactly why I'm open-sourcing it.

These services are selling air to people who are looking for any kind of hope. The data is public. The APIs aren't hard to figure out. You shouldn't have to pay someone to refresh a webpage for you. Funda could kill this entire market overnight by offering a public API. They don't, so here we are.

Here's the code, do it yourself. Send my library link to any AI service you use and ask it to build whatever tool you think will make your life easier while searching for your next home.

With pyfunda, I've already done all the heavy lifting for you.

Why pyfunda?

Because it simply works.

Funda has no public API. If you want Dutch real estate data programmatically, your options are limited:

Library Approach Limitations
whchien/funda-scraper HTML scraping Listing dates blocked since Q4 2023 (requires login). Breaks when Funda changes frontend.
khpeek/funda-scraper Scrapy Last updated 2016. No longer maintained.
joostboon/Funda-Scraper Selenium Requires manual CAPTCHA solving. Slow browser automation.
Official API Only available to registered brokers. Not accessible to developers.

pyfunda takes a different approach: it uses Funda's internal mobile app API, reverse-engineered from the official Android app.

  • Pure Python, no browser or Selenium needed
  • No CAPTCHAs or anti-bot blocks
  • 70+ fields including photos, floorplans, coordinates, and listing dates
  • Stable mobile API that doesn't break when the website changes

Installation

pip install pyfunda

Quick Start

from funda import Funda

f = Funda()

# Get a listing by ID
listing = f.get_listing(43117443)
print(listing['title'], listing['city'])
# Reehorst 13 Luttenberg

# Get a listing by URL
listing = f.get_listing('https://www.funda.nl/detail/koop/amsterdam/appartement-123/43117443/')

# Search listings
results = f.search_listing('amsterdam', price_max=500000)
for r in results:
    print(r['title'], r['price'])

How It Works

This library uses Funda's undocumented mobile app API, which provides clean JSON responses unlike the website that embeds data in Nuxt.js/JavaScript bundles.

Discovery Process

The API was reverse engineered by intercepting and analyzing HTTPS traffic from the official Funda Android app:

  1. Configured an Android device to route traffic through an intercepting proxy
  2. Used the Funda app normally - browsing listings, searching, opening shared URLs
  3. Identified the *.funda.io API infrastructure separate from the www.funda.nl website
  4. Analyzed request/response patterns to understand the query format and available filters
  5. Discovered how the app resolves URL-based IDs (tinyId) to internal IDs (globalId)

API Architecture

The mobile app communicates with a separate API at *.funda.io:

Endpoint Method Purpose
listing-detail-page.funda.io/api/v4/listing/object/nl/{globalId} GET Fetch listing by internal ID
listing-detail-page.funda.io/api/v4/listing/object/nl/tinyId/{tinyId} GET Fetch listing by URL ID
listing-search-wonen.funda.io/_msearch/template POST Search listings

ID System

Funda uses two ID systems:

  • globalId: Internal numeric ID (7 digits), used in the database
  • tinyId: Public-facing ID (8-9 digits), appears in URLs like funda.nl/detail/koop/amsterdam/.../{tinyId}/

The tinyId endpoint was key - it allows fetching any listing directly from a Funda URL without needing to know the internal ID.

Search API

Search uses Elasticsearch's Multi Search Template API with NDJSON format:

{"index":"listings-wonen-searcher-alias-prod"}
{"id":"search_result_20250805","params":{...}}

Search parameters:

Parameter Description Example
selected_area Location filter ["amsterdam"]
radius_search Radius from location {"index": "geo-wonen-alias-prod", "id": "1012AB-0", "path": "area_with_radius.10"}
offering_type Buy or rent "buy" or "rent"
price.selling_price Price range (buy) {"from": 200000, "to": 500000}
price.rent_price Price range (rent) {"from": 500, "to": 2000}
object_type Property types ["house", "apartment"]
floor_area Living area m² {"from": 50, "to": 150}
plot_area Plot area m² {"from": 100, "to": 500}
energy_label Energy labels ["A", "A+", "A++"]
sort Sort order {"field": "publish_date_utc", "order": "desc"}
page.from Pagination offset 0, 15, 30...

Results are paginated with 15 listings per page.

Valid radius values: 1, 2, 5, 10, 15, 30, 50 km (other values are not indexed).

Required Headers

User-Agent: Dart/3.9 (dart:io)
X-Funda-App-Platform: android
Content-Type: application/json

Response Data

Listing responses include:

  • Identifiers - globalId, tinyId
  • AddressDetails - title, city, postcode, province, neighbourhood, house number
  • Price - numeric and formatted prices (selling or rental), auction flag
  • FastView - bedrooms, living area, plot area, energy label
  • Media - photos, floorplans, videos, 360° photos, brochure URL (all with CDN base URLs)
  • KenmerkSections - detailed property characteristics (70+ fields)
  • Coordinates - latitude/longitude
  • ObjectInsights - view and save counts
  • Advertising.TargetingOptions - boolean features (garden, balcony, solar panels, heat pump, parking, etc.), construction year, room counts
  • Share - shareable URL
  • GoogleMapsObjectUrl - direct Google Maps link
  • PublicationDate - when the listing was published
  • Tracking.Values.brokers - broker ID and association

API Reference

Funda

Main entry point for the API.

from funda import Funda

f = Funda(timeout=30)

get_listing(listing_id)

Get a single listing by ID or URL.

# By numeric ID (tinyId or globalId)
listing = f.get_listing(43117443)

# By URL
listing = f.get_listing('https://www.funda.nl/detail/koop/city/house-name/43117443/')

search_listing(location, ...)

Search for listings with filters.

results = f.search_listing(
    location='amsterdam',           # City or area name
    offering_type='buy',            # 'buy' or 'rent'
    price_min=200000,               # Minimum price
    price_max=500000,               # Maximum price
    area_min=50,                    # Minimum living area (m²)
    area_max=150,                   # Maximum living area (m²)
    plot_min=100,                   # Minimum plot area (m²)
    plot_max=500,                   # Maximum plot area (m²)
    object_type=['house'],          # Property types (default: house, apartment)
    energy_label=['A', 'A+'],       # Energy labels to filter
    sort='newest',                  # Sort order (see below)
    page=0,                         # Page number (15 results per page)
)

Radius search - search within a radius from a postcode or city:

results = f.search_listing(
    location='1012AB',              # Postcode or city
    radius_km=10,                   # Search radius in km
    price_max=750000,
)

Note: Valid radius values are 1, 2, 5, 10, 15, 30, and 50 km. Other values are automatically mapped to the nearest valid radius.

Sort options:

Sort Value Description
newest Most recently published first
oldest Oldest listings first
price_asc Lowest price first
price_desc Highest price first
area_asc Smallest living area first
area_desc Largest living area first
plot_desc Largest plot area first
city Alphabetically by city
postcode Alphabetically by postcode

Multiple locations:

results = f.search_listing(['amsterdam', 'rotterdam', 'utrecht'])

get_latest_id()

Get the highest listing ID currently in Funda's search index.

latest = f.get_latest_id()  # e.g., 7852306

poll_new_listings(since_id, ...)

Generator that polls for new listings by incrementing IDs.

for listing in f.poll_new_listings(
    since_id=7852306,           # Start from this ID + 1
    max_consecutive_404s=20,    # Stop after N consecutive 404s
    offering_type='buy',        # Filter: 'buy' or 'rent' (optional)
):
    print(listing['title'])

This bypasses ES search and queries the detail API directly, catching listings that haven't been indexed yet.

get_price_history(listing)

Get historical price data for a listing, including previous asking prices, WOZ tax assessments, and sale history.

listing = f.get_listing(43032486)
history = f.get_price_history(listing)

for change in history:
    print(change['date'], change['human_price'], change['status'])
# 22 okt, 2025    €435.000     asking_price
# 1 jan, 2025     €472.000     woz
# 19 aug, 2019    €300.000     asking_price

Returns: List of price changes, each containing:

Field Description
price Numeric price
human_price Formatted price (e.g., "€435.000")
date Human readable date
timestamp ISO timestamp
source "Funda" or "WOZ"
status asking_price, sold, or woz

Note: This fetches data from the Walter Living API. Only called when explicitly requested (lazy-loaded).

Listing

Listing objects support dict-like access with convenient aliases.

Basic info:

listing['title']            # Property title/address
listing['city']             # City name
listing['postcode']         # Postal code
listing['province']         # Province
listing['neighbourhood']    # Neighbourhood name
listing['municipality']     # Municipality (gemeente)
listing['house_number']     # House number
listing['house_number_ext'] # House number extension (e.g., "A", "II")

Price & Status:

listing['price']            # Numeric price
listing['price_formatted']  # Formatted price string (e.g., "€ 450.000 k.k.")
listing['price_per_m2']     # Price per m² (from characteristics)
listing['status']           # "available" or "sold"
listing['offering_type']    # "Sale" or "Rent"

Property details:

listing['object_type']      # House, Apartment, etc.
listing['house_type']       # Type of house (e.g., "Tussenwoning")
listing['construction_type'] # New or existing construction
listing['construction_year'] # Year built
listing['bedrooms']         # Number of bedrooms
listing['rooms']            # Total number of rooms
listing['living_area']      # Living area in m²
listing['plot_area']        # Plot area in m²
listing['energy_label']     # Energy label (A, B, C, etc.)
listing['description']      # Full description text

Dates:

listing['publication_date'] # When listed on Funda
listing['offered_since']    # "Offered since" date (from characteristics)
listing['acceptance']       # Acceptance terms (e.g., "In overleg")

Location:

listing['coordinates']      # (lat, lng) tuple
listing['latitude']         # Latitude
listing['longitude']        # Longitude
listing['google_maps_url']  # Direct Google Maps link

Media:

listing['photos']           # List of photo IDs
listing['photo_urls']       # List of full CDN URLs for photos
listing['photo_count']      # Number of photos
listing['floorplans']       # List of floorplan IDs
listing['floorplan_urls']   # List of full CDN URLs for floorplans
listing['videos']           # List of video IDs
listing['video_urls']       # List of video URLs
listing['photos_360']       # List of 360° photo dicts with name, id, url
listing['brochure_url']     # PDF brochure URL (if available)

Property features (booleans):

listing['has_garden']           # Has garden
listing['has_balcony']          # Has balcony
listing['has_roof_terrace']     # Has roof terrace
listing['has_solar_panels']     # Has solar panels
listing['has_heat_pump']        # Has heat pump
listing['has_parking_on_site']  # Parking on property
listing['has_parking_enclosed'] # Enclosed parking
listing['is_energy_efficient']  # Energy efficient property
listing['is_monument']          # Listed/protected building
listing['is_fixer_upper']       # Fixer-upper (kluswoning)
listing['is_auction']           # Sold via auction
listing['open_house']           # Has open house scheduled

Stats & metadata:

listing['views']            # Number of views on Funda
listing['saves']            # Number of times saved
listing['highlight']        # Highlight text (blikvanger)
listing['global_id']        # Internal Funda ID
listing['tiny_id']          # Public ID (used in URLs)
listing['url']              # Full Funda URL
listing['share_url']        # Shareable URL
listing['broker_id']        # Broker ID
listing['broker_association'] # Broker association (e.g., "NVM")
listing['characteristics']  # Dict of all detailed characteristics

Key aliases - these all work:

Alias Canonical Key
name, address title
location, locality city
area, size living_area
type, property_type object_type
images, pictures, media photos
agent, realtor, makelaar broker
zip, zipcode, postal_code postcode

Methods

listing.summary()       # Text summary of the listing
listing.to_dict()       # Convert to plain dictionary
listing.keys()          # List available keys
listing.get('key')      # Get with default (like dict.get)
listing.getID()         # Get listing ID

Examples

Find apartments in Amsterdam under €400k

from funda import Funda

f = Funda()
results = f.search_listing('amsterdam', price_max=400000)

for listing in results:
    print(f"{listing['title']}")
    print(f"  Price: €{listing['price']:,}")
    print(f"  Area: {listing.get('living_area', 'N/A')}")
    print(f"  Bedrooms: {listing.get('bedrooms', 'N/A')}")
    print()

Get detailed listing information

from funda import Funda

f = Funda()
listing = f.get_listing(43117443)

print(listing.summary())

# Access all characteristics
for key, value in listing['characteristics'].items():
    print(f"{key}: {value}")

Search rentals in multiple cities

from funda import Funda

f = Funda()
results = f.search_listing(
    location=['amsterdam', 'rotterdam', 'den-haag'],
    offering_type='rent',
    price_max=2000,
)

print(f"Found {len(results)} rentals")

Find energy-efficient homes with a garden

from funda import Funda

f = Funda()
listing = f.get_listing(43117443)

# Check property features
if listing['has_garden'] and listing.get('has_solar_panels'):
    print("Energy efficient with garden!")

if listing['is_energy_efficient']:
    print(f"Energy label: {listing['energy_label']}")

Download listing photos

from funda import Funda
import requests

f = Funda()
listing = f.get_listing(43117443)

# Photo URLs are ready to use
for i, url in enumerate(listing['photo_urls'][:5]):
    response = requests.get(url)
    with open(f"photo_{i}.jpg", "wb") as file:
        file.write(response.content)

# Also available: floorplan_urls, video_urls

Search by radius from postcode

from funda import Funda

f = Funda()
results = f.search_listing(
    location='1012AB',
    radius_km=15,
    price_max=600000,
    energy_label=['A', 'A+', 'A++'],
    sort='newest',
)

for r in results:
    print(f"{r['title']} - €{r['price']:,}")

Poll for new listings (bypass ES lag)

Funda's search index can lag behind the actual database by hours. Use poll_new_listings to find listings that search doesn't show yet:

from funda import Funda

f = Funda()

# Get starting point from search (one-time)
latest_id = f.get_latest_id()  # e.g., 7852306

# Poll for new listings by incrementing IDs
for listing in f.poll_new_listings(since_id=latest_id):
    print(f"New: {listing['title']}, {listing['city']}")
    print(f"     {listing['url']}")

# Filter by type
for listing in f.poll_new_listings(since_id=latest_id, offering_type="buy"):
    print(f"New sale: {listing['title']}")

The generator stops after 20 consecutive 404s (configurable via max_consecutive_404s).

Get price history for a listing

from funda import Funda

f = Funda()
listing = f.get_listing(43032486)

# Fetch historical prices (WOZ assessments, previous asking prices, sales)
history = f.get_price_history(listing)

print(f"Price history for {listing['title']}:")
for change in history:
    print(f"  {change['date']}: {change['human_price']} ({change['status']})")

# Calculate price change over time
funda_prices = [c for c in history if c['source'] == 'Funda']
if len(funda_prices) >= 2:
    newest, oldest = funda_prices[0]['price'], funda_prices[-1]['price']
    change_pct = ((newest - oldest) / oldest) * 100
    print(f"\nPrice change: {change_pct:+.1f}%")

Disclaimer

This is an unofficial library and is not affiliated with, authorized, maintained, sponsored, or endorsed by Funda or any of its affiliates. Use at your own risk.

This library only accesses publicly available listing data through Funda's undocumented internal API. Using this library may violate Funda's Terms of Service. The authors are not responsible for any consequences of using this software.

This project is intended for personal use, research, and educational purposes only.

  • The API is undocumented and may change or break at any time without notice.
  • Please use this library responsibly and avoid excessive requests that could burden Funda's infrastructure.
  • Scraped data may be subject to copyright and usage restrictions. Ensure your use complies with applicable laws.

License

AGPL-3.0

About

Reverse Engineering funda (funda.nl) mobile APIs

Resources

License

Stars

Watchers

Forks

Sponsor this project

 

Packages

No packages published

Contributors 2

  •  
  •  

Languages