The History Call
POST /v1/history returns one property's recorded transaction story: the chain of title
newest first, and any buy/sell flips detected in it. It is the call for the moment a
comparable sale looks too high — the one that turns "that comp is way above the others"
into "that comp was itself a flip, bought in July and resold in February by a company"
before it anchors your offer.
It is a Metered Call and it costs one Credit, the same at every Tier. The member-by-member schema is in the API reference; this page is what the call is, what it promises, and — the part you should read before you send one — what it charges for.
Input is exactly one of address or property_id
Send one of them. A body carrying both, or neither, is rejected with 400 and nothing is charged.
Code
Code
Presence of the member is what counts, not whether its value is usable. Sending an empty
address alongside a real property_id counts as sending both, and is rejected. That is
deliberate: choosing one for you would mean answering about a different house from the one
your empty field was reaching for, and you would have no way to tell.
This endpoint accepts no other members. condition, rehab_budget, target_margin and
overrides belong to the Scored Call and are rejected here.
The two arms behave differently, and the response says which one ran
- By
address— the address is resolved exactly the way a Scored Call resolves it: the same matching, the same refusals for an ambiguous address, the same coverage rules. - By
property_id— take the identifier straight off any comparable property in a Scored Call answer, or off a subject you have already scored. No address matching is performed on this arm at all. The record is looked up directly.
Either way the response carries a subject_property block — the same block a Scored Call
publishes — so you can always see which house the answer is about. On the property_id
arm its address_quality.code reads identified-by-property-id, which says plainly that no
house number and no ZIP code were compared.
⚠️ A property with nothing on file is a charged answer
If the county records are available and hold no recorded transactions for the property, the
call returns 200 with an empty transactions array — and one Credit is consumed.
This is stated here rather than left for you to discover from a line on your Ledger. "We checked; nothing on file" is an answer: the records were read, and that is the work you paid for. A property with a clean, short title history is a real and useful finding.
Every such response carries records_statement, a plain sentence saying exactly that.
A failure is a different thing entirely, and it is not charged. If the records could not be read — the service was unavailable, timed out, or returned an incomplete record — the call returns a 5xx and the Credit is returned to your Account as a Refund. You will never be charged for an answer we did not obtain, and an incomplete record is never quietly reported as an empty history.
transactions[] — the chain of title
Newest first. Each event carries its recorded date and price — either is null where the
record carries none, and neither absence removes the event from the chain — and a kind
from a small fixed vocabulary:
kind | What it means |
|---|---|
standard-sale | An ordinary arms-length sale |
foreclosure | An involuntary taking |
bank-repo | A lender reselling a repossessed property |
family-transfer | A transfer between family members |
new-construction | A first conveyance from a builder |
unknown | The record did not establish a category |
unknown is a real answer, not a placeholder. County records are inconsistent, and where
one does not establish what kind of transaction occurred, this API says so rather than
guessing. Treat unknown as "not established", never as "probably a sale".
Each event also carries a buyer_type — individual, company, trust or unknown.
What these categories are, honestly
Every category in this response is a derived conclusion, not a fact the county asserted.
They are this API's reading of the recorded documents. Where the county itself drew a
conclusion — that a transfer was a foreclosure, say — that conclusion is passed through and is
also published as its own field. Where it did not, the category is derived, and where nothing
supports a derivation the answer is unknown.
Two consequences worth planning for:
- A transfer recorded at a nominal price is never reported as a
standard-sale. A deed recorded at one dollar is a transfer of title, not what the house changed hands for, and reporting it as a sale would put a fabricated number in front of you. - A price is always published with its
price_is_estimatedflag beside it. When that flag istruethe price is the record's own estimate, not a recorded consideration.
Dates the record does not carry
Some recorded events carry no sale date. They are still listed — dropping them would hide
real transactions, including foreclosures — but they cannot be ordered, so they appear last,
after every event that can be. Their sale_date is null. They are never presented as the
oldest event on the property.
flips[] — detected buy/sell pairs
Each entry carries the two dates, the two prices, days_held, percentage_gain (a fraction —
0.4195 is a 41.95% gain), and seller_posture — professional, private or unknown.
It is present on every entry: professional is a company or a trust selling at the end of the
hold, private is an individual.
unknown is a real answer, not a placeholder. It means no seller was recorded on the sell
leg, which is ordinary and common. A rule written as "if it is not professional it is
private" labels every unrecorded seller an individual — a confident claim about a real
person's house that the record does not support.
- A negative
percentage_gainis a real outcome. A flip that lost money is reported as it happened. - A zero-day hold is real too — a same-day buy and sell is a simultaneous close.
- A flip is a short hold, and that rule decides most of them. A buy and a resale are reported as a flip only when 540 days or fewer separate the two recorded sale dates. The boundary is inclusive — a hold of exactly 540 days is a flip — so a property bought in 2015 and resold in 2024 is ownership rather than a trade, and never appears here however large the gain.
- A pair is reported only when both legs carry a real market price, because a percentage gain computed against a nominal price would be a fabricated number.
- A pair involving a family transfer is not reported as a flip.
seller_position — how long they have held it, and what we cannot tell you
This chapter answers one question honestly and refuses to answer another one at all.
| Member | What it is |
|---|---|
owned_since | The recorded date the current ownership period runs from — the newest dated event in transactions[] above. null when the chain carries no dated event. |
days_owned | Whole days from owned_since to now. null when ownership length could not be established, and null rather than a negative number if the record carries a sale date in the future. |
recorded_loan_available | Whether a recorded loan for this property is available to us. |
basis | A plain sentence stating what this chapter rests on. Fixed wording, selected from a table — it never repeats your address or identifier. |
There is no payoff estimate and no equity estimate, and here is exactly why
recorded_loan_available is false on every answer today, and that is a measured statement
rather than a placeholder. This chapter was specified to publish a payoff range and an
equity range derived from the loan recorded at the property's last sale. Before building
it we went looking for that loan record — through the property data service's full published
route list, through thirty-one candidate record-set names, and by matching every one of 579
distinct column names it returns across a sample of properties. There is no per-property
loan, mortgage or lien record available to us: no loan amount, no loan date, no lender, no
interest rate, no maturity and no payoff.
So no payoff figure and no equity figure is published, and nothing is inferred from that absence. In particular this answer never says a property is owned free and clear, and never says a purchase was made in cash. A missing record means we did not find one to look at — it does not mean no loan exists. A confident financial claim about someone's house, built on the absence of a column, is exactly the kind of answer this API exists not to give.
Two things are worth knowing even for the day a loan record does become available, because they are limits the question itself carries rather than limits of our data:
- A payoff derived from the loan recorded at the last sale is an estimate about the past. Refinances after that sale are invisible to the public record, so an owner who refinanced would carry a balance the recorded loan cannot show.
- For that reason such an estimate would be published as a range, never as a point figure, and it would state its basis in the answer.
Until then, what this chapter reports is ownership length, taken from the chain of title above so the two can never disagree.
permits — recorded work, and an honest word about coverage
| Member | What it is |
|---|---|
coverage_known | true only when at least one permit record was actually returned. Never inferred from an empty result. |
statement | A plain sentence you can show a colleague, saying what was retrieved or what could not be. |
permits[] | The permits themselves — see the selection rule below. |
roof, hvac, water_heater, electrical, plumbing, demolition | The most recent recorded permit in each category, as most_recent_date, most_recent_job_value and work_status. All three are null where no permit for that category came back. |
has_open_permit | Whether work is still open. See below — this is three-valued on purpose. |
"We cannot get permit records for this" is never "this property has no permits"
This is a promise about honesty, not a footnote. There is no signal available to us that distinguishes this property has no permits from this county publishes no permit records. We know that because we checked: an identifier that does not exist at all gets the same cheerful empty answer as a real property with a clean record. Three completely different facts, one identical response.
So this endpoint reports records-unavailable and never reports an absence of permits.
coverage_known is false, permits is empty, and statement says so in words. A permit
lookup that fails outright is reported the same way and the call is still charged — the
statement it produces is identical, because it is identical in truth: we cannot report permit
records for this property. The rest of your answer — the chain of title, the flips, the
listing story — is unaffected and was never at risk.
Read coverage_known, not the length of the array. An empty array on its own means
nothing, and that is the whole reason the two members are published together.
permits[] is a selection, not the whole file
It carries the most recent permit in each of the six categories above, plus every permit still open and every demolition. A property with eighty-four recorded permits does not hand you eighty-four rows. Each entry carries:
| Member | What it is |
|---|---|
work_status | completed, inactive, open, in-review, or unknown. |
job_value | The recorded value of the work, or null. null is ordinary — most permit records carry no job value at all. |
file_date, issue_date, final_date | Recorded dates, or null. |
construction_duration | A recorded duration figure, or null. It carries no unit, and that is deliberate — see below. |
tags[] | What the work was, as a fixed set of categories. Never the county's free-text description. |
work_status: unknownis notcompleted. A permit whose status the county did not state is a permit we cannot tell you is finished.has_open_permitis three-valued.truewhen a permit is open or in review.falseonly when records were retrieved and every permit's status was readable.nullwhenever records were unavailable or any permit's status was unreadable — becausefalseclaims we checked, and an unreadable status has not been checked.construction_durationhas no unit and we will not invent one. The record carries a bare number with nothing anywhere naming days, weeks, months or inspection cycles. Rather than guess, we publish the figure as it is recorded and say plainly that its unit is not established. If that changes, the unit will be added then.
market_status — the current listing story
| Member | What it is |
|---|---|
state | active, sold, failed, off-market, rental or unknown. |
rental_listing | Whether the listing is a rental. null means the record does not say. |
days_on_market | Days on market for a sale. Sparse — most records carry none. |
original_listing_date, failed_listing_date, sold_price, sold_date | As recorded, or null. |
list_price_high / list_price_high_date, list_price_low / list_price_low_date | The high- and low-water asking prices and when each was recorded. |
price_reduced | Whether the asking price was cut. Three-valued — see below. |
- A rental listing is not a sale listing. When the record marks a listing as a rental,
stateisrentaland the sale-specific figures — days on market, sold price, sold date — are withheld rather than reported as if they described a sale. unknownis a real answer. The listing status vocabulary published by the underlying record is not a fixed list we can rely on, so a status we have not seen before is reported asunknownrather than filed under the nearest-looking category. A cancelled listing in particular is not reported asfailed: a withdrawn listing may simply have been relisted, andfailed_listing_dateis published separately for the case the record does establish.price_reducedistrueonly when the low price was recorded after the high one. The two figures are high- and low-water marks with no order of their own, so a low price recorded first is a price that went up. It isnullwhenever any of the four figures is missing, because "we could not tell" is not "it was not cut" — and missing is the common case here.
summary — the same facts, shaped for a CRM field
A short set of at-a-glance values drawn from the chapters above: has_recorded_transactions,
last_recorded_sale_kind, last_recorded_buyer_type, flip_detected, long_tenured_owner,
permit_records_available, open_permit_work, demolition_recorded, market_state,
asking_price_reduced and recorded_loan_available.
Every member is a boolean, a fixed category or null — no free text and no sentence, so
these are safe to map straight onto a field or branch on in an agent chain.
Two rules make them trustworthy:
- The summary is computed from the chapters above it, not from a second reading of the underlying record. It is structurally incapable of disagreeing with the detail it summarises.
- A summary value whose chapter established nothing is
nullorunknown, neverfalse.falsemeans we checked, and no. When permit records could not be retrieved,open_permit_workanddemolition_recordedarenull— notfalse.
long_tenured_owner is true when days_owned reaches a configured threshold, and null when
ownership length could not be established at all.
Names never appear
The underlying county records contain buyer, seller and owner names, their mailing addresses, and recorder document numbers. None of it appears in the response.
Those columns are read in memory only, to reach a conclusion — the buyer was a company — and then discarded. What is published is the conclusion: a boolean, a count, a duration, a range, or a fixed category. No name, no mailing address and no document number reaches a response, an error body, a log line or an alert.
Errors
The error contract applies unchanged: the same rows, the same distinct type
values, the same Refund behaviour, and every response carries a Trace ID in the trace-id
header. Quote it to support and it identifies the call.
Two things specific to this endpoint:
property-not-found(422) — no property record could be found for the identifier on your request. It is decided after the Spend, so the Credit is Refunded. It is its owntype, distinct from "which one did you mean" and from "not that state yet".unscorable-addressis never returned by this endpoint. Nothing is being scored here, so that row would describe something that did not happen.
Idempotency
Send an Idempotency-Key and a retry can never charge you twice — the same seven promises the
idempotency contract makes, unchanged.
One clarification specific to this endpoint: the same key and the same body sent to
/v1/predict is a conflict, not a replay. The two endpoints answer different questions, and
a key is scoped to the request you made, not to the bytes you sent.
What this endpoint does not return yet
The response carries subject_property, records_statement, transactions, flips,
seller_position, permits, market_status and summary, and nothing else.
The rule that governed the six chapters above has not changed, only what it applies to:
a chapter that establishes nothing publishes what it could not establish, rather than a
zero. You will not find permits: [] standing alone here — the permits chapter carries
coverage_known and a plain sentence beside its list, and seller_position carries
recorded_loan_available beside its ownership figures. An empty array on its own would be a
claim; a stated absence is not.
Three things are deliberately still absent, and each is absent for a measured reason rather than because nobody got to it:
- No payoff figure and no equity figure, and no range of either — not as a number, not as
null, not as an empty object. See theseller_positionsection above. - No permit number and no permit reference id. A permit number plus the county it was issued in retrieves a public record naming the applicant, and this endpoint publishes no applicant. Withholding the name and handing over the lookup that retrieves it would not be withholding anything.
- No count of permits. The underlying record's own count is a page size rather than a total, so reporting it as "how many permits this property has" would invent a certainty.
When further chapters ship they will appear as new members, which is an additive change under the versioning policy.