# Using the prophaic2 API A guide for people who call the deployed service: how to reach it, what to send, what comes back, how US and Canadian requests differ, and how to read the answer. Sample outputs below were captured on 2026-10-12 from a local run of the same code, with live source requests, and are trimmed (`…`). Values change as listings change. - [Reaching the service](#reaching-the-service) - [Endpoints](#endpoints) - [Input payload](#input-payload) - [Output payload](#output-payload) - [US and Canada](#us-and-canada) - [Examples](#examples) - [Errors](#errors) - [Reading an estimate](#reading-an-estimate) - [Performance and good manners](#performance-and-good-manners) ## Reaching the service The deployed API is at **`https://prophaic.31-97-135-147.nip.io`**. nginx on `resu` terminates TLS (Let's Encrypt, auto-renewed) and proxies to uvicorn, which listens on `127.0.0.1:8095` only (`deploy/prophaic2-api.service`, `deploy/nginx-prophaic2.conf`). Every request needs the shared key in an `X-API-Key` header. Ask the maintainer for it; it is not in this repo. Set it once in your shell: ```bash export PROPHAIC_URL=https://prophaic.31-97-135-147.nip.io export PROPHAIC_KEY=... # the shared key curl -s "$PROPHAIC_URL/health" -H "X-API-Key: $PROPHAIC_KEY" ``` | Result | Meaning | |---|---| | `401 {"detail":"missing or invalid X-API-Key header"}` | No key, or the wrong one. Answered by nginx; the API never sees it | | `429` | More than ~30 requests a minute from one IP (a burst of 10 is allowed) | | `504` | A slow source held the estimate past nginx's 120 s timeout; retry | On `resu` itself you can skip nginx and call `http://127.0.0.1:8095` with no key. There is no per-user identity, no usage metering and no API versioning: the key is shared, and every call makes live requests to third-party sites (see [Performance and good manners](#performance-and-good-manners)). To rotate the key, edit `/etc/nginx/conf.d/prophaic-apikey.conf` on `resu`, then `sudo nginx -t && sudo systemctl reload nginx`. **Internal use only.** Canadian sold comps come from Wahi, whose data terms limit it to personal, non-commercial use (`docs/canada-list-prices.md`). Do not share the key outside the team. ## Endpoints | Method and path | Purpose | |---|---| | `POST /estimate` | Value one property | | `GET /health` | Service and source status | | `GET /docs`, `GET /openapi.json` | FastAPI's generated interactive docs and schema | ### `GET /health` ```json {"ok": true, "degraded_sources": [], "sources": {"realtor": {"requests": 3, "ok": 3, "blocked": 0, "errors": 0, "failed": 0, "skipped": 0, "last_ok_at": "2026-10-12T11:32:04", "circuit_open_until": {}, "penalty_s": 0.0, "circuit_open": false}, "zillow": {…}, "redfin": {…}, "wahi": {…}}, "photo_model": "loaded", "cache": {"items": 7, "hits": 0, "misses": 7}} ``` - `degraded_sources`: sources whose circuit is open (every route was blocked; requests to them fail at once for 10 minutes) or whose last attempt failed after a block. If this is non-empty, expect warnings or reduced accuracy for the affected country. - `photo_model`: `loading` (first ~40 s after start), `loaded`, or `unavailable` (photo libraries not installed; requests that ask for photos get a warning instead). ## Input payload `POST /estimate` with `Content-Type: application/json`. Supply **exactly one** of `facts`, `listing_id` or `listing_url`. | Field | Type | Notes | |---|---|---| | `facts` | object | Property facts; see below | | `listing_id` | string | An MLS number (e.g. `MDMC2245576`), or `realtor:`. **US in practice:** see [US and Canada](#us-and-canada) | | `listing_url` | string | Not implemented; returns `501` | | `market_hint` | `"US"` or `"CA"` | Optional. Sets the country when `facts.country` is absent, and chooses which sources an MLS lookup asks | | `include_comps` | bool, default `true` | `false` drops the `comps` list (smaller, faster to read; same estimate) | | `with_photos` | bool or omitted | Score listing photos for the subject and top comps. Omitted: **on for Canada, off for the US**. Adds up to ~0.7 s | | `as_of` | date `YYYY-MM-DD`, default today | Valuation date; not in the future. See [Valuation date](#valuation-date-as_of) | ### Valuation date (`as_of`) Every input is restricted to what was known before `as_of`: - **Sold comps** closed strictly before it (Canada: Wahi filters on the sold date server-side). - **Listed comps** on a past date: only listings already on the market before it, at **today's** asking prices (a response warning says so). - **The home's own records** are used only as earlier, actual sales: its own listing, any sale on or after `as_of`, and — with `listing_id` — the sale of that very listing are never comps, never its "last sale" and never its asking-price anchor. With a past `as_of`, a `listing_id` subject's listing state is rebuilt from its price history as of that day (on the market or not, the asking price and cuts it had then). - Facts (beds, size, tax…) come from the home's records regardless of date. So to value a home that sold on 2026-09-21 the way it looked before the sale, send `"as_of": "2026-09-01"`. Without `as_of`, a home searched by **address** that sold within the last year is still valued from that sale (it is the best evidence of today's value); by **`listing_id`**, the listing's own sale is excluded either way. The response echoes `as_of`. ### `facts` You need an `address`, or `lat` and `lon`. **Send an address whenever you have one**; add `lat`/`lon` as well only if you already hold them. Everything else is optional, but **more facts mean a better estimate**; a missing fact is simply not compared (comps that know it while the subject does not are slightly down-weighted). **Location and type** | Field | Notes | |---|---| | `address` | Street line with the unit for condos: `"7111 Woodmont Ave Apt 402, Bethesda, MD"`. City, state or province help the geocoder | | `lat`, `lon` | Skips geocoding. **Also set `country`**: with coordinates only, the country defaults to `US`. Without an `address` you lose accuracy: see below | | `country` | `"US"` or `"CA"`. Inferred from the geocoder's state/province when you give an address; an address no US geocoder finds is tried in Canada. In Canada addresses are placed by Mapbox (the unit is kept from your address); send `"CA"` to skip the US lookups | | `property_type` | `single_family` (**the default**), `condo`, `townhouse`, `multi_family`, `land`, `mobile`, `other`. Always set it for condos and townhouses | | `subtype` | Board style such as `detached`, `semi-detached`, `link`. Used in the GTA (comps of a different style keep 10% weight); ignored elsewhere | | `street_number`, `street_name` | Street number and name without the unit (`"2003"`, `"Rabbit Hill Rd"`). Read only to find the home on a city's assessment roll (Edmonton, Winnipeg, Montréal); without them (and without an `address`) Edmonton and Winnipeg match by coordinates, which misses more condo buildings, and Montréal matches nothing. A condo's unit number comes from `address` (`"304-1220 Boul René-Lévesque E"`, `"…, app. 304"`). They don't change comp matching | **Size and age** | Field | Notes | |---|---| | `beds`, `baths` | Numbers; half baths as `.5` | | `sqft` | Living area. If you only have a range, give `sqft_min` and `sqft_max`; the midpoint is used | | `lot_sqft` | Lot size in sq ft. A value under 50 on a house is read as acres | | `year_built` | | | `stories`, `garage` | `garage` = garage or parking spaces. `0` means none (a real value); omit if unknown | | `hoa_fee` | Monthly association or maintenance fee. Condos: improves comparison between buildings | | `tax_annual`, `assessed_value`, `assessed_year`, `tax_history` | Latest tax bill and assessment. Used in the hedonic adjustment (assessment, else tax) and, without an assessment, in comp similarity; also for sanity warnings; `tax_history` is a list of `{year, tax, assessed}` | | `style`, `suite`, `bsmt_finished`, `den` | Extra board facts (building style, second kitchen, finished basement, den). Accepted; used only by candidate features that are currently off | **Price history (changes which method drives the headline)** | Field | Effect | |---|---| | `last_sale_price`, `last_sale_date` | Ignored if on or after `as_of`. Within the year before it: the headline becomes that sale moved to the valuation date by the local trend (tier A). Older: it is indexed by how nearby homes appreciated since, and blended with the comps (US only in practice) | | `list_price` | **The home is treated as listed and the headline is its asking price alone** (tier A), with the comps shown for context. Leave it out to value the home from comps. Also set `status` to `active` or `pending`, or leave it unset; a `sold` status makes the price ignored | | `original_list_price`, `price_cuts` | Number of price cuts lowers the weight of the asking price | | `status` | `active`, `pending`, `sold`, `off_market` | **Identity (rarely needed)**: `source`, `source_id`, `mls_id` link the subject to its own listing record so it is not counted as its own comp. `description` and `photo_urls` are accepted but only read by the optional Jev and photo features; they are never echoed back. **Coordinates without an address.** The service accepts them, but the address does real work after geocoding, so a coordinates-only request is less accurate: - the subject cannot be recognised among the comps, so its own earlier sale or current listing can be counted as a comparable of itself; - "same building" for condos falls back to a short distance test instead of the street match; - in Canada the subject's own Wahi record (its board style, missing facts, photos) is not looked up, because that lookup matches on the street line and unit. With an address and coordinates together, the coordinates are used for the search and the address for matching, which is the best combination. Unknown fields are ignored. Wrong types return `422`. ## Output payload JSON with **null fields omitted**: absent means unknown, so read with defaults rather than expecting every key. | Field | Meaning | |---|---| | `as_of` | The valuation date used (your `as_of`, or today) | | `subject` | The subject as resolved: your facts plus anything filled in (geocoded lat/lon and country, the source's beds/baths/size/HOA/tax/assessment, last sale) | | `currency` | `USD` or `CAD` | | `estimate`, `low`, `high` | Headline value and the P10–P90 range (80% interval). `null` if no comps could be found | | `confidence` | `A`, `B` or `C` (below) | | `n_eff` | Effective number of comps after weighting; ~10 or more is comfortable | | `blend` | Weight of each part of the headline, summing to 1, e.g. `{"comps": 1.0}`, `{"subject_recent_sale": 1.0}`, `{"comps": 0.62, "prior_sale": 0.38}` | | `methods` | Every part's own estimate, each `{estimate, low, high, n, n_eff}`: `sold_only`, `active_only`, `blended` (the comps estimate), `same_building` (condos with 3+ building comps), `hedonic` (the model's own prediction), `prior_sale`, `subject_list_anchor`, `subject_recent_sale` | | `sale_to_list` | The ratio applied to asking prices: `{value, n_pairs, basis, calibrated}`. `basis`: `final_list`, `original_list`, `as_of_list`, `default`, `country_default` or `board_published` (the last also carries `area` and `asof`) | | `hedonic` | The fitted local model: `coefs` (log-price per feature, e.g. `lsqft` ≈ size elasticity), `n`, `n_eff`, `intercept`, `resid_sd`. Absent if too few comps | | `comps` | Up to 25 best-weighted comps (see below). Empty with `include_comps: false` | | `warnings` | Plain-text caveats; always read them | | `vision` | Photo scoring summary when photos were used: `comps_top`, `comps_with_photos`, `comps_scored`, `timed_out`, `ms`, and the subject's `condition` / `finish` / `appeal` if its photos were found | | `timings_ms` | `resolve`, `gather`, `fetch_`, `vision`, `estimate`, `total` | ### Confidence | Tier | Meaning | Range | |---|---|---| | **A** | The headline rests on the home's own price: its asking price, or a sale within a year | As computed | | **B** | 10+ effective comps, sold comps present, and every headline part agrees within its uncertainty | As computed | | **C** | Everything else, always when there are no sold comps | Widened ×1.3 | ### One comp (`comps[]`) | Field | Meaning | |---|---| | `source`, `source_id`, `mls_id`, `url` | Where it came from (`realtor`, `redfin`, `wahi`, `zillow`) and its page | | `status` | `sold`, `active` or `pending` | | `is_mls` | `false` for public-record-only sales (US) | | `address`, `distance_mi`, `property_type`, `subtype`, `beds`, `baths`, `sqft`, `sqft_source`, `year_built`, `hoa_fee`, `assessed_value` | The comp's facts. `sqft_source`: `field`, `range_mid` (TRREB range midpoint), `above_grade`, `text`, `building` | | `list_price`, `close_price`, `close_date` | Its prices. A sold row's `list_price` is its final asking price | | `value` | What it contributes: its price moved to the subject | | `basis` | `close` (sold: close price) or `list_x_ratio` (listed: asking price × `sale_to_list.value`) | | `adjustment` | `value` ÷ price, from the hedonic model. 0.92 means the comp is adjusted down 8% to match the subject | | `same_building` | Condo in the subject's building | | `weight` | The factors multiplied into the comp's weight: `distance`, `sqft`, `beds`, `baths`, `year`, `lot`, `building`, `extra`, `comparability`, `recency`, `kind`, and their product `total` | | `vision` | Photo scores `{condition, finish, appeal, n_photos}` when scored | ### Common warnings | Warning (abridged) | What to do | |---|---| | `N parking / storage units dropped from the comps`, `N comps with no size or bedroom count dropped` | Informational: data cleaning | | `few effective comps (n_eff=…)` | Treat the range as optimistic | | `no sold comps: estimate rests on the asking prices of N active listings × R …` | Asking prices only; tier C. See [US and Canada](#us-and-canada) | | `sale-to-list ratio is N months old …` | The published board ratio is stale; refresh `prophaic/ca_ratios.py` | | `sale-to-list ratio uncalibrated (N pairs); using 1.0` | Few sold/list pairs nearby | | `asking price differs from the comps estimate by ±X%` | The subject's own price disagrees with its neighbours | | `last sale (date) indexed to … is far from the comps; not used` | The prior-sale part was dropped | | `estimate is X× the assessed value; nearby homes sell at about Y× theirs`, `estimate differs from the median of N automated valuations by ±X%` | Sanity checks; the number is unchanged. Re-check your facts | | `sold comps unavailable: blocked every route …`, ` failed: …` | A source is walled or down; check `/health` | | `photo scoring hit the 0.7 s budget: 10/15 comps scored …` | Normal; the rest is cached, so a repeat call scores more (comps take 8 photos each, so a cold call often scores only a few) | | `photo scores unavailable: vision extras not installed` | Photo libraries missing on the server | | `City of Edmonton 2026 assessment $… (…, matched by address)` | Informational: the home's assessed value is part of the headline (City of Edmonton homes) | | `City of Edmonton assessment unavailable (…)` | The City's open-data API failed; the estimate rests on the comps | | `City of Winnipeg 2027 assessment $…`, `City of Montréal 2026–2028 roll value $…` | Informational: the home's assessed value is part of the headline (see [US and Canada](#us-and-canada)) | | `no comps: the estimate is the city's assessed value alone` | Winnipeg or Montréal with no comps: the roll value is the estimate; tier C | | `City of Winnipeg assessment unavailable (…)`, `City of Montréal assessment unavailable (…)` | The City's open-data API failed | | `subject lookup on Wahi failed`, `subject price history failed` | The subject's own record could not be fetched; the estimate used your facts only | ## Photo ratings (premium) The estimate always uses the listing photos where the market's settings call for it (Canada by default, the US with `with_photos`): each comp's weight is multiplied by how much its photos look like the subject's. What a caller can *see* depends on the key. | | Standard key | Premium key | |---|---|---| | Estimate uses photos | yes | yes | | `include_photos: true` | `403 {"detail": {"error": "premium_required", ...}}` | returns the ratings below | | `photo_ratings` in the response | `premium_only` when photos were scored | `included`, or `not_requested` | Premium keys are listed in `PROPHAIC_PREMIUM_KEYS` (comma separated) in the API's environment, and must also be accepted by nginx's `map` in `conf.d/prophaic-apikey.conf`. The API reads the same `X-API-Key` header nginx forwards. A request with `include_photos` waits up to 25 s for photos not yet scored (a cold comp takes a few seconds; later requests are cached), so its estimate can differ from a request that hit the 0.7 s budget on a cold cache. Ratings use the judge's scales: `condition` and `finish` 1 to 5, `appeal` 1 to 3. ```jsonc "vision": { // Estimate level "comps_top": 15, "comps_scored": 15, "subject": { "condition": 3.81, "finish": 3.13, "appeal": 2.88, "n_photos": 8, "rooms": {"exterior": 2, "living": 4, "kitchen": 1, "other": 1}, "photos": [ {"url": "https://…jpg", "room": "exterior", "condition": 3.47, "finish": 2.76, "appeal": 2.75}, … ] }, "scales": {"condition": "1-5", "finish": "1-5", "appeal": "1-3"} } // comps[].vision { "condition": 3.8, "finish": 3.2, "appeal": 2.9, "n_photos": 8, "photos": [ …same rows… ], "similarity": { "condition_finish": null, "visual": 0.29 }, // the factors that applied; null = not used "photo_factor": 0.29, // the comp's weight is multiplied by this (1 = looks like the subject) "weight_share": 0.088, "weight_share_without_photos": 0.109, // approximate: ignores the 1/3 cap "weight_change": -0.19, "effect": "down" } // up | down | none (±2%) ``` A comp with fewer than 2 rated photos is unscored: it has no `vision` and its factor is 1. A subject with fewer than 2 photos (some sold listings have one) has none either, so no comp is compared with it. `photos[].url` is the image the rating was computed on (Realtor's 460 px rendition, or the Wahi photo); it is the source's own URL, not a copy. ## US and Canada The same request shape works for both countries; the sources and some behaviours differ. | | US | Canada | |---|---|---| | Choose by | Default | Address in a Canadian province, or `facts.country: "CA"`, or `market_hint: "CA"` | | Currency | `USD` | `CAD` | | Sold comps | Realtor.com (Zillow fallback if Realtor.com fails entirely), last 12 months | Wahi, nearest 200 within 12 months. **Ontario, Alberta and Nova Scotia only** | | Active comps | Realtor.com, active and pending | Redfin.ca, active only | | Where there is no sold source (BC, SK, NB, …) | n/a | Estimate = asking prices × a sale-to-list ratio; tier C; a `no sold comps` warning | | Sale-to-list ratio | From nearby sold/listed pairs | From nearby pairs if any; otherwise TRREB's published monthly ratio (GTA); otherwise 1.0 (`country_default`) | | Geocoding (address only) | Realtor.com, then Zillow | Mapbox (address-level matches only), then Zillow and Realtor.com | | Subject history | Realtor.com record: last sale, original price, price cuts | Wahi record: missing facts and board style only | | Prior-sale anchor | Yes: the home's last sale, indexed to today | No | | Photos | Off unless `with_photos: true`; then embedding retrieval | On by default; comps weighted by closeness of photo condition/finish (15 comps × 8 photos, subject up to 8) | | Tax / assessment | Assessed value (tax where none) in the hedonic adjustment | Tax bill in the hedonic adjustment and comp similarity (Ontario clipped ±0.25). **City of Edmonton:** the home's value on the City's assessment roll is also a headline part, about 80% of the weight (`city_assessment`), because no public source has Edmonton's sales. **Winnipeg, City of Montréal:** the same from their rolls (σ 0.11 / 0.12); with no comp source there it is usually the estimate alone, tier C (`docs/winnipeg-montreal-rolls.md`) | | Sizes | Square feet from the listing | Condos usually a TRREB **range**; the midpoint is used (`sqft_source: "range_mid"`), so it can be 50–100 sq ft off | | Condos | Same-building weight and term | Same, plus parking-space and maintenance-fee terms | | GTA houses | none | A comp of another board style than the subject's keeps 10% weight, if the style is known | | MLS-number input | `listing_id` works | **Not expected to work**: with `market_hint: "CA"` only Zillow is asked and Zillow is walled (not tested). Use `facts` | | Typical error (backtest median) | ~3.8% for a listed home, ~9% for an unlisted one | ~7.8% with sold comps; ~12.8% on asking prices alone | | Coverage of the P10–P90 range | Fitted on US backtests | Not validated for Canada | Practical consequences: - **Canada: give an address with a province, or `market_hint: "CA"`.** If you send `lat`/`lon` instead, also send `country: "CA"` (coordinates alone are treated as US), and include the address if you can (see [Input payload](#input-payload)). - **For a Canadian condo, say whether it has parking** (`garage`: `0`, `1`, `2`). A downtown Toronto unit with no spot sells roughly $60–100K under the same building's units with one, and the model cannot see it otherwise (`docs/ca-misses.md`). - **Canadian listed homes** get no extra anchor: `list_price` still makes the asking price the headline, but nothing fills it in for you, so supply it yourself. ## Examples All examples assume `PROPHAIC_URL` and `PROPHAIC_KEY` from [Reaching the service](#reaching-the-service). ### 1. US home by address ```bash curl -s "$PROPHAIC_URL/estimate" -H "X-API-Key: $PROPHAIC_KEY" -H 'Content-Type: application/json' -d '{ "facts": {"address": "7111 Woodmont Ave Apt 402, Bethesda, MD", "property_type": "condo", "beds": 2, "baths": 2, "sqft": 977}}' ``` ```json { "subject": {"beds": 2.0, "baths": 2.0, "sqft": 977.0, "garage": 2.0, "hoa_fee": 1012.0, "tax_annual": 7036.0, "assessed_value": 570000.0, "assessed_year": 2025, "address": "7111 Woodmont Ave Apt 402", "country": "US", "property_type": "condo", "last_sale_price": 620000.0, "last_sale_date": "2026-08-28", "…": "…"}, "currency": "USD", "estimate": 612257, "low": 571750, "high": 655634, "as_of": "2026-10-12", "n_eff": 10.04, "confidence": "A", "blend": {"subject_recent_sale": 1.0}, "methods": { "sold_only": {"estimate": 612257, "n": 223, "n_eff": 4.98}, "active_only": {"estimate": 412654, "n": 168, "n_eff": 5.25}, "blended": {"estimate": 600123, "n": 391, "n_eff": 10.04}, "same_building": {"estimate": 600123, "n": 19, "n_eff": 2.94}, "hedonic": {"estimate": 528401, "n": 143, "n_eff": 30.28}, "subject_recent_sale": {"estimate": 612257, "n": 1, "n_eff": 1.0} }, "sale_to_list": {"value": 0.992, "n_pairs": 130, "basis": "final_list", "calibrated": true}, "warnings": ["25 parking / storage units dropped from the comps", "27 comps with no size or bedroom count dropped"], "timings_ms": {"resolve": 741.1, "gather": 1350.8, "estimate": 15.1, "total": 2107.0} } ``` Read this one carefully: the subject **sold within the last year** (2026-08-28, $620,000), so the headline is that sale carried to today by the local trend (`blend: subject_recent_sale = 1.0`, tier A). The comps broadly agree (`blended` $600K), but the home's own sale is also one of them, so that is not an independent check, and `sold_only` is pulled to the same figure for that reason. Active units ask less ($413K); most are smaller or older. To value the home as it stood before that sale (for example to check the model against it), send an `as_of` earlier than the sale date. ### 2. US home by MLS number, comps omitted ```bash curl -s "$PROPHAIC_URL/estimate" -H "X-API-Key: $PROPHAIC_KEY" -H 'Content-Type: application/json' \ -d '{"listing_id": "MDMC2245576", "include_comps": false}' ``` Returns the same fields with `"comps": []`, and a `subject` filled from the listing (beds, baths, 997 sq ft, year built, HOA, tax history). This listing is active at $524,900, so the headline is its asking price (`blend: {"subject_list_anchor": 1.0}`, tier A, range $490K–$562K) while `methods.blended` shows what the comps say (about $616K). Total time 2.7 s. ### 3. Canadian condo by address ```bash curl -s "$PROPHAIC_URL/estimate" -H "X-API-Key: $PROPHAIC_KEY" -H 'Content-Type: application/json' -d '{ "facts": {"address": "700 King St W Apt 801, Toronto, ON", "property_type": "condo", "beds": 2, "baths": 2, "sqft": 950, "garage": 0}, "market_hint": "CA"}' ``` ```json { "subject": {"beds": 2.0, "baths": 2.0, "sqft": 950.0, "garage": 0.0, "address": "700 King St W #801 Toronto", "lat": 43.644135, "lon": -79.403267, "country": "CA", "property_type": "condo"}, "currency": "CAD", "estimate": 667414, "low": 511829, "high": 870292, "as_of": "2026-10-12", "n_eff": 31.0, "confidence": "B", "blend": {"comps": 1.0}, "methods": {"sold_only": {"estimate": 737302, "n": 172}, "active_only": {"estimate": 620707, "n": 257}, "blended": {"estimate": 667414, "n": 429}, "hedonic": {"estimate": 776312, "n": 170}}, "sale_to_list": {"value": 0.96, "n_pairs": 172, "basis": "final_list", "calibrated": true}, "comps": [{ "source": "wahi", "mls_id": "C13558746", "status": "sold", "address": "123 Portland St Apt 303", "distance_mi": 0.19, "property_type": "condo", "subtype": "condo apt", "beds": 2.0, "baths": 2.0, "sqft": 849.5, "sqft_source": "range_mid", "hoa_fee": 904.0, "list_price": 624800.0, "close_price": 600000.0, "close_date": "2026-08-13", "value": 661157.0, "basis": "close", "adjustment": 1.1019, "weight": {"distance": 0.904, "recency": 0.707, "kind": 1.0, "total": 0.524}, "vision": {"condition": 3.94, "finish": 3.35, "appeal": 2.89, "n_photos": 2} }, "…"], "warnings": ["photo scoring hit the 0.7 s budget: 7/15 comps scored; the rest are cached for next time", "13 comps with no size or bedroom count dropped"], "vision": {"comps_top": 15, "comps_with_photos": 15, "comps_scored": 7, "timed_out": true, "ms": 735.2}, "timings_ms": {"gather": 6302.1, "fetch_wahi_sold": 6288.1, "vision": 735.4, "total": 7191.2} } ``` The range here is wide (about −23% / +30%) because only the size, bed/bath and parking facts were given and the comps in a downtown tower vary a lot. Sold comps ($737K) sit well above the asking prices of active units ($621K), which are converted with a 0.96 sale-to-list ratio. Adding `hoa_fee`, `year_built` and `tax_annual` narrows the comparison. `vision.subject` is absent when the subject's own record was not found: that record, which also supplies board style and photos, is matched by street line and unit. ### 4. Canadian home with coordinates you already hold Send the address too; coordinates need the country spelled out: ```bash curl -s "$PROPHAIC_URL/estimate" -H "X-API-Key: $PROPHAIC_KEY" -H 'Content-Type: application/json' -d '{ "facts": {"address": "123 Example St, Hamilton, ON", "lat": 43.2557, "lon": -79.8711, "country": "CA", "property_type": "single_family", "subtype": "detached", "beds": 3, "baths": 2, "sqft": 1450, "lot_sqft": 4000, "year_built": 1958}}' ``` ### 5. A home that is listed: price its asking price ```bash curl -s "$PROPHAIC_URL/estimate" -H "X-API-Key: $PROPHAIC_KEY" -H 'Content-Type: application/json' -d '{ "facts": {"address": "123 Example St, Chicago, IL", "property_type": "single_family", "beds": 3, "baths": 2, "sqft": 1800, "list_price": 525000, "status": "active", "price_cuts": 1}}' ``` The headline is the asking price (tier A, tightest range). The comps remain in the response, so compare `methods.blended` with `methods.subject_list_anchor`. To ignore the price and value the home from comps, omit `list_price`. ### 6. Python ```python import os import httpx BASE = os.environ["PROPHAIC_URL"] HEADERS = {"X-API-Key": os.environ["PROPHAIC_KEY"]} def estimate(**body): r = httpx.post(f"{BASE}/estimate", json=body, headers=HEADERS, timeout=90) if r.status_code == 409: # several properties share this MLS number raise ValueError([c["address"] for c in r.json()["detail"]["candidates"]]) r.raise_for_status() return r.json() d = estimate(facts={"address": "7111 Woodmont Ave Apt 402, Bethesda, MD", "property_type": "condo", "beds": 2, "baths": 2, "sqft": 977}, include_comps=False) print(d["currency"], round(d["estimate"]), round(d["low"]), round(d["high"]), d["confidence"]) for w in d.get("warnings", []): print("warning:", w) ``` `timeout=90`: a cold request that has to fetch comps from several sources can take 2–6 s, and a slow source has taken far longer. ## Errors All errors are JSON with a `detail` field. | Status | When | `detail` | |---|---|---| | `409` | An MLS number matches several properties (MLS numbers repeat across boards) | `{"error": "several properties match this MLS number", "candidates": []}`. Re-send with one candidate's address as `facts`, or `realtor:` | | `404` | `no listing with MLS # …`, or `could not locate address: …` | A string | | `422` | Not exactly one of `facts` / `listing_id` / `listing_url` (a list of validation errors), `facts need an address or lat/lon`, or a wrong type | List or string | | `501` | `listing_url` supplied | String | | `503` | The lookup source was blocked during resolution | String; check `/health` | A request that resolves but finds no usable comps returns `200` with `estimate: null` and warnings, not an error. A source that fails during comp gathering does not fail the request; it adds a warning and the estimate uses what remains. ## Reading an estimate 1. **Check `confidence` and `warnings` first.** `C` or a `no sold comps` warning means asking prices only. 2. **Compare `methods`.** `blended` is the comps estimate; if the headline differs (recent sale, asking price, prior sale), the `blend` weights say why. Large gaps are worth a second look. 3. **Look at `n_eff` and the range width.** n_eff under 5 or a range wider than ±20% means the neighbourhood does not offer close comparables. 4. **Scan the top `comps`**: distance, size, `adjustment`, `weight.total`. A heavily weighted comp with an adjustment beyond ±15% is doing a lot of the work. 5. **The range is P10–P90**: about 80% of outcomes should fall inside it if the model is calibrated. That has been checked on US backtests, not on Canada. What the estimate is not: an appraisal, and not valid for land, multi-family, or homes whose value is mostly in condition, renovation or location features that listings do not report (see `docs/ca-misses.md`: atypical homes make most big misses). ## Performance and good manners | Case | Typical time | |---|---| | US, cold, no photos | 1.4–2.5 s | | Canada, cold (photos on by default) | ~4–6 s | | Same area again within an hour (comps cached) | 0.5–0.7 s | - Results are cached in memory per grid cell for an hour (sold comps six hours), so repeated and neighbouring requests are much faster. A service restart empties the cache. - Each cold request makes tens of requests to Realtor.com, Redfin or Wahi. The service paces itself and backs off when a source refuses, but it has one process and one IP set. **Do not run bulk valuations through it**; for batches, throttle to a few requests a minute and prefer neighbouring addresses, which share cache. - To skip the photo cost in Canada, send `"with_photos": false`. It changes the estimate slightly (comps are no longer weighted by photo similarity). - If `/health` shows a source in `degraded_sources`, wait: the circuit reopens on its own after 10 minutes.