# 99.co Singapore Property Listings Scraper (`incognito_mode/99co-property-listings-scraper`) Actor

Scrape Singapore property from 99.co — HDB, condo, landed and commercial, for sale or rent. Price, PSF, floor area, beds, baths, tenure, TOP year, coordinates, nearest MRT, photos and agent phone/WhatsApp. Breaks past 99.co's 10,000-result cap to reach all 48,000+ listings. No proxy needed.

- **URL**: https://apify.com/incognito\_mode/99co-property-listings-scraper.md
- **Developed by:** [Elena Vance](https://apify.com/incognito_mode) (community)
- **Categories:** Real estate, Lead generation, Business
- **Stats:** 2 total users, 1 monthly users, 100.0% runs succeeded, 0 bookmarks
- **User rating**: No ratings yet

## Pricing

from $2.00 / 1,000 property listings

This Actor is paid per event. You are not charged for the Apify platform usage, but only a fixed price for specific events.

Learn more: https://docs.apify.com/actors/running/actors-in-store.md#pay-per-event

## What's an Apify Actor?

Actors are web data automations that power AI and operations. They run on the Apify platform to scrape websites, process data, connect APIs, and automate workflows.
In Batch mode, an Actor accepts a well-defined JSON input, performs an action which can take anything from a few seconds to a few hours,
and optionally produces a well-defined JSON output, datasets with results, or files in key-value store.
In Standby mode, an Actor provides a web server which can be used as a website, API, or an MCP server.
Actors are written with capital "A".

## How to integrate an Actor?

If asked about integration, you help developers integrate Actors into their projects.
You adapt to their stack and deliver integrations that are safe, well-documented, and production-ready.
The best way to integrate Actors is as follows.

- **AI agents and MCP clients** — the [Apify MCP server](https://docs.apify.com/integrations/mcp.md) at `https://mcp.apify.com` (remote, streamable HTTP, OAuth on first use).
- **Agentic workflows and local Actor development** — [Agent Skills](https://apify.com/.well-known/agent-skills/index.json) with the [Apify CLI](https://docs.apify.com/cli/docs.md): `npm install -g apify-cli`, then `apify login`.
- **JavaScript/TypeScript projects** — the official [JS/TS client](https://docs.apify.com/api/client/js/docs.md): `npm install apify-client`.
- **Python projects** — the official [Python client](https://docs.apify.com/api/client/python/docs.md): `pip install apify-client`.
- **Any other language** — the [REST API](https://docs.apify.com/api/v2.md).

For usage examples, see the [API](#api) section below.

For more details, see Apify documentation as [Markdown index](https://docs.apify.com/llms.txt) and [Markdown full-text](https://docs.apify.com/llms-full.txt).

# README

## 99.co Singapore Property Listings Scraper

Scrape Singapore property listings from [99.co](https://www.99.co) — HDB flats,
condos, landed homes and commercial space, for sale or for rent. Every row
carries price, price per square foot, floor area, bedrooms, bathrooms, tenure,
completion year, coordinates, nearest MRT with walking time, photos, and the
listing agent's phone and WhatsApp.

**It reaches listings other 99.co scrapers cannot.** 99.co refuses to serve
more than 10,000 results for any one search, so a conventional scraper tops out
at 10,000 of the 48,000+ sale listings — and cannot tell that it stopped short.
This one splits the search by postal district automatically and retrieves all of
them.

No API key, no login, no residential proxy, no browser.

### What you get

One row per listing. A real sample, from a District 15 condo run:

| projectName | district | price | psf | sqft | beds | tenure | nearest MRT | walk | agent | phone |
| --- | ---: | ---: | ---: | ---: | ---: | --- | --- | ---: | --- | --- |
| Tembusu Grand | 15 | 7,888,000 | 2,931 | 2,691 | 5 | 99 yrs | Tanjong Katong MRT | 10 min | Paramjit Kaur | +6590091172 |
| The Waterside | 15 | 6,500,000 | 2,709 | 2,399 | 4 | Freehold | Katong Park MRT | 5 min | Vivien | +6593859960 |
| The Line @ Tanjong Rhu | 15 | 5,000,000 | 1,689 | 2,960 | 3 | Freehold | Katong Park MRT | 3 min | Jamie Yoeng | +6589893212 |

Plus, on every row: postal code, street number and name, both address lines,
latitude/longitude, floor area in sqm, HDB flat model, photo count, floor-plan
count, video tour URL, virtual-tour flag, 99.co's verification badges, posting
time, and the cluster id that joins to 99.co's project data.

Export as JSON, CSV, Excel or XML.

### Why this is cheap to run

99.co's Cloudflare wall is a **TLS-stack gate**, not a JavaScript challenge.
Presenting Chrome's TLS fingerprint clears it outright — which means this Actor
needs no browser and no residential proxy:

| | This Actor | Typical 99.co scraper |
| --- | --- | --- |
| Runtime | Plain HTTP client | Headless browser, or residential proxy |
| Memory | **256 MB** | 2,048–4,096 MB |
| Start fee | **$0.00005** | up to $0.10 |
| Proxy cost | **none** | ~$8/GB residential |

`apify-actor-start` is billed once per gigabyte, so a 4 GB Actor charges its
start fee four times before returning a single row. This one charges it once.

A default 100-listing run finishes in **under 3 seconds** using **one** HTTP
request. A 3,000-listing run takes 21 seconds and peaks at 103 MB.

### What makes this different

**It gets past the 10,000-result cap.** `page_num × page_size` must be ≤ 10,000
or 99.co answers HTTP 400. That 400 looks exactly like a transient error, so a
naive scraper reports success having collected 21% of the market. Ask this
Actor for more than the cap allows and it partitions the search by postal
district — 28 disjoint queries whose counts sum to the full corpus — then by
property type and price band if a district ever outgrows the window too. On
smaller runs nothing is split, so you never pay for the machinery you don't need.

**It separates room rentals from whole units.** 99.co's rental feed mixes them,
and rooms are **46% of it** (10,364 of 22,570). Averaging a S$900 room in with
S$5,000 condos makes rent and PSF figures meaningless. `rentalType` picks one,
and every row states which it is.

**It never charges you twice for the same listing.** Listing ids are
de-duplicated across the entire run, before billing, not after — a live
marketplace re-orders itself between requests, and a 6,000-listing test run
suppressed 204 duplicates that a naive pager would have billed for.

**It tells you when it could not finish.** If a search genuinely cannot be
narrowed below the cap, the run says so in its status message instead of
quietly returning a short dataset.

### Input

Everything is optional — running with no input at all returns 100 listings for
sale across Singapore.

| Field | Description |
| --- | --- |
| `listingType` | `sale` or `rent`. Default `sale`. |
| `propertyType` | `all`, `condo`, `hdb` or `landed`. |
| `districts` | Postal districts, e.g. `["D9", "D10", "D11"]`. Empty means all of Singapore. |
| `searchUrl` | Paste a 99.co search URL and its filters are read out of it. |
| `maxItems` | How many listings to return. Default 100. |
| `minPrice` / `maxPrice` | Total price for sale, monthly rent for rentals. |
| `bedrooms` | Multi-select, `0` (studio) to `7`. |
| `bathrooms` | Minimum bathroom count. |
| `tenure` | `freehold`, `999`, `99`. |
| `furnishing` | `fully`, `partial`, `unfurnished`. |
| `rentalType` | `whole_unit` or `room`. Rentals only. |
| `propertySegment` | `residential` or `commercial` (offices, retail, industrial). |
| `minFloorAreaSqft` / `maxFloorAreaSqft` | Applied locally — 99.co has no floor-area filter. |
| `newLaunchOnly` | Restrict to new-launch projects. |
| `includeAgentContacts` | Agent name, phone, WhatsApp. Default on. |
| `includePhotoGallery` | Every photo with caption and room category. Default off. |
| `completeCoverage` | Split past the 10,000 cap. Default on. |
| `maxRunSecs` | Time budget. The run stops cleanly and keeps what it has. |

### Example runs

**Every 3-bedroom condo in the prime districts over S$3M**

```json
{
  "listingType": "sale",
  "propertyType": "condo",
  "districts": ["D9", "D10", "D11"],
  "bedrooms": ["3"],
  "minPrice": 3000000,
  "maxItems": 500
}
```

**Whole-unit rentals only, fully furnished, near an MRT**

```json
{
  "listingType": "rent",
  "rentalType": "whole_unit",
  "furnishing": ["fully"],
  "maxItems": 300
}
```

**The entire sale market — all 48,000+ listings**

```json
{ "listingType": "sale", "maxItems": 50000, "maxRunSecs": 3600 }
```

This is the run that needs district partitioning; it happens automatically.

**Agent lead list for one district**

```json
{ "districts": ["D19"], "includeAgentContacts": true, "maxItems": 2000 }
```

### What this Actor does NOT return

Said plainly, because competing 99.co Actors advertise some of it:

- **Agent agency name, CEA licence number, email or transaction counts.**
  99.co's agent endpoint exists but will not resolve the agent ids its search
  results carry, and the agent profile pages are behind the Cloudflare wall
  that the JSON API is not. Rather than guess, these fields are absent. You get
  the agent's name, phone, WhatsApp and 99.co's badge flags.
- **Sorted results.** 99.co accepts no sort parameter at all — eleven spellings
  were tested and every one was ignored. Results arrive in 99.co's own
  relevance order. Sort the dataset yourself; because coverage is complete,
  sorting it is meaningful.
- **Server-side floor-area filtering.** There is no such parameter, so
  `minFloorAreaSqft` / `maxFloorAreaSqft` are applied after fetching. Listings
  that state no floor area are kept, not dropped — an absent area is not
  evidence of not matching.
- **Price history or past transactions.** 99.co's search API carries only the
  current asking price.
- **Indonesia and Malaysia.** Singapore only.

### Notes on the data

- `posted_at` is converted from 99.co's microsecond epoch to ISO-8601 UTC.
- `tenure` and `completedYear` are routinely `null` on landed property. That is
  99.co's data, not a parsing failure.
- `subCategory` is sometimes the literal string `"unknown"`. It is passed
  through rather than nulled, so you can tell "99.co says unknown" from
  "99.co said nothing".
- `marketSegment` (CCR/RCR/OCR) and `districtName` are **derived** from the
  district number using URA's convention; 99.co does not return them.
- Totals drift by a few listings over the course of a long run. It is a live
  marketplace.

### Related

`clusterId` on every row is the join key to 99.co's project data — feed it to
the **99.co Project & Condo Profile Scraper** for building specifications,
price ranges per bedroom type, and nearby amenities with walking and driving
times.

### Legal

This Actor collects publicly available listing data. Agent phone and WhatsApp
numbers are business contact details that agents publish on their own listings
so that buyers can contact them. You are responsible for using the data in
compliance with applicable law, including Singapore's PDPA and the GDPR where
it applies, and with 99.co's terms. Do not use it for unsolicited bulk
messaging where that is prohibited.

# Actor input Schema

## `listingType` (type: `string`):

Which side of the market to scrape. 99.co carries about 48,000 sale listings and 22,500 rental listings.

## `propertyType` (type: `string`):

Condo covers ECs and apartments; HDB covers all flat sizes; landed covers bungalows, semi-detached and terraces. For offices, retail and industrial use the Property segment field below instead — 99.co does not accept commercial as a property type.

## `districts` (type: `array`):

Singapore postal districts to search. Leave empty for the whole island — coverage is complete either way, because the search is split by district automatically when it has to be.

## `searchUrl` (type: `string`):

Paste a search URL from 99.co and its filters are read out of it (listing type, property type, districts, price, bedrooms, bathrooms, tenure, furnishing). Anything you also set explicitly above wins over the URL.

## `maxItems` (type: `integer`):

How many listings to return. This is what you are billed for, one charge per listing. There is no unlimited setting — asking for the whole 48,000-listing sale market takes far longer than one run's time budget, so raise maxRunSecs alongside this for large pulls.

## `minPrice` (type: `integer`):

Total price for sale listings, monthly rent for rentals.

## `maxPrice` (type: `integer`):

Upper bound on the same figure — total price for sale, monthly rent for rentals.

## `bedrooms` (type: `array`):

Match any of the selected bedroom counts.

## `bathrooms` (type: `integer`):

Only listings with at least this many bathrooms.

## `tenure` (type: `array`):

Freehold, 999-year or 99-year leasehold.

## `furnishing` (type: `array`):

Most useful on rentals, where two thirds of listings state it.

## `propertySegment` (type: `string`):

Leave as Any for both. Commercial is how you reach offices, retail, industrial and warehouse listings (about 4,900 of them).

## `rentalType` (type: `string`):

99.co's rental feed mixes whole units with room rentals — renting a single bedroom in a shared flat — and rooms are 46% of it (10,364 of 22,570). Averaging the two together makes rent and PSF figures meaningless, so pick one unless you want both.

## `minFloorAreaSqft` (type: `integer`):

Applied after fetching, since 99.co offers no floor-area parameter. Listings that do not state an area are kept, not dropped.

## `maxFloorAreaSqft` (type: `integer`):

Upper bound on floor area, also applied here rather than by 99.co.

## `newLaunchOnly` (type: `boolean`):

Restrict to new-launch projects (about 3,000 listings).

## `includeAgentContacts` (type: `boolean`):

Adds the listing agent's name, phone and WhatsApp number in full international format, plus 99.co's own pre-filled enquiry message. These are the business contact details the agent publishes on the listing. Note that agency name and CEA licence number are not available from 99.co's API and are never returned.

## `includePhotoGallery` (type: `boolean`):

Adds every photo URL with its caption and room category. Off by default because it can triple the size of a row; the main photo is always included.

## `completeCoverage` (type: `boolean`):

99.co refuses to serve more than 10,000 results for any single search, so a plain scraper can only ever reach 10,000 of the 48,000 sale listings. Leave this on and the search is split by postal district automatically once you ask for more than the limit allows, which reaches all of them. It costs nothing on smaller runs — no split happens unless it is needed.

## `maxRunSecs` (type: `integer`):

The run stops cleanly and keeps everything collected so far once this is reached. Raise it for large pulls; the default is tuned so a default run finishes well inside Apify's five-minute automated test.

## `proxyConfiguration` (type: `object`):

Not needed. 99.co is reached directly — Cloudflare here is a TLS gate that this Actor clears without a proxy, unlike competing scrapers that bill you for residential traffic. A proxy is used automatically only if a request is genuinely blocked.

## Actor input object example

```json
{
  "listingType": "sale",
  "propertyType": "all",
  "districts": [],
  "searchUrl": "https://www.99.co/singapore/sale/condos-apartments?query_type=district&query_ids=dtdistrict09&price_min=1000000",
  "maxItems": 100,
  "bedrooms": [],
  "tenure": [],
  "furnishing": [],
  "propertySegment": "any",
  "rentalType": "any",
  "newLaunchOnly": false,
  "includeAgentContacts": true,
  "includePhotoGallery": false,
  "completeCoverage": true,
  "maxRunSecs": 210,
  "proxyConfiguration": {
    "useApifyProxy": false
  }
}
```

# Actor output Schema

## `dataset` (type: `string`):

Dataset containing every scraped listing.

# API

You can run this Actor programmatically using our API. Below are code examples in JavaScript, Python, and CLI, as well as the OpenAPI specification and MCP server setup.

## JavaScript example

```javascript
import { ApifyClient } from 'apify-client';

// Initialize the ApifyClient with your Apify API token
// Replace the '<YOUR_API_TOKEN>' with your token
const client = new ApifyClient({
    token: '<YOUR_API_TOKEN>',
});

// Prepare Actor input
const input = {
    "listingType": "sale",
    "propertyType": "all",
    "maxItems": 100,
    "maxRunSecs": 210
};

// Run the Actor and wait for it to finish
const run = await client.actor("incognito_mode/99co-property-listings-scraper").call(input);

// Fetch and print Actor results from the run's dataset (if any)
console.log('Results from dataset');
console.log(`💾 Check your data here: https://console.apify.com/storage/datasets/${run.defaultDatasetId}`);
const { items } = await client.dataset(run.defaultDatasetId).listItems();
items.forEach((item) => {
    console.dir(item);
});

// 📚 Want to learn more 📖? Go to → https://docs.apify.com/api/client/js/docs

```

## Python example

```python
from apify_client import ApifyClient

# Initialize the ApifyClient with your Apify API token
# Replace '<YOUR_API_TOKEN>' with your token.
client = ApifyClient("<YOUR_API_TOKEN>")

# Prepare the Actor input
run_input = {
    "listingType": "sale",
    "propertyType": "all",
    "maxItems": 100,
    "maxRunSecs": 210,
}

# Run the Actor and wait for it to finish
run = client.actor("incognito_mode/99co-property-listings-scraper").call(run_input=run_input)

# Fetch and print Actor results from the run's dataset (if there are any)
print(f"💾 Check your data here: https://console.apify.com/storage/datasets/{run.default_dataset_id}")
for item in client.dataset(run.default_dataset_id).iterate_items():
    print(item)

# 📚 Want to learn more 📖? Go to → https://docs.apify.com/api/client/python/docs/quick-start

```

## CLI example

```bash
echo '{
  "listingType": "sale",
  "propertyType": "all",
  "maxItems": 100,
  "maxRunSecs": 210
}' |
apify call incognito_mode/99co-property-listings-scraper --silent --output-dataset

```

## MCP server setup

```json
{
    "mcpServers": {
        "apify": {
            "type": "http",
            "url": "https://mcp.apify.com/?tools=fetch-actor-details,incognito_mode/99co-property-listings-scraper"
        }
    }
}

```

The hosted server signs you in with OAuth on first connect, so no API token belongs in this config. Clients without OAuth support can send an `Authorization: Bearer <APIFY_API_TOKEN>` header instead, using a token from API & Integrations in Apify Console (https://console.apify.com/settings/integrations).

## OpenAPI specification

Download the OpenAPI definition: https://api.apify.com/v2/actors/50McUwSE2Jxr0tofP/builds/kytbUkJENQcDQVAhh/openapi.json
