# Property Listing Identity Reconciler (`plusonets/property-listing-reconciler`) Actor

Assign stable property IDs and lifecycle events to listing feeds you already have the right to use. Does not scrape property portals.

- **URL**: https://apify.com/plusonets/property-listing-reconciler.md
- **Developed by:** [Olalekan Johnson](https://apify.com/plusonets) (community)
- **Categories:** Real estate, Automation
- **Stats:** 2 total users, 1 monthly users, 100.0% runs succeeded, 0 bookmarks
- **User rating**: No ratings yet

## Pricing

from $20.00 / 1,000 lifecycle events

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?

An Actor is a serverless cloud program that runs on the Apify platform. It has two run modes.
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.

Apify vocabulary and the platform model are defined once, in the agent quickstart at https://apify.com/agents.md.

## 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.

Do not guess an integration path. Every one of them is in the agent quickstart at https://apify.com/agents.md: the Apify MCP server, Agent Skills with the Apify CLI, the JavaScript and Python clients, the REST API, and the account-free path for an agent with no human to sign in. It also carries the rule on stating cost before the first paid run.

For examples already wired to this Actor's own input schema, see the [API](#api) section below.

Each client library has reference documentation the quickstart does not restate: [JavaScript/TypeScript](https://docs.apify.com/api/client/js/docs.md) (`npm install apify-client`) and [Python](https://docs.apify.com/api/client/python/docs.md) (`pip install apify-client`).

# README

## Property Listing Identity Reconciler

A regional broker analytics team, or a property-data product, already has listing files it is allowed to use. Those files do not agree with each other. The same home comes back under a new listing id, two brokerages advertise one apartment at once, and a price change is buried in a full export. This Actor turns that book into one property history: a stable property id that is not the listing id, a conservative match, and a lifecycle event only when something material changed.

It does **not** scrape Zillow, Rightmove, Realtor.com, or any other portal. Unofficial field mapping only. Not affiliated with those brands or with any brokerage named in the sample. You supply an Apify dataset from a run in your own account, or JSON or CSV you already have the right to process. Upstream collection, and any fee for it, is separate.

The default input is a fictional sample. It is labelled `BUILT_IN_SAMPLE` on the run summary and `SAMPLE` on every row. Nothing in that sample was collected from a live site.

Contact: plusonecares@gmail.com

### What you put in

Leave the input empty and the Actor runs the built-in sample. To reconcile your own book, pass one of these and the sample is ignored:

| Input | When to use it |
| --- | --- |
| Apify dataset | A dataset already in your account, read-only. Common Zillow-style, Rightmove-style, and Realtor-style field names are mapped automatically. |
| Listings JSON | An array of listings, or `{ "snapshots": [ { "snapshotId", "observedAt", "coverage", "listings": [] } ] }`. |
| Listings CSV | A header row plus one listing per line. |
| Listings file URL | An `http` or `https` JSON or CSV file you are allowed to fetch. Private addresses, localhost, and HTML pages are refused. |

`fieldMapping` overrides a path when you set it, for example `{ "listingId": "mlsNumber", "address": "siteAddress", "unit": "apartment" }`. Anything you leave blank still uses the automatic aliases.

Mapped facts: listing id, address, unit, city, region, postal code, latitude, longitude, beds, baths, area, price, currency, status, agent name, brokerage name, and listing date. Phone numbers and email addresses are not copied, even if a column contains them.

Set **Snapshot coverage** to partial unless this file is a full comparable scan of the same book. Either way, a listing that is missing from the file is **unknown**, not sold and not withdrawn.

### What you get

The default dataset is a flat table. Webhooks, n8n, Make, and CSV exports can read it without unpacking nested objects. `recordType` is one of:

- `reconciled_listing` — one delivered observation of a listing, joined to a property id
- `lifecycle_event` — a material change, with lineage and match confidence
- `manual_review` — not merged, not charged

Lifecycle `eventType` values:

| Event | What it means |
| --- | --- |
| `new_listing` | First time this listing identity was observed for a property. The status on that row is the status in the file, which may already be sold or withdrawn. |
| `price_change` | Same listing id, and both prices are known. A missing price is not a change to zero. A currency change is not a price change. |
| `status_change` | Same listing id, and both statuses are known. Returning to market is a reactivation instead. |
| `reactivation` | The same listing id moves from sold or withdrawn back to active or pending. |
| `duplicate_advertisement` | A second listing id is on the market for the same normalized address and unit in this snapshot. |
| `probable_relisting` | A new listing id for a property whose earlier listing was observed off market. |

The run summary is the `OUTPUT` record in the default key-value store. It includes counts, the units that were billed, coverage, and listing ids that were absent. Absence outcome is always `unknown`.

A probable relisting from the sample:

```json
{
  "recordType": "lifecycle_event",
  "eventType": "probable_relisting",
  "propertyId": "prop_cae050ba7a2de817",
  "listingId": "RMLS-22004",
  "previousListingId": "ZIL-885100",
  "previousStatus": "withdrawn",
  "price": 615000,
  "currency": "USD",
  "matchMethod": "fuzzy",
  "matchScore": 97.5,
  "matchConfidence": "high",
  "matchDecision": "accepted",
  "lineage": "Probable relisting: new listing RMLS-22004 for property prop_cae050ba7a2de817. Previous listing ZIL-885100 was withdrawn. Match fuzzy, score 97.5.",
  "chargeEvent": "lifecycle-event",
  "isSample": true,
  "snapshotLabel": "SAMPLE snapshot B — relistings, duplicates, and unit traps (fictional, 6 Oct 2026)",
  "coverageStatus": "partial",
  "portalDisclaimer": "Unofficial field mapping. Not affiliated with Zillow, Rightmove, Realtor.com, or any brokerage."
}
```

The same sample refuses to merge `500 NW 23rd Ave` when unit 1 and unit 2 are already known. That row is `manual_review`, `matchReason` `unit_not_proven`, `chargeEvent` null.

### What the sample demonstrates

Snapshot A is an opening book of fictional Portland listings on 8 Sep 2026. Snapshot B, on 6 Oct 2026, is partial and contains the traps:

- **1842 SE Division St** is written as "Southeast Division Street" and the price falls from 725000 to 699000. Same listing id, so this is a price change, not a new home.
- **905 NE Knott St** moves from for-sale to pending. **909 NE Knott St** is a different house number and stays a different property.
- **2210 N Williams Ave unit 3** was sold and comes back active at a new price: a reactivation and a price change. "North" and "Unit 3" normalize onto the same listing.
- **410 SW Eleventh Ave** and **410 SW 11th Avenue** are the same address with two listing ids and two brokerages, both still active. That is a duplicate advertisement. "Eleventh" and "11th" are the same street.
- **77 NE Schuyler St** was withdrawn. A later feed spells it "Schuylar" under a new listing id. The score is high enough to call a probable relisting, and the property id does not change.
- **500 NW 23rd Ave Apt 1** and **Apt 2** never share a property id. A third row with no unit goes to manual review instead of picking one of them.
- **88 SE Yamhill St** is in snapshot A only. Snapshot B is partial, so Yamhill is listed as not observed. It is not marked sold. The old Schuyler listing id is also absent after the relist and is treated the same way.

The default run reconciles **20** listings, emits **18** lifecycle events (11 first observations, 3 price changes, 1 status change, 1 reactivation, 1 duplicate, 1 relisting), and holds **1** manual review. One repeated export row is dropped. At the prices below, that sample is **$0.44** in Actor events before the platform start event.

### How matching stays conservative

1. Same source and listing id. This is the same advertisement. Price and status are compared to the saved registry.
2. Same normalized house number, street, and unit. Apartment, unit, suite, flat, and `#` are kept as a unit token. `Apt 04` and `Unit 4` match. `Apt 1` and `Apt 2` do not. `123A` stays a house number, not a unit.
3. Same upstream property key (for example a ZPID) only when the units do not conflict.
4. A scored fuzzy match on the street name, after the house number has already matched. Coordinates, postal code, and beds can raise the score. They cannot override a different house number or a different unit.
5. Anything between the review score (default 75) and the auto-match score (default 90), a missing unit against a known unit, or two close candidates, goes to **manual review**. Those rows are delivered and are not charged.

A confirmed link (`confirmedLinksJson`) can point a listing at a property you already trust. It still cannot merge different units, and it cannot attach a blank unit to a numbered one. Put the unit on the row if you know it.

An observation dated earlier than the saved listing does not roll the price or status backward. Re-running the sample does not emit the same lifecycle event again.

### State across runs

The registry lives in a named key-value store, `property-listing-reconciler-state` by default, under the key you set (`default` is the usual book). The next run sees the property ids, the last price, the last status, and the event ids already delivered.

The registry is written only after the run finishes the listings it delivered. The previous good registry is copied to a last-good key first. If this run fails, or if another run committed a newer revision, the good registry is left in place. Two overlapping runs on the same state key are refused while a lock is held (15 minutes). Run one schedule per state key.

`resetState` starts a fresh book and keeps the previous registry as last-good. That re-delivers events and charges them again, because they are new deliveries against an empty book.

### Pricing

This Actor uses pay-per-event pricing:

| Event | When it is charged | Price |
| --- | --- | --- |
| `listing-reconciled` | A listing observation was matched and written to the dataset | $0.004 |
| `lifecycle-event` | A new lifecycle event was written to the dataset | $0.02 |

There is no per-dataset-item event. The platform start event is $0.005 per GB, charged once per run. Manual review rows, unsupported rows, and lifecycle events that were already delivered are not charged. In **alert only on change** mode, unchanged listings are not written and are not charged. The registry still updates.

Set a max total charge on the run. When the platform reports that the limit is reached, the Actor stops cleanly, keeps the registry for listings it fully delivered, and says so on the summary.

| Workload | Billable units | Actor events |
| --- | --- | --- |
| Default sample, one run | 20 listings + 18 events | $0.44 |
| One weekly full ledger: 3,000 listings, 40 events | 3,000 × $0.004 + 40 × $0.02 | $12.80, about $51.20 across four weeks |
| The same week in alert-only mode, if only those 40 listings changed | 40 × $0.004 + 40 × $0.02 | $0.96 |
| A heavy month in normal mode: 15,000 observations, 1,000 events | 15,000 × $0.004 + 1,000 × $0.02 | $80 |

Add the start event for each run. A scraper or other Actor that produced the dataset is a separate bill. A second run of the same book charges `listing-reconciled` again for the rows it delivers, and charges a lifecycle event only when the change is new.

### Integrations

Schedule this Actor after the export you already trust. Pass that run's dataset id. Use one state key per metro or per client.

- **Webhook.** On run succeeded, read the default dataset. Keep rows whose `recordType` is `lifecycle_event`, or whose `needsReview` is true. The summary URL is in the output schema.
- **n8n or Make.** HTTP node: run the Actor with `datasetId` and `stateKey`. Next node: get dataset items. A filter on `eventType` is enough to post a price drop or a relisting into Slack or a sheet. Flat fields mean you do not need a code step to unpack them.
- **Sheets.** Download the dataset as CSV. `lineage`, `candidatePropertyIds`, and the price columns are single cells.

`alertOnlyOnChange` is the setting to use when the webhook should stay quiet on an unchanged book. Open manual reviews are still included.

### Run locally

```bash
pip install -r requirements-dev.txt
pytest
python main.py
```

`python main.py` with no input runs the sample and writes `./storage`. The dataset items and the `OUTPUT` summary are under that directory. Pytest covers normalization, portal-shaped field mapping, unit traps, lifecycle diffs, charging, a charge limit, a registry that must not be overwritten, and URL checks with a mocked HTTP client.

Publish with **limited permissions**. The Actor reads only the dataset you pass in, and it creates its own named state store.

### Limits

- This is not a valuation, not a portal scraper, and not a contact finder.
- The address normalizer is built for common US and UK lines (directions, street suffixes, flats, ZIP and postcode tails). Other local conventions can miss and will land in review or as a new property rather than a silent merge.
- Fuzzy matching never crosses a house number. It will under-merge before it merges two units.
- There is no geocoder. Coordinates are used only when the feed provides them.
- A feed that only has a ZPID, and no separate MLS or listing id, cannot show a relist that keeps the same ZPID. Map `listingId` to the MLS number when you have it.
- Coverage is whatever you declare. The Actor cannot tell that a file was truncated unless it hits `maxListings`.
- Listing reconciliation is charged once per delivered observation in a run. A platform retry that starts a **new** run id can charge those listing rows again. Lifecycle event ids are stored, so the same price drop is not billed twice.
- Overlapping runs on one state key are blocked with a short lock plus a revision check. The lock is not a distributed transaction. Do not schedule two writers on the same key.

### FAQ

**Why was a home I dropped from the file not marked sold?**
Missing from the file means unknown. A partial extract, a failed page, or a feed that only contains active ads would otherwise look like a wave of sales. Status changes only when the listing is present and the status text changed.

**Why did two apartments stay apart?**
The unit token is part of the property identity. Apt 1 and Apt 2 are different properties even when the street, beds, and coordinates match.

**Why is a row in manual review instead of a new property?**
The address is close to one or more saved properties, but the unit is missing or the score is in the review band. Attaching it would be a guess. The row is free. Add the unit, or send a confirmed link that does not cross units, and run again.

**A listing id came back with an earlier date. Did the price revert?**
No. An observation older than the saved one does not rewrite price or status and does not emit a new event.

**Is the sample real listing data?**
No. It is fictional, labelled on every row, and included so the default run returns a non-empty dataset without calling a portal.

# Actor input Schema

## `useSample` (type: `boolean`):

Run the labelled fictional sample (two snapshots with a relisting, a duplicate advertisement, and unit traps). Ignored when you supply a dataset, JSON, CSV, or URL.

## `datasetId` (type: `string`):

Dataset from a listing run in your Apify account. Read-only. Leave empty to use JSON, CSV, a file URL, or the sample. This Actor does not scrape listing sites.

## `listingsJson` (type: `string`):

A JSON array of listings, or an object with a snapshots array. Each snapshot may set snapshotId, observedAt, coverage, and listings.

## `listingsCsv` (type: `string`):

CSV text with a header row. Common listing column names are mapped automatically.

## `listingsUrl` (type: `string`):

HTTPS or HTTP URL of a JSON or CSV file you are allowed to fetch. Private hosts, localhost, and link-local addresses are refused. HTML pages are not parsed.

## `fieldMapping` (type: `object`):

Optional map of canonical fields to your column paths. Example: {"listingId": "mlsNumber", "address": "siteAddress", "unit": "apartment"}. Empty values fall back to automatic mapping.

## `snapshotId` (type: `string`):

Id for a flat JSON, CSV, or dataset input. Defaults to the dataset id, or input. Not used when the JSON already contains snapshots.

## `observedAt` (type: `string`):

Observation date for a flat input, YYYY-MM-DD. Defaults to today. An older date does not rewrite a newer saved listing.

## `snapshotCoverage` (type: `string`):

Partial means this file is not a full comparable scan. Listings missing from a partial or complete file stay unknown. They are never marked sold.

## `sourceLabel` (type: `string`):

Label stored with listing ids when a row does not name its own source. Use one label per feed so the same listing id stays stable across runs.

## `defaultCountry` (type: `string`):

Country assumed when a row does not say. US or GB.

## `stateStoreName` (type: `string`):

Named key-value store that keeps the property registry across runs. Created on first use under limited permissions.

## `stateKey` (type: `string`):

Separate books inside the same store, for example a metro or a client.

## `resetState` (type: `boolean`):

Ignore the saved registry for this run and treat every listing as a first observation. The previous registry is kept under the last-good key.

## `confirmedLinksJson` (type: `string`):

Optional array of {"listingId", "propertyId"} or {"listingId", "sameAsListingId"}. A confirmation cannot merge two different units, or a missing unit into a known unit.

## `matchThreshold` (type: `integer`):

Fuzzy score from 70 to 100 required before two listings are treated as the same property. House number must still match, and units must be compatible.

## `reviewThreshold` (type: `integer`):

Scores from this value up to the auto-match score are delivered for review and are not merged or charged as a reconciliation.

## `maxListings` (type: `integer`):

Stop reading after this many rows per snapshot. A truncated file is treated as partial coverage.

## `alertOnlyOnChange` (type: `boolean`):

Push lifecycle events and manual reviews only. Unchanged listings are not delivered and are not charged. The registry is still updated.

## Actor input object example

```json
{
  "useSample": true,
  "fieldMapping": {},
  "snapshotCoverage": "partial",
  "sourceLabel": "feed",
  "defaultCountry": "US",
  "stateStoreName": "property-listing-reconciler-state",
  "stateKey": "default",
  "resetState": false,
  "matchThreshold": 90,
  "reviewThreshold": 75,
  "maxListings": 20000,
  "alertOnlyOnChange": false
}
```

# Actor output Schema

## `overview` (type: `string`):

Flat rows. Use recordType to separate lifecycle_event, reconciled_listing, and manual_review.

## `events` (type: `string`):

Price changes, status changes, reactivations, duplicate advertisements, probable relistings, and first observations.

## `summary` (type: `string`):

Counts, charged units, coverage, and listings that were missing without being marked sold.

# 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 = {};

// Run the Actor and wait for it to finish
const run = await client.actor("plusonets/property-listing-reconciler").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 = {}

# Run the Actor and wait for it to finish
run = client.actor("plusonets/property-listing-reconciler").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 '{}' |
apify call plusonets/property-listing-reconciler --silent --output-dataset

```

## MCP server setup

```json
{
    "mcpServers": {
        "apify": {
            "type": "http",
            "url": "https://mcp.apify.com/?tools=fetch-actor-details,plusonets/property-listing-reconciler"
        }
    }
}
```

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/RMhtPQBqJsdnoOLxi/builds/aHaf53tkCYdcM6gOo/openapi.json
