Property Listing Identity Reconciler avatar

Property Listing Identity Reconciler

Pricing

from $20.00 / 1,000 lifecycle events

Go to Apify Store
Property Listing Identity Reconciler

Property Listing Identity Reconciler

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

Pricing

from $20.00 / 1,000 lifecycle events

Rating

0.0

(0)

Developer

Olalekan Johnson

Olalekan Johnson

Maintained by Community

Actor stats

0

Bookmarked

2

Total users

1

Monthly active users

2 days ago

Last modified

Share

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:

InputWhen to use it
Apify datasetA dataset already in your account, read-only. Common Zillow-style, Rightmove-style, and Realtor-style field names are mapped automatically.
Listings JSONAn array of listings, or { "snapshots": [ { "snapshotId", "observedAt", "coverage", "listings": [] } ] }.
Listings CSVA header row plus one listing per line.
Listings file URLAn 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:

EventWhat it means
new_listingFirst 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_changeSame 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_changeSame listing id, and both statuses are known. Returning to market is a reactivation instead.
reactivationThe same listing id moves from sold or withdrawn back to active or pending.
duplicate_advertisementA second listing id is on the market for the same normalized address and unit in this snapshot.
probable_relistingA 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:

{
"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:

EventWhen it is chargedPrice
listing-reconciledA listing observation was matched and written to the dataset$0.004
lifecycle-eventA 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.

WorkloadBillable unitsActor events
Default sample, one run20 listings + 18 events$0.44
One weekly full ledger: 3,000 listings, 40 events3,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 changed40 × $0.004 + 40 × $0.02$0.96
A heavy month in normal mode: 15,000 observations, 1,000 events15,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

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.