# Investorlift Data Services API
> Complete documentation for Large Language Models
---
## Document: Quickstart
Create a key, check your setup for free and underwrite one property in five steps.
URL: /quickstart
# Quickstart
import { Card } from "zudoku/ui/Card";
import { SlidersHorizontal, List, CircleAlert, Bot } from "zudoku/icons";
import FullResponse from "../components/FullResponse";
import { GET_API_KEY_URL, TOP_UP_URL } from "../site/links";
Get your first underwriting answer and learn what its main fields mean.
1. **Get an API key**
Sign in to the dashboard and open Developers → Create key.
Creating a key needs a saved card. We check the card and don't charge it.
Each underwrite is charged its price from your balance; see [Per-call prices](/pricing/per-call-prices).
Checking a request with `/v1/validate` is free.
Your [balance](/balance) is in dollars and never expires.
2. **Save your key**
```bash title="Save your key"
export INVESTORLIFT_API_KEY=""
```
Every sample below reads your key from the environment.
JavaScript uses Node.js 20 or later; Python uses the `requests` package.
3. **Check your setup**
```bash title="cURL"
curl https://api.investorliftdata.com/v1/validate \
--request POST --max-time 30 \
--header "Authorization: Bearer $INVESTORLIFT_API_KEY" \
--header "Content-Type: application/json" \
--data '{"address": "1026 N Beckley Ave, Dallas, TX 75203"}'
```
```javascript title="JavaScript"
const response = await fetch("https://api.investorliftdata.com/v1/validate", {
method: "POST",
signal: AbortSignal.timeout(30_000),
headers: {
Authorization: `Bearer ${process.env.INVESTORLIFT_API_KEY}`,
"Content-Type": "application/json",
},
body: JSON.stringify({"address": "1026 N Beckley Ave, Dallas, TX 75203"}),
});
console.log(await response.json());
```
```python title="Python"
import os
import requests
response = requests.post(
"https://api.investorliftdata.com/v1/validate",
headers={
"Authorization": f"Bearer {os.environ['INVESTORLIFT_API_KEY']}",
"Content-Type": "application/json",
},
json={"address": "1026 N Beckley Ave, Dallas, TX 75203"},
timeout=30,
)
print(response.json())
```
```json title="Full response" operation="validate"
{
"valid": true
}
```
`401` means [fix the key](/api-keys); `200` means you are ready.
4. **Underwrite a property**
Send the sample address to `POST /v1/predict`.
This takes 5–10 seconds; set your client timeout to at least 30 seconds.
```bash title="cURL"
curl https://api.investorliftdata.com/v1/predict \
--request POST --max-time 30 \
--header "Authorization: Bearer $INVESTORLIFT_API_KEY" \
--header "Content-Type: application/json" \
--data '{"address": "1026 N Beckley Ave, Dallas, TX 75203"}'
```
```javascript title="JavaScript"
const response = await fetch("https://api.investorliftdata.com/v1/predict", {
method: "POST",
signal: AbortSignal.timeout(30_000),
headers: {
Authorization: `Bearer ${process.env.INVESTORLIFT_API_KEY}`,
"Content-Type": "application/json",
},
body: JSON.stringify({"address": "1026 N Beckley Ave, Dallas, TX 75203"}),
});
console.log(await response.json());
```
```python title="Python"
import os
import requests
response = requests.post(
"https://api.investorliftdata.com/v1/predict",
headers={
"Authorization": f"Bearer {os.environ['INVESTORLIFT_API_KEY']}",
"Content-Type": "application/json",
},
json={"address": "1026 N Beckley Ave, Dallas, TX 75203"},
timeout=30,
)
print(response.json())
```
```json title="Full response" operation="predict" {58,63,72,75,125}
{
"comps": [
{
"sqft": 2080,
"lotsize": 6500,
"bedrooms": 6,
"bathrooms": 2,
"sale_date": "2024-12-03",
"sale_price": 246641,
"year_built": 1945,
"address_zip": "75203",
"property_id": "00000000000000000000000000000001",
"address_city": "Dallas",
"address_state": "TX",
"property_type": "Single Family",
"address_street": "523 N Marsalis Ave",
"distance_miles": 0.4,
"similarity_rank": 1,
"price_per_square_foot": 119
},
{
"sqft": 1400,
"lotsize": 7025,
"bedrooms": 3,
"bathrooms": 1,
"sale_date": "2026-05-08",
"sale_price": 234999,
"year_built": 1930,
"address_zip": "75203",
"property_id": "00000000000000000000000000000001",
"address_city": "Dallas",
"address_state": "TX",
"property_type": "Single Family",
"address_street": "102 E 5th St",
"distance_miles": 0.09,
"similarity_rank": 2,
"price_per_square_foot": 168
},
{
"sqft": 1787,
"lotsize": 6678,
"bedrooms": 4,
"bathrooms": 2,
"sale_date": "2025-03-05",
"sale_price": 503139,
"year_built": 1945,
"address_zip": "75208",
"property_id": "00000000000000000000000000000001",
"address_city": "Dallas",
"address_state": "TX",
"property_type": "Single Family",
"address_street": "818 Elsbeth St",
"distance_miles": 0.24,
"similarity_rank": 3,
"price_per_square_foot": 282
}
],
"comp_band": {
"low": 388634,
"high": 942020,
"median": 571412
},
"cost_stack": {
"loan": 331200,
"points": 6624,
"interest": 6533,
"hold_days": 60,
"down_payment": 82800,
"closing_costs": 29562,
"target_profit": 114282
},
"rehab_budget": 0,
"target_margin": 0.2,
"valuation_mean": 1016099,
"subject_property": {
"facts": {
"bathrooms": {
"value": 2,
"source": "county-record"
},
"year_built": {
"value": 1935,
"source": "county-record"
},
"square_feet": {
"value": 2047,
"source": "county-record"
},
"property_type": {
"value": "Single Family",
"source": "county-record"
},
"lot_size_square_feet": {
"value": 7100,
"source": "county-record"
}
},
"address": {
"zip": "75203",
"city": "Dallas",
"state": "TX",
"street": "1026 N Beckley Ave"
},
"property_id": "00000000000000000000000000000001",
"address_quality": {
"code": "zip-confirmed",
"statement": "The house number matched exactly one county record, and that record sits in the same ZIP code your address resolved to. Check the address and property facts in this block before relying on these numbers."
},
"outside_model_range": {
"flagged": false,
"reasons": []
}
},
"as_is_similar_band": {
"basis": "all-comparable-sales",
"statement": "Too few comparable sales passed the size, age and recency filter this service applies to state a similar-sales range, so this price-per-square-foot range covers every comparable sale below that carries a usable sale price and square footage. `qualifying_sales` is how many passed the filter and fell short.",
"qualifying_sales": 1,
"price_per_square_foot": {
"low": 190,
"high": 460,
"median": 279
}
},
"rehab_adjusted_arv": 571000,
"max_allowable_offer": {
"amount": 414000,
"clamped": false
},
"valuation_quantiles": {
"0.02": 191787,
"0.10": 345336,
"0.20": 476815,
"0.30": 598093,
"0.40": 733771,
"0.50": 878872,
"0.60": 1110818,
"0.70": 1417051,
"0.80": 1988162,
"0.90": 3615373,
"0.98": 15505619
},
"effective_sample_size": 51.04950714111328,
"model_estimate_available": true,
"pricing_model_arv_estimate": 1417000
}
```
5. **Read the answer**
- `subject_property` identifies the house and its recorded facts.
- `comp_band` shows what comparable homes sold for.
- `rehab_budget` is the repair figure used.
- `max_allowable_offer` is the investor's ceiling; when `clamped: true`, no price works. Do not display the amount as an offer.
Some fields can be absent; [read the possible answers](/scoring/score-a-property).
That underwrite was charged to your balance; see it in the dashboard under Billing.
For `402`, [add funds](/balance).
For [ambiguous-address](/errors/ambiguous-address), add a ZIP code or choose a candidate.
## Next steps
---
## Document: Error catalogue
Find each error type, what it charges and the next action.
URL: /errors
# Error catalogue
Every error is an RFC 9457 Problem Detail sent as `application/problem+json`.
Its stable `type` address opens the matching page below and changes only through a documented deprecation.
Every response carries a `trace-id` header; errors repeat that identifier as `trace_id` in the body.
The 4 errors sharing status `503` have different outcomes for what is charged; [Handle errors](/production/handle-errors) explains the distinction.
The 5 errors sharing status `422` need different fixes; [Handle errors](/production/handle-errors) explains which action to take.
The 3 errors sharing status `429` limit how fast you send or how much you spend; [Handle errors](/production/handle-errors) explains both.
## Every error
| Status | Type | Title | What happened | What to do | Retryable | Charged |
| --- | --- | --- | --- | --- | --- | --- |
| `401` | [`invalid-key`](/errors/invalid-key) | Invalid API Key | This API key is not valid or has been revoked. Nothing charged. | Check the key in your request, or create a new one in Developers → Keys. | No | Nothing charged |
| `402` | [`insufficient-credits`](/errors/insufficient-credits) | Not enough balance | Your balance is not enough for this call. Nothing charged. | Add funds in Billing. | No | Nothing charged |
| `429` | [`rate-limited`](/errors/rate-limited) | Rate limit exceeded | Too many calls arrived in a short time. Nothing charged. | Wait for the time in the Retry-After header, then send the call again. | Yes | Nothing charged |
| `400` | [`invalid-request`](/errors/invalid-request) | Malformed request | The request does not match the documented form for this endpoint. Nothing charged. | Check the request against the API reference, correct it and send it again. | No | Nothing charged |
| `413` | [`payload-too-large`](/errors/payload-too-large) | Batch too large | This request carried more properties than this endpoint accepts. Nothing charged. | Split the batch into smaller parts, or remove any body field the API reference does not list. | No | Nothing charged |
| `503` | [`ledger-unreachable`](/errors/ledger-unreachable) | Ledger unreachable | Our balance records could not be reached, so the call never started. Nothing charged. | Wait for the time in the Retry-After header, then send the call again. | Yes | Nothing charged |
| `503` | [`spend-indeterminate`](/errors/spend-indeterminate) | Spend outcome indeterminate | The call did not finish and we cannot yet tell whether it was charged. Any charge recorded for it will be returned to your balance. | Wait for the time in the Retry-After header, then send the call again. | Yes | Returned to your balance if recorded |
| `504` | [`upstream-timeout`](/errors/upstream-timeout) | Upstream service timed out | A data service we rely on did not answer in time, so no result was produced. Returned to your balance. | Send the call again. | Yes | Returned to your balance |
| `502` | [`upstream-unavailable`](/errors/upstream-unavailable) | Upstream service unavailable | A data service we rely on could not complete this call, so no result was produced. Returned to your balance. | Send the call again. | Yes | Returned to your balance |
| `422` | [`ambiguous-address`](/errors/ambiguous-address) | Address is ambiguous | This address matches several properties. Returned to your balance. | Send the property's id from the candidate list, or add the unit number, city or ZIP. | No | Returned to your balance |
| `422` | [`address-out-of-coverage`](/errors/address-out-of-coverage) | Address outside current data coverage | This address is in a state we do not cover yet. Returned to your balance. | Nothing to change in the request; the docs list the states we cover. | No | Returned to your balance |
| `422` | [`unscorable-address`](/errors/unscorable-address) | Address cannot be scored | We could not underwrite this address. Returned to your balance. | Nothing to change in the request; try another address. | No | Returned to your balance |
| `422` | [`idempotency-key-conflict`](/errors/idempotency-key-conflict) | Idempotency-Key conflict | This Idempotency-Key was already used for a different request. Nothing charged. | Send the call again with a new Idempotency-Key. | No | Nothing charged |
| `409` | [`duplicate-request-in-flight`](/errors/duplicate-request-in-flight) | Duplicate request in flight | The same request with this Idempotency-Key is still running. Nothing charged for this copy. | Wait for the time in the Retry-After header, then ask for the result again with the same key. | Yes | Nothing charged |
| `404` | [`unknown-endpoint`](/errors/unknown-endpoint) | No such endpoint | No endpoint exists at this path. Nothing charged. | Check the path against the API reference. | No | Nothing charged |
| `503` | [`call-not-started`](/errors/call-not-started) | Call never started | This call never started. Nothing charged. | Wait for the time in the Retry-After header, then send the call again. | Yes | Nothing charged |
| `403` | [`terms-not-accepted`](/errors/terms-not-accepted) | Terms of Service not accepted | The Account's owner has not accepted the current Terms of Service, so the call did not start. Nothing charged. | Ask the Account's owner to accept the Terms in the app, then send the call again. | No | Nothing charged |
| `429` | [`spend-cap-exceeded`](/errors/spend-cap-exceeded) | Daily spending cap reached | This Account reached its daily spending cap, so the call did not start. Nothing charged. | Wait, or change the cap in Billing. | Yes | Nothing charged |
| `422` | [`property-not-found`](/errors/property-not-found) | Property not found | No property record matches this identifier. Returned to your balance. | Check the identifier, or look the property up again to get its current id. | No | Returned to your balance |
| `400` | [`upstream-rejected-request`](/errors/upstream-rejected-request) | Request rejected by an upstream data service | A data service we use did not accept part of this request, such as an identifier, place or option. Returned to your balance. | Correct the request against the API reference and send it again. | No | Returned to your balance |
| `503` | [`route-unavailable`](/errors/route-unavailable) | Endpoint temporarily switched off | This endpoint is switched off for now. Other endpoints still work, and nothing was charged. | Send the same call again later; the Retry-After header says when. | Yes | Nothing charged |
| `403` | [`desk-only`](/errors/desk-only) | This call needs a Desk | Buyer and lender data is sold only with a Desk. Your account buys with packs, which cover underwrites, property files, listings and summaries. Nothing charged. | To talk about a Desk, write to `support@investorliftdata.com`. | No | Nothing charged |
| `429` | [`free-call-limit`](/errors/free-call-limit) | Today's free calls are used up | Your account has made 500 free calls today, across the app, the API and assistants. Nothing charged. | Free calls start again at 00:00 UTC. Paid calls are not affected. | Yes | Nothing charged |
| `403` | [`free-calls-locked`](/errors/free-calls-locked) | Save a card to unlock free calls | Free calls on this account need a saved card. We check the card and don't charge it. Nothing charged. | Save a card in the app, then send the same request again. | No | Nothing charged |
The Charged column is a summary; each page states the full outcome.
For refunded calls, detail says whether the refund is applied or pending.
An applied refund appears in your account history.
Quote the `trace_id` to `support@investorliftdata.com`.
---
## Document: Definitions
Read the ten defined terms used by the API and Terms of Service.
URL: /definitions
# Definitions
Ten words are defined terms rather than ordinary English, and the Terms of Service
incorporates these ten definitions by reference. They mean the same thing on every page
here, in the Portal, and in that agreement. Other capitalised phrases in these pages —
Scored Call, for a Metered Call that returned a score — are ordinary shorthand built out of
these ten, and the agreement does not incorporate them.
## The ten defined terms
- **Account** — the single record that owns a Balance, its API Keys and its Ledger
history; every Metered Call is attributed to exactly one Account.
- **API Key** — the credential a request presents so the API can tell which Account the
call belongs to.
- **Balance** — the number of Credits available on an Account right now, read from the
Ledger every time it is shown and never cached.
- **Credit** — one unit of prepaid consumption against this API, bought in advance and
consumed by a Metered Call.
- **Ledger** — the authoritative record of every Credit movement on an Account, where each
Spend, each Refund and each Balance adjustment appears as its own line item.
- **Metered Call** — a call to a priced endpoint, which consumes a Credit;
`POST /v1/validate` is not one, because it costs nothing.
- **Refund** — a Credit returned to the Balance after a Metered Call ended in one of the
ways the [error reference](/errors) publishes as refunded; it is always a Ledger line
item you can see, and it never exceeds the Spend it reverses.
- **Spend** — the Ledger entry recording that a Metered Call consumed a Credit from the
Balance.
- **Tier** — the commercial band an Account is on; a Tier affects only the price paid per
Credit, never the cost of a call.
- **Trace ID** — the identifier every response carries in its `trace-id` header, success or
failure, and that every refusal repeats in the body as `trace_id`; it is the one handle to
quote when asking about a particular call.
These capitalised words keep their legal meaning here and in the Terms of Service; the guides use the ordinary words.
---
## Document: Changelog
Read changes to the API and developer docs.
URL: /changelog
# Changelog
This page lists changes to the API and developer docs; see the [versioning rules](/api#versioning).
## 2026-10-31
- Changed the [`403 free-calls-locked`](/errors/free-calls-locked) error's wording: free calls need a saved card. Its type, status and fields stay the same.
- Changed the [quickstart](/quickstart), [API keys](/api-keys) and [Your balance](/balance) pages: creating a key and making free calls need a saved card.
## 2026-10-28
- Added the [per-call price list](/pricing/per-call-prices): every call's price in dollars, its unit and who may buy it, generated from the price table the API charges by.
- Changed every call's cost line to its price in dollars, and renamed the page about your balance to [Your balance](/balance).
- Added `balance_cents`, `required_cents`, `cap_cents` and `consumed_cents` (whole cents) to the not-enough-balance and spending-cap errors. `balance`, `required`, `cap` and `consumed` keep their meaning in dollars and may now carry cents (for example `0.10`) ([Not enough balance](/errors/insufficient-credits)).
- Added the [`403 desk-only`](/errors/desk-only) error: a call sold only with a Desk, refused before anything is charged.
- Added the [`429 free-call-limit`](/errors/free-call-limit) error: an account's free calls for the day are used up.
- Added the [`403 free-calls-locked`](/errors/free-calls-locked) error: free calls wait until a card is saved.
- Added `cap_scope` to the [`429 spend-cap-exceeded`](/errors/spend-cap-exceeded) error: `account` for the account's daily spending cap, `connection` for one assistant connection's daily limit.
- Changed the MCP server to version 1.2.0: assistant tools ask you to confirm any single question that would cost more than $5.00, and accept an optional `confirmation` value on the repeat ([Connect an AI assistant](/agents/connect)).
## 2026-10-26
- Changed the MCP server to version 1.1.0: the existing single-property tools accept either an address or a property identifier, and the optional `partial_answer` flag on the same terms as the HTTP call ([Connect an AI assistant](/agents/connect)).
## 2026-10-25
- Changed the developer docs: a home page, three tabs, search, a five-step quickstart, an example answer on every call, and try-it with your own key ([Developer docs](/))
- Changed the name the MCP server reports to connected AI clients to Investorlift Data Services, with version 1.0.0 ([Connect an AI assistant](/agents/connect)).
- Added the API description at a stable address ([Download](/openapi.json))
- Added plain-text copies of every guide for AI tools ([Docs for AI tools](/agents/docs-for-ai))
## 2026-10-24
- Added identifier support: single predict and validate calls now accept exactly one of `address` or `property_id`. The existing MCP tools share this additive contract. Identifier picks use the normal price and refund rules; validate remains free. Batch requests are unchanged. Ambiguous-address candidates may include supplied, validated paired coordinates; no extra detail lookup is made for the list ([Connect an AI assistant](/agents/connect)).
- Added optional coordinates: single-property underwriting answers now optionally include paired numeric `latitude` and `longitude` in `subject_property` and each `comps` entry. These are the existing supplier coordinates; both are omitted if either is missing or invalid. Latitude is bounded to ±90 and longitude to ±180. Batch and History Call outputs and Credit prices are unchanged ([Valuation and comparable sales](/scoring/valuation-and-comps)).
- Added support for `POST /v1/predict` to accept an optional `partial_answer` member: for a property we found but the pricing model does not price, it returns the facts with a null `max_allowable_offer` and the reasons, charged as an answer. Existing calls are unchanged ([Accuracy and limits](/scoring/accuracy-and-limits)).
- Added the figures used to solve the offer: every `POST /v1/predict` answer's `cost_stack` now carries `assumptions`, the figures the maximum allowable offer was solved with. Existing integrations see no change ([The maximum allowable offer](/scoring/maximum-allowable-offer)).
## 2026-10-23
- Added the optional `X-Customer-Reference` request header on every charged call, so resellers can see usage by their own customer. Existing calls are unchanged.
## 2026-10-22
- Error responses' documentation gains a plain explanation and next step for every error type.
## 2026-10-16
- Added production access to the property and market data calls ([Property and market data](/data/property-and-market-data)).
## 2026-10-09
- Changed property identifiers; identifiers obtained before 9 October 2026 are no longer valid. Look up the address again to get a current identifier ([Property and market data](/data/property-and-market-data)).
## 2026-08-27
- Added recorded property transactions through the history call ([Property history](/data/property-history)).
- Added scoring for a list of properties in one call ([Score a list of properties](/scoring/score-a-list)).
- Changed the offer field's name to `max_allowable_offer` ([Score a property](/scoring/score-a-property)).
---
## Document: Your balance
How your balance works: dollars, packs, bonuses, refunds and free calls.
URL: /balance
# Your balance
import { Card } from "zudoku/ui/Card";
import { BookOpen, CircleAlert } from "zudoku/icons";
import { TOP_UP_URL } from "../site/links";
Every charged call is paid from your balance. This page says how the balance works and how an error affects it.
## Your balance is in dollars
Your balance is in US dollars, to the cent, and it never expires.
It stops at zero: a call your balance cannot cover is refused, never billed later.
Each call's price is on its reference page and in the [per-call price list](/pricing/per-call-prices).
There is no free balance and no sign-up grant: add funds with a pack before your first charged call.
## Add funds
Buy a pack on the dashboard's Billing page.
Each pack adds this much to your balance:
- $50 pack — $50 to spend
- $250 pack — $275 to spend
- $1,000 pack — $1,150 to spend
A [`402 insufficient-credits`](/errors/insufficient-credits) means your balance is too low for the call; add funds before trying again.
## Bonuses
Bonus balance is kept apart from paid balance.
Bonus is spent after your paid balance, and can't be refunded or used for Desk-only calls.
## Refunds
First pack: the unused part is refunded on request within 14 days of purchase, bonus removed. Every other purchase is final, except for our mistakes.
Nothing renews unless you turn on automatic top-up.
Money goes back only to the card that paid.
## Free calls
Free calls need a saved card and are capped at 500 a day per account, across the app, the API and assistants.
Free calls start again at 00:00 UTC. Paid calls are not affected.
## What an error does to your balance
Read the error's `type`, not just its HTTP status.
The [error catalogue](/errors) lists the outcome for every error.
### No charge
These errors charge nothing.
| Errors |
| --- |
| [`invalid-key`](/errors/invalid-key), [`insufficient-credits`](/errors/insufficient-credits), [`rate-limited`](/errors/rate-limited), [`invalid-request`](/errors/invalid-request), [`payload-too-large`](/errors/payload-too-large), [`ledger-unreachable`](/errors/ledger-unreachable), [`idempotency-key-conflict`](/errors/idempotency-key-conflict), [`duplicate-request-in-flight`](/errors/duplicate-request-in-flight), [`unknown-endpoint`](/errors/unknown-endpoint), [`call-not-started`](/errors/call-not-started), [`terms-not-accepted`](/errors/terms-not-accepted), [`spend-cap-exceeded`](/errors/spend-cap-exceeded), [`route-unavailable`](/errors/route-unavailable), [`desk-only`](/errors/desk-only), [`free-call-limit`](/errors/free-call-limit), [`free-calls-locked`](/errors/free-calls-locked) |
### Charged, then returned to your balance
The call was charged; `detail` says whether the return is applied or pending.
| Errors |
| --- |
| [`upstream-timeout`](/errors/upstream-timeout), [`upstream-unavailable`](/errors/upstream-unavailable), [`ambiguous-address`](/errors/ambiguous-address), [`address-out-of-coverage`](/errors/address-out-of-coverage), [`unscorable-address`](/errors/unscorable-address), [`property-not-found`](/errors/property-not-found), [`upstream-rejected-request`](/errors/upstream-rejected-request) |
### Charge outcome unknown
Whether the call was charged is unknown. Any charge recorded for the call is returned to your balance.
| Errors |
| --- |
| [`spend-indeterminate`](/errors/spend-indeterminate) |
## Next steps
---
## Document: API keys
Create, reveal and rotate a key, and understand when revocation takes effect.
URL: /api-keys
# API keys
import { Card } from "zudoku/ui/Card";
import { BookOpen, Coins } from "zudoku/icons";
import { GET_API_KEY_URL } from "../site/links";
Use an [API key](/definitions), your account credential, to authenticate calls.
## Get and manage your key
Open Developers → Keys to create or revoke a key.
Creating a key needs a saved card. Rotating or revoking a key does not.
Developers → Keys shows your current key’s masked prefix; press **Reveal key** to view the full key again. It hides after 60 seconds or when you leave.
A missing or invalid key receives [`401 invalid-key`](/errors/invalid-key), and nothing is charged.
Your [balance](/balance) is the money in dollars available to spend now.
## Distinguish keys from your balance
A valid API Key with an empty Balance still authenticates. The request is then refused
as [`402 insufficient-credits`](/errors/insufficient-credits) — a different error,
with a different fix. `401` means fix the key; `402` means add funds.
`POST /v1/validate` needs a valid key and works at zero Balance without spending.
## Rotate or revoke a key
Press **Rotate key** in Developers → Keys yourself; the replacement appears in full.
Rotation leaves your Balance and Tier unchanged and charges nothing.
A revoked API Key can continue to authenticate for up to **~60 seconds** after
revocation — the gateway holds a short-lived key cache — and within a minute it stops
everywhere. If you are rotating credentials on a schedule, treat that minute as part
of the rotation window: issue the new key, move traffic, then revoke the old one.
:::warning[If calls must stop now]
Ask us to **zero your Balance**: contact `support@investorliftdata.com`.
A zero Balance refuses charged calls on the request itself, independently of key-cache propagation.
Your history stays intact, and you can add funds again afterwards.
Revoke the key as well: zeroing stops spending; revocation retires the credential.
:::
## Submitted addresses
Submitted address data is never sold, never shared between customers, and never used to build or market a competing acquisition list. [Terms of Service](https://investorliftdata.com/terms).
## Next steps
---
## Document: Use your own repair budget and margin
Set repair costs, profit margin and supported facts without changing the property being underwritten.
URL: /scoring/your-own-numbers
# Use your own repair budget and margin
import { Card } from "zudoku/ui/Card";
import { Code2, Calculator, ChartNoAxesCombined } from "zudoku/icons";
Set your repair budget, target margin and supported property facts to price the house as you describe it.
## Set the repair budget and margin
These inputs use the existing sample address:
| Input | Example value |
| --- | --- |
| `address` | 1026 N Beckley Ave, Dallas, TX 75203 |
| `rehab_budget` | `48000` whole dollars |
| `target_margin` | `0.25`, or 25% |
`rehab_budget` accepts whole dollars from 0 to 10,000,000, inclusive.
Use `48000`, not `48000.50`.
It replaces the repair bracket selected by `condition`, even when you send both.
A zero budget prices the property as `turn_key` does.
Omitting both budget and condition also means no repairs.
Without your budget, the response uses the whole-project midpoint for the chosen condition.
`condition: tear_down` still receives `400`, even with a repair budget.
The service has no land-value estimate; analyze lot value separately.
Repair brackets are screening estimates, not contractor bids.
`target_margin` accepts a fraction from `0.01` to `0.9`, inclusive; its default is `0.2`.
For example, `0.25` means 25% of the repaired value.
The margin is a policy assumption chosen by judgment, not a fitted parameter.
The response's `rehab_budget` is the amount the arithmetic used.
The response's `target_margin` is the applied fraction, including when you accepted the default.
It appears on priced answers and is absent when `comp_band` is absent.
Changing the repair budget can change both the repaired value and holding costs.
Target profit uses the unrounded repaired value; published amounts may differ through rounding.
The default captured answer does not show the result of these custom inputs.
## Replace supported property facts
Send `overrides` to replace any of these six facts for the model, comparable-sales band and derived price.
Leaving it out keeps the county facts.
| Input | Allowed value |
| --- | --- |
| `overrides.square_feet` | Finished living area in square feet; a number above zero. |
| `overrides.bedrooms` | Bedroom count; a number at least zero, including studios. |
| `overrides.bathrooms` | Bathroom count; a number at least zero, which may be fractional, such as `2.5`. |
| `overrides.year_built` | Year built; a number above zero. |
| `overrides.lot_size_square_feet` | Lot size in square feet, never acres; a number above zero. |
| `overrides.property_type` | Nonblank text; surrounding spaces are trimmed. |
Each fact in `subject_property.facts` labels its source.
`caller-stated` means you supplied the value; `county-record` means the stored county value.
Your value reads `caller-stated` even when it equals the county record: the label tracks its source, not a change.
Overrides cannot change the property's identity, address or coordinates.
You cannot override `latitude`, `longitude`, or `partial_bathrooms`.
The resolved `subject_property.property_id` and `subject_property.address` still identify the matched house.
`subject_property.facts.partial_bathrooms` stays at the county figure and cannot be overridden.
Changing bathrooms does not change partial bathrooms, and this service does not add the two together.
Partial does not necessarily mean a half bath.
## Handle rejected values and missing facts
Unknown override keys, wrong types, `null`, invalid values and an empty overrides object receive `400` before charging.
Overrides can state facts, never erase them.
The [free request check](/production/test-without-spending) applies the same rules.
Top-level misspellings follow a different rule: they can count as extra properties and produce `413`.
Keep request options in their documented positions.
The valuation still needs coordinates, lot size and property type after overrides are applied.
If a required fact remains missing, the call is refused and its charge is refunded.
Supplying the missing lot size or property type can make the property possible to underwrite, with that fact labelled `caller-stated`.
Use [Score a property](/scoring/score-a-property) for the runnable default request and its captured answer.
The [reference](/api/scoring#score-a-property-address) gives the detailed input rules.
## Next steps
---
## Document: Valuation and comparable sales
Separate the model estimate from recorded sales and check the property facts behind both.
URL: /scoring/valuation-and-comps
# Valuation and comparable sales
import { Card } from "zudoku/ui/Card";
import { Code2, Calculator, CircleAlert } from "zudoku/icons";
Read the model estimate, recorded sales and property facts to understand what supports a valuation.
## Separate the two sources
`pricing_model_arv_estimate` is the model's after-repair-value estimate, rounded to the nearest thousand dollars.
It comes from the model's own value distribution and does not move when comparable sales are added or removed.
`comp_band` comes from recorded comparable sales, size-normalised to the subject property, the house being underwritten, where square footage allows.
Its low, median and high values are whole-dollar totals.
It moves with the sales and is absent when too few carry usable prices.
`rehab_adjusted_arv` lifts the band's median toward its high value as the repair budget increases.
Before rounding, it equals the median without a repair signal and never exceeds the high value.
The published value rounds to the nearest thousand dollars, so it can differ from that median or exceed that high.
Its thresholds are policy assumptions, and it is absent without the band.
## Read withheld model estimates
`model_estimate_available` and `subject_property` are always present on a successful underwriting answer.
When the flag is `false`, the model estimate disagreed with comparable sales and was withheld.
The sales-derived band and comparable sales are unaffected.
This remains a charged successful `200`, meaning the call is charged to your balance.
It still carries a valuation from sales.
`valuation_quantiles` carries the model's predicted value distribution.
`valuation_mean` is an optional point estimate and can be null.
`effective_sample_size` measures how much comparable evidence supported the model estimate; it can be fractional.
Those three members and `pricing_model_arv_estimate` are absent when the model estimate is withheld.
A low effective sample size means thin model evidence.
It is different from the number of sales passing the similar-sales filter below.
## Check the resolved property
`subject_property.property_id` identifies the house that was underwritten.
`subject_property.address` contains the county record's address, not an echo of submitted text.
The stored city is published unchanged, without re-casing.
Missing address components are omitted; the whole address is absent when none exists.
The identifier remains present.
Check that this block describes the house you meant.
The seven possible facts are:
| Member | Meaning |
| --- | --- |
| `subject_property.facts.square_feet` | Finished living area in square feet. |
| `subject_property.facts.bedrooms` | Bedroom count. |
| `subject_property.facts.bathrooms` | Bathroom count as the property data service calculates it, published as stored; it can be fractional. |
| `subject_property.facts.partial_bathrooms` | Separately stored partial-bathroom count; partial does not necessarily mean a half bath. |
| `subject_property.facts.year_built` | Year the structure was built. |
| `subject_property.facts.lot_size_square_feet` | Lot size in square feet, never acres. |
| `subject_property.facts.property_type` | Property type in the record's wording, or your supplied text. |
**Bathrooms and partial bathrooms are separate figures; this service does not add the two together.**
It makes no claim about what the calculated bathroom count already includes.
A bathroom count of `2.5` and a partial count of `1` must not be read as a total of `3.5`.
Each fact contains its value and source.
The source has exactly two values: `county-record` means the stored county value; `caller-stated` means a value you supplied through `overrides`.
A supplied value reads `caller-stated` even when unchanged; partial bathrooms always read `county-record`.
Missing usable facts are omitted, not replaced with null, zero or an empty string.
The valuation ran without those facts.
Recorded zero square feet is unusable living area and is omitted; genuine zero bedroom or bathroom counts remain valid.
## Check the address and model range
`subject_property.address_quality.code` reports address checks.
It is not a confidence score and not a probability.
Both codes mean the house number matched exactly one complete county record.
| Code | Meaning |
| --- | --- |
| `zip-confirmed` | The resolved address has a readable ZIP code matching the county record. This does not establish that the record is correct or current. |
| `zip-unconfirmed` | The resolved address has no readable ZIP, or the county record has a different ZIP or none at all. |
The compared ZIP is the resolved one, which need not be one you typed.
Branch on the code; the statement explains it for a person.
`subject_property.outside_model_range` is always present.
Its flag is a caution about the estimate, not a claim about the house.
Its reasons list is empty when unflagged.
| Reason | Meaning |
| --- | --- |
| `large-lot` | Lot size exceeds the model's calibrated range. |
| `large-structure` | Living area exceeds the model's calibrated range. |
| `property-type` | The effective type is poorly covered, such as multifamily, commercial, mixed-use or industrial. |
Multiple reasons can apply; missing facts cannot raise their own reason.
The checks use effective facts, including overrides.
Size thresholds are policy figures chosen by judgment and intended to be tuned.
## Read the per-square-foot evidence
`as_is_similar_band` states dollars per square foot, not the total dollars in `comp_band`.
It feeds nothing: no other response number is computed from it.
It can remain present when the model estimate is withheld.
The current similar-sales filter requires all three conditions:
| Filter | Current setting |
| --- | --- |
| Size | Within ±15% of the subject's living area, inclusive. |
| Age | Within ±20 years of the subject's build year, inclusive. |
| Recency | Recorded within 365 days before the request. |
The recency window is this service's chosen setting.
Missing or unreadable sale dates do not qualify; they are treated as neither recent nor old.
Without usable subject size or build year, no sale qualifies and the range falls back.
With fewer than three qualifying sales, `as_is_similar_band.basis` changes from `similar-sales` to `all-comparable-sales`.
The filters are dropped; the range covers every comparable with a usable sale price and square footage.
Thin filters are ordinary, and the fallback remains dollars per square foot.
`as_is_similar_band.qualifying_sales` still counts sales passing the filter, never the size of the comparable list.
Branch on `as_is_similar_band.basis`, not the human wording in `as_is_similar_band.statement`.
With no usable price-and-size pair, the entire block is absent; no member inside it is null.
## Read individual comparable sales
`comps` carries recorded sales and is absent when none were returned.
The subject's own prior sale is removed before calculations.
Entries carry available address, date, price, size, beds, baths and build year.
| Member | Meaning |
| --- | --- |
| `comps[].property_id` | Identifier accepted by [property history](/data/property-history), which reports buyer kind, never a name. Single-property underwriting accepts an address or property identifier. |
| `comps[].similarity_rank` | Position in the returned list, starting at 1 without gaps after removal and capping. |
| `comps[].price_per_square_foot` | Sale price divided by living area, rounded once. |
| `comps[].distance_miles` | Straight-line distance from the subject, in miles to two decimals. |
| `comps[].lotsize` | Recorded lot size; its unit has not been established. Compare within the answer without assuming a unit. |
| `comps[].property_type` | Recorded type, unchanged. |
Rank is a position, not a similarity score or percentage; rank 1 does not claim the most comparable sale.
The ordering method is not published.
Missing coordinates omit distance; missing usable price or size omits price per square foot.
Nominal transfer prices omit sale price and its ratio, contributing to neither the range nor qualifying count.
Missing facts are not filled with zeros, nulls or unknown text; internal similarity scores are not published.
### Saved locations
The single-property answer optionally carries paired numeric `subject_property.latitude` and `subject_property.longitude`.
Each comparable can carry `comps[].latitude` and `comps[].longitude`. These use the supplier-resolved locations already used in underwriting, with latitude in [-90, 90] and longitude in [-180, 180].
Both coordinates are omitted when either is missing or invalid; zero is a valid coordinate.
Older saved answers may lack these fields. Batch and History Call outputs are unchanged.
## Next steps
---
## Document: Underwrite a property
Send one address and read the valuation, repair budget and maximum allowable offer.
URL: /scoring/score-a-property
# Underwrite a property
import { Card } from "zudoku/ui/Card";
import { Calculator, ChartNoAxesCombined, SlidersHorizontal, List } from "zudoku/icons";
Send one address to get its valuation, maximum allowable offer and supporting sales.
## Send the address
Save your [API key](/definitions), the credential that identifies you, in `INVESTORLIFT_API_KEY`.
Your [account](/definitions) is the record that owns your keys and the money you add.
The [API keys guide](/api-keys) explains how to create one.
Send the property's full street address to `POST /v1/predict`.
Set your client timeout to at least 30 seconds.
These examples use the default options.
```bash title="cURL"
curl https://api.investorliftdata.com/v1/predict \
--request POST --max-time 30 \
--header "Authorization: Bearer $INVESTORLIFT_API_KEY" \
--header "Content-Type: application/json" \
--data '{"address": "1026 N Beckley Ave, Dallas, TX 75203"}'
```
```javascript title="JavaScript"
const response = await fetch("https://api.investorliftdata.com/v1/predict", {
method: "POST",
signal: AbortSignal.timeout(30_000),
headers: {
Authorization: `Bearer ${process.env.INVESTORLIFT_API_KEY}`,
"Content-Type": "application/json",
},
body: JSON.stringify({"address": "1026 N Beckley Ave, Dallas, TX 75203"}),
});
console.log(await response.json());
```
```python title="Python"
import os
import requests
response = requests.post(
"https://api.investorliftdata.com/v1/predict",
headers={
"Authorization": f"Bearer {os.environ['INVESTORLIFT_API_KEY']}",
"Content-Type": "application/json",
},
json={"address": "1026 N Beckley Ave, Dallas, TX 75203"},
timeout=30,
)
print(response.json())
```
## Choose optional inputs
| Option | What it changes |
| --- | --- |
| `condition` | Sets a repair bracket; omitted means `turn_key`, with no repairs. Tear-downs are refused. |
| `rehab_budget` | Supplies your repair budget in whole dollars instead of the condition bracket. |
| `target_margin` | Sets the profit margin as a fraction of the repaired value. |
| `overrides` | Supplies six supported property facts in place of the county record. |
[Use your own numbers](/scoring/your-own-numbers) explains limits, precedence and factual overrides.
The [reference](/api/scoring#score-a-property-address) lists the accepted inputs.
## Read the answer
Show the full response (145 lines)
```json title="Full response" operation="predict"
{
"comps": [
{
"sqft": 2080,
"lotsize": 6500,
"bedrooms": 6,
"bathrooms": 2,
"sale_date": "2024-12-03",
"sale_price": 246641,
"year_built": 1945,
"address_zip": "75203",
"property_id": "00000000000000000000000000000001",
"address_city": "Dallas",
"address_state": "TX",
"property_type": "Single Family",
"address_street": "523 N Marsalis Ave",
"distance_miles": 0.4,
"similarity_rank": 1,
"price_per_square_foot": 119
},
{
"sqft": 1400,
"lotsize": 7025,
"bedrooms": 3,
"bathrooms": 1,
"sale_date": "2026-05-08",
"sale_price": 234999,
"year_built": 1930,
"address_zip": "75203",
"property_id": "00000000000000000000000000000001",
"address_city": "Dallas",
"address_state": "TX",
"property_type": "Single Family",
"address_street": "102 E 5th St",
"distance_miles": 0.09,
"similarity_rank": 2,
"price_per_square_foot": 168
},
{
"sqft": 1787,
"lotsize": 6678,
"bedrooms": 4,
"bathrooms": 2,
"sale_date": "2025-03-05",
"sale_price": 503139,
"year_built": 1945,
"address_zip": "75208",
"property_id": "00000000000000000000000000000001",
"address_city": "Dallas",
"address_state": "TX",
"property_type": "Single Family",
"address_street": "818 Elsbeth St",
"distance_miles": 0.24,
"similarity_rank": 3,
"price_per_square_foot": 282
}
],
"comp_band": {
"low": 388634,
"high": 942020,
"median": 571412
},
"cost_stack": {
"loan": 331200,
"points": 6624,
"interest": 6533,
"hold_days": 60,
"down_payment": 82800,
"closing_costs": 29562,
"target_profit": 114282
},
"rehab_budget": 0,
"target_margin": 0.2,
"valuation_mean": 1016099,
"subject_property": {
"facts": {
"bathrooms": {
"value": 2,
"source": "county-record"
},
"year_built": {
"value": 1935,
"source": "county-record"
},
"square_feet": {
"value": 2047,
"source": "county-record"
},
"property_type": {
"value": "Single Family",
"source": "county-record"
},
"lot_size_square_feet": {
"value": 7100,
"source": "county-record"
}
},
"address": {
"zip": "75203",
"city": "Dallas",
"state": "TX",
"street": "1026 N Beckley Ave"
},
"property_id": "00000000000000000000000000000001",
"address_quality": {
"code": "zip-confirmed",
"statement": "The house number matched exactly one county record, and that record sits in the same ZIP code your address resolved to. Check the address and property facts in this block before relying on these numbers."
},
"outside_model_range": {
"flagged": false,
"reasons": []
}
},
"as_is_similar_band": {
"basis": "all-comparable-sales",
"statement": "Too few comparable sales passed the size, age and recency filter this service applies to state a similar-sales range, so this price-per-square-foot range covers every comparable sale below that carries a usable sale price and square footage. `qualifying_sales` is how many passed the filter and fell short.",
"qualifying_sales": 1,
"price_per_square_foot": {
"low": 190,
"high": 460,
"median": 279
}
},
"rehab_adjusted_arv": 571000,
"max_allowable_offer": {
"amount": 414000,
"clamped": false
},
"valuation_quantiles": {
"0.02": 191787,
"0.10": 345336,
"0.20": 476815,
"0.30": 598093,
"0.40": 733771,
"0.50": 878872,
"0.60": 1110818,
"0.70": 1417051,
"0.80": 1988162,
"0.90": 3615373,
"0.98": 15505619
},
"effective_sample_size": 51.04950714111328,
"model_estimate_available": true,
"pricing_model_arv_estimate": 1417000
}
```
Read these five headline members in order:
| Member | What to read |
| --- | --- |
| `subject_property` | Check the resolved house, its facts and their sources. |
| `comp_band` | Read the value range implied by recorded comparable sales. |
| `rehab_budget` | Check the repair budget actually used. |
| `max_allowable_offer` | Read the investor's purchase ceiling alongside `max_allowable_offer.clamped`. |
| `cost_stack` | Read the costs and target profit behind that ceiling. |
When `max_allowable_offer.clamped` is `true`, no purchase price clears the target margin.
Do not show the amount as an offer.
Successful answers always include `subject_property` and `model_estimate_available`.
Other members can be absent; the five headline members are not all required.
Without `comp_band`, the repaired value, target margin, maximum allowable offer and cost stack are absent.
The captured answer above uses only the address, with a zero repair budget and the default margin.
Its three displayed comparable sales are a retained sample; their count does not reconstruct every aggregate.
## Next steps
---
## Document: Underwrite a list of properties
Underwrite several addresses and handle each result, refund and whole-batch refusal.
URL: /scoring/score-a-list
# Underwrite a list of properties
import { Card } from "zudoku/ui/Card";
import { Code2, RotateCcw, CircleAlert } from "zudoku/icons";
Send a list of addresses to get one result per property in submission order.
:::warning
Batch answers carry the core numbers only, with no `comps` list.
Use [Score a property](/scoring/score-a-property) by address or property identifier to get those sales.
:::
## Send the list
Each item in `properties` accepts the single-call inputs.
The envelope accepts only that array; options belong inside each item.
Duplicate addresses are allowed, and each is charged.
Save your [API key](/definitions), the credential identifying you, in `INVESTORLIFT_API_KEY`.
Your [account](/definitions) is the record owning your keys and the money you add.
Use a client timeout of at least 30 seconds.
The second address is a placeholder; the captured answer shows its real failure.
```bash title="cURL"
curl https://api.investorliftdata.com/v1/predict/batch \
--request POST --max-time 30 \
--header "Authorization: Bearer $INVESTORLIFT_API_KEY" \
--header "Content-Type: application/json" \
--data '{"properties": [{"address": "1026 N Beckley Ave, Dallas, TX 75203"}, {"address": "100 Sample Street, Dallas, TX 75203", "condition": "major_rehab"}]}'
```
```javascript title="JavaScript"
const response = await fetch("https://api.investorliftdata.com/v1/predict/batch", {
method: "POST",
signal: AbortSignal.timeout(30_000),
headers: {
Authorization: `Bearer ${process.env.INVESTORLIFT_API_KEY}`,
"Content-Type": "application/json",
},
body: JSON.stringify({"properties": [{"address": "1026 N Beckley Ave, Dallas, TX 75203"}, {"address": "100 Sample Street, Dallas, TX 75203", "condition": "major_rehab"}]}),
});
console.log(await response.json());
```
```python title="Python"
import os
import requests
response = requests.post(
"https://api.investorliftdata.com/v1/predict/batch",
headers={
"Authorization": f"Bearer {os.environ['INVESTORLIFT_API_KEY']}",
"Content-Type": "application/json",
},
json={"properties": [{"address": "1026 N Beckley Ave, Dallas, TX 75203"}, {"address": "100 Sample Street, Dallas, TX 75203", "condition": "major_rehab"}]},
timeout=30,
)
print(response.json())
```
## Read each result
Show the full response (100 lines)
```json title="Full response" operation="predict-batch"
{
"results": [
{
"comp_band": {
"low": 388634,
"high": 942020,
"median": 571412
},
"cost_stack": {
"loan": 331200,
"points": 6624,
"interest": 6533,
"hold_days": 60,
"down_payment": 82800,
"closing_costs": 29562,
"target_profit": 114282
},
"rehab_budget": 0,
"target_margin": 0.2,
"valuation_mean": 1016099,
"subject_property": {
"facts": {
"bathrooms": {
"value": 2,
"source": "county-record"
},
"year_built": {
"value": 1935,
"source": "county-record"
},
"square_feet": {
"value": 2047,
"source": "county-record"
},
"property_type": {
"value": "Single Family",
"source": "county-record"
},
"lot_size_square_feet": {
"value": 7100,
"source": "county-record"
}
},
"address": {
"zip": "75203",
"city": "Dallas",
"state": "TX",
"street": "1026 N Beckley Ave"
},
"property_id": "00000000000000000000000000000001",
"address_quality": {
"code": "zip-confirmed",
"statement": "The house number matched exactly one county record, and that record sits in the same ZIP code your address resolved to. Check the address and property facts in this block before relying on these numbers."
},
"outside_model_range": {
"flagged": false,
"reasons": []
}
},
"as_is_similar_band": {
"basis": "all-comparable-sales",
"statement": "Too few comparable sales passed the size, age and recency filter this service applies to state a similar-sales range, so this price-per-square-foot range covers every comparable sale below that carries a usable sale price and square footage. `qualifying_sales` is how many passed the filter and fell short.",
"qualifying_sales": 1,
"price_per_square_foot": {
"low": 190,
"high": 460,
"median": 279
}
},
"rehab_adjusted_arv": 571000,
"max_allowable_offer": {
"amount": 414000,
"clamped": false
},
"valuation_quantiles": {
"0.02": 191787,
"0.10": 345336,
"0.20": 476815,
"0.30": 598093,
"0.40": 733771,
"0.50": 878872,
"0.60": 1110818,
"0.70": 1417051,
"0.80": 1988162,
"0.90": 3615373,
"0.98": 15505619
},
"effective_sample_size": 51.04950714111328,
"model_estimate_available": true,
"pricing_model_arv_estimate": 1417000
},
{
"type": "https://docs.investorliftdata.com/errors/unscorable-address",
"title": "Address cannot be scored",
"detail": "The submitted address could not be scored. The Credit for this call has been returned to the Account as a Refund.",
"status": 422,
"trace_id": "00000000-0000-4000-8000-000000000000"
}
]
}
```
`results` preserves submission order, with none omitted or reordered.
Each position answers the input at that position.
Only error entries carry `results[].status` and `results[].type`; branch on the type to choose a fix.
An underwritten entry uses the single-call member names and meanings, including `max_allowable_offer`.
When `max_allowable_offer.clamped` is `true`, no price clears the target margin: do not show the amount as an offer.
Conditional members can still be absent.
Read [Maximum allowable offer](/scoring/maximum-allowable-offer) before using the ceiling in your workflow.
`effective_sample_size` measures evidence supporting the model, and is absent when that estimate is withheld.
`as_is_similar_band.qualifying_sales` counts sales passing its filter, including when the range falls back to all comparable sales.
These are different counts.
Keep `subject_property.property_id` for [property history](/data/property-history); use an address when underwriting again.
Read the call's [Trace ID](/definitions), its call identifier, from the `trace-id` response header when contacting support.
Every entry shares the call's identifier; the captured error also includes `results[].trace_id`.
## Understand charges and refunds
Each submitted property is charged the batch call's price from your [balance](/balance); see [Per-call prices](/pricing/per-call-prices).
A covered property failure returns that property's charge to your balance as a [refund](/definitions).
The whole batch is charged before any property is underwritten.
If your [balance](/balance), the money available on your account, cannot cover it, the entire batch is rejected without charging or splitting it.
After adding funds, you can retry unchanged; batches are never partially accepted.
The [reference](/api/scoring#score-a-batch-of-property-addresses) publishes the batch limit as maxItems.
Exceeding it returns `413` before charging, with limit and submitted counts.
Covered failures use the single call's error body and refund reasons.
A covered failure leaves an error entry, refunds its share and still attempts later properties.
The response remains `200`.
Covered reasons are an ambiguous address, an address outside coverage, an unscorable address, a service failure or a service timeout; see [errors](/errors).
An uncovered refusal stops processing: later properties are not attempted, no `results` array returns, and the request answers `503` [spend-indeterminate](/errors/spend-indeterminate).
Every recorded charge is returned to your balance, including already underwritten properties whose answers are not returned.
The request is retryable; you pay only for properties that produced an answer.
## Retry safely
Send `Idempotency-Key` and follow [Retry safely](/production/retry-safely).
Using one key for the same body on the single and batch endpoints is a conflict, never a replay.
## Next steps
---
## Document: The maximum allowable offer
Read the investor ceiling, the clamped flag and the costs behind the price.
URL: /scoring/maximum-allowable-offer
# The maximum allowable offer
import { Card } from "zudoku/ui/Card";
import { Code2, SlidersHorizontal, CircleAlert } from "zudoku/icons";
Read the maximum allowable offer and its costs to decide whether the returned amount is usable.
**This is the investor's ceiling — your offer to the seller is this minus your fee.**
## Read the ceiling and its flag
`max_allowable_offer` is the highest purchase price at which a cash flip buyer on standard financing clears the target margin.
It reverses a flip profit calculation; it does not predict what the property will trade at.
`max_allowable_offer.amount` is in whole dollars, rounded to the nearest thousand.
`max_allowable_offer.clamped` is `true` when no purchase price clears the target margin at this repair budget.
When it is `true`, the amount is **not an offer** and must not be presented as one.
The flag uses the raw calculation before rounding.
A small negative result therefore stays clamped instead of becoming a usable zero-dollar offer.
Read the flag before acting on the amount.
Both members are present whenever the offer is a priced object.
On a priced answer, the object is absent whenever `comp_band`, the range implied by comparable sales, is absent.
A partial answer instead carries `max_allowable_offer: null`.
## Read the cost stack
`cost_stack` accompanies a priced `max_allowable_offer` object.
A partial answer has a null offer and no cost stack.
Its amounts are whole dollars; its holding period is days.
| Member | Meaning |
| --- | --- |
| `cost_stack.down_payment` | Cash the buyer puts into the purchase. |
| `cost_stack.loan` | Borrowed amount: the maximum allowable offer less the down payment. |
| `cost_stack.interest` | Interest on the loan over the holding period. |
| `cost_stack.points` | Lending points charged on the loan. |
| `cost_stack.hold_days` | Holding period; a larger repair budget means a longer hold. |
| `cost_stack.closing_costs` | Costs charged on both purchase and resale. |
| `cost_stack.target_profit` | Target margin multiplied by the unrounded repaired value. |
The response's `target_margin` states the fraction used.
Multiplying the published `rehab_adjusted_arv` by it can differ from `cost_stack.target_profit` because the published repaired value is rounded.
The offer uses the unrounded value.
## Check the arithmetic
**The down payment plus the loan equals the maximum allowable offer exactly.**
That identity holds even when the offer is clamped.
When `max_allowable_offer.clamped` is `false`, add the down payment, loan, repair budget, interest, points, closing costs and target profit.
They reconcile to `rehab_adjusted_arv`, subject to rounding.
This whole-stack comparison is different from the exact down-payment-plus-loan identity.
The offer and repaired value round to the nearest thousand; other amounts round to whole dollars.
The reconciliation gap stays a little over a thousand dollars across the service's repair brackets.
Very large repair budgets can increase it because longer holds put more financing cost on each rounded dollar.
When the flag is `true`, the published price is a floor, not a solved price.
The stack still describes that price, but it does not reconcile to the repaired value.
The difference can be large.
## Understand the assumptions
Down payment, interest, points, closing costs, holding period and margin are policy figures chosen by judgment and intended to be tuned.
They are not fitted to a data set or quoted by a lender.
[Accuracy and limits](/scoring/accuracy-and-limits) separates checked arithmetic from market accuracy.
**`cost_stack.assumptions` — the figures the offer was solved with.**
On `POST /v1/predict`,
`cost_stack.assumptions` carries the figures used to compute the offer: `cost_stack.assumptions.down_fraction`, `cost_stack.assumptions.annual_interest_rate`, `cost_stack.assumptions.days_per_year`,
`cost_stack.assumptions.points_fraction`, `cost_stack.assumptions.closing_fraction`, `cost_stack.assumptions.closing_applies_to_purchase`, `cost_stack.assumptions.carry`,
`cost_stack.assumptions.hold_base_days`, `cost_stack.assumptions.hold_days_per_thousand_rehab`, `cost_stack.assumptions.rehab_lift_low_share`,
`cost_stack.assumptions.rehab_lift_high_share`, `cost_stack.assumptions.offer_rounding_increment` and `cost_stack.assumptions.arv_rounding_increment`.
The last two are separate publication increments; the cost stack uses the unrounded repaired value.
With those and `comp_band`, you can
recompute the offer for a different repair budget or margin yourself.
Batch answers do not
carry it.
## Next steps
---
## Document: Accuracy and limits
Separate verified arithmetic from unverified market accuracy and recognize thin evidence.
URL: /scoring/accuracy-and-limits
# Accuracy and limits
import { Card } from "zudoku/ui/Card";
import { ChartNoAxesCombined, Calculator, SlidersHorizontal } from "zudoku/icons";
Separate checked arithmetic from market accuracy before relying on an answer.
## What is verified
The derived arithmetic is internally consistent and deterministic: the same response produces the same derived numbers.
The maximum allowable offer reverses a flip profit calculation, and its unrounded round-trip holds to the cent.
Published amounts still follow their documented rounding rules.
The check that withholds a model estimate disagreeing with comparable sales is tested.
That path sets `model_estimate_available` to `false`.
Checking this behavior does not validate market accuracy.
## What is not verified
**There is no published back-test of predicted price against realized sale price.**
That applies to both the model estimate and the derived maximum allowable offer.
Plan your own back-test if accuracy matters to your use case.
Target margin and financing assumptions are policy figures chosen by judgment and intended to be tuned.
They are not fitted to a data set or quoted by a lender.
## Caveats to check
- **Residential only.** Large multifamily and commercial properties fall outside the model's coverage and can diverge instead of producing an error.
- **Tear-downs are unsupported.** There is no land-value estimate; analyze lot value separately.
- **Repair estimates are screening ranges, not bids.** Small homes skew high per square foot; heavy-rehab figures are the least reliable.
- **Thin model evidence needs caution.** Show `effective_sample_size` when available; it measures comparable evidence supporting the model, not sales passing a filter.
- **Missing evidence matters.** Comparable-sales bands depend on usable recorded sales; read omissions and the similar-sales fallback before relying on the range.
- **Sparse facts still produce precise-looking numbers.** Inspect `subject_property` and its sources; the number of displayed digits does not tell you how complete the inputs were.
When `max_allowable_offer.clamped` is `true`, no purchase price clears the target margin.
Do not present its amount as an offer.
## When the model does not price a property
Some properties sit outside what the pricing model prices: a very large lot, a very large
house, or a property type it does not cover. By default a property like that is refused
with 422 [unscorable-address](/errors/unscorable-address), and the charge is returned to your balance.
Set `partial_answer` to `true` with your request to `POST /v1/predict` to get the facts
instead.
When we found the property but the model does not price it, the answer is `200`.
It carries `subject_property` with the available house facts, `max_allowable_offer` as `null`, and `outside_model_range.reasons` saying why. That answer
is charged as an answer, because it describes a real property.
It carries no valuation, no
comparable sales and no `cost_stack`: those all come from the pricing model's own answer.
| Reason | What it means |
| --- | --- |
| `large-lot` | The lot is larger than the sizes the pricing model is calibrated over. |
| `large-structure` | The finished living area is larger than the sizes the model is calibrated over. |
| `property-type` | The property type is one the model does not cover well: multifamily, commercial, mixed-use or industrial. |
| `not-modelled` | We found the property, but the pricing model did not price it, and none of the three named limits applies. |
`partial_answer` is accepted on `POST /v1/predict` and `POST /v1/validate` only, and is
refused on the batch endpoint.
It must be `true` or `false`; anything else is refused
with 400 [invalid-request](/errors/invalid-request) before you are charged. An address we cannot find, and a call
that fails on our side, are refunded whether or not you send it.
## Next steps
---
## Document: Test without spending
Check the request shape for free before sending an underwriting call.
URL: /production/test-without-spending
# Test without spending
import { Card } from "zudoku/ui/Card";
import { Code2, CircleAlert } from "zudoku/icons";
Send `POST /v1/validate` to check your request body without being charged anything.
A call sent from a reference page with your key is a real call; `/v1/validate` is free there too.
## Check the request shape
Use a valid [API key](/definitions), the credential that identifies your [account](/definitions), the record that owns your keys and the money you add.
An account sign-in also works.
This free call works at a zero [balance](/definitions), the money available on your account.
It checks shape, required fields and the property cap for `POST /v1/predict`.
It never calls the underwriting service.
A true result does not prove that the address exists, is inside coverage or can be underwritten.
The underwriting call decides whether a well-formed address can be underwritten.
The [validate reference](/api/scoring#check-a-request-without-spending) describes the accepted body.
In the playground, use plain-text mode with valid JSON and an active `Content-Type: application/json` header.
The request example sets both; other body modes or a missing content type receive `400`.
```bash title="cURL"
curl https://api.investorliftdata.com/v1/validate \
--request POST --max-time 30 \
--header "Authorization: Bearer $INVESTORLIFT_API_KEY" \
--header "Content-Type: application/json" \
--data '{"address": "1026 N Beckley Ave, Dallas, TX 75203"}'
```
```javascript title="JavaScript"
const response = await fetch("https://api.investorliftdata.com/v1/validate", {
method: "POST",
signal: AbortSignal.timeout(30_000),
headers: {
Authorization: `Bearer ${process.env.INVESTORLIFT_API_KEY}`,
"Content-Type": "application/json",
},
body: JSON.stringify({ address: "1026 N Beckley Ave, Dallas, TX 75203" }),
});
console.log(await response.json());
```
```python title="Python"
import os
import requests
response = requests.post(
"https://api.investorliftdata.com/v1/validate",
headers={
"Authorization": f"Bearer {os.environ['INVESTORLIFT_API_KEY']}",
"Content-Type": "application/json",
},
json={"address": "1026 N Beckley Ave, Dallas, TX 75203"},
timeout=30,
)
print(response.json())
```
```json title="Full response" operation="validate"
{
"valid": true
}
```
## Handle availability
With an API Key it answers even while the Ledger is down.
A connected app reads the Ledger to check that its connection is still approved.
If that lookup is unavailable or its result is unclear, it returns [`503 call-not-started`](/errors/call-not-started).
A revoked connection receives [`401 invalid-key`](/errors/invalid-key).
Nothing is charged either way.
If the operator temporarily switches this endpoint off, it returns [`503 route-unavailable`](/errors/route-unavailable).
Other endpoints are unaffected, and nothing was charged.
Wait for `Retry-After` before sending the same call again.
Any `Idempotency-Key` header is ignored; this free call has no charge to replay.
## Next steps
---
## Document: Retry safely
Reuse a request key to replay a stored success without another charge.
URL: /production/retry-safely
# Retry safely
import { Card } from "zudoku/ui/Card";
import { CircleAlert, ListChecks } from "zudoku/icons";
Keep the same `Idempotency-Key` when you retry one logical call.
## Choose a key
The header is optional, and absence is never an error.
Without it, every send is a new, charged request.
Keys are scoped to your [account](/definitions), the record that owns your keys and [balance](/definitions), the money available for API calls.
One account cannot replay another account's result.
Use **255 characters or fewer**, **no control characters**, and a value that is **not blank**.
Tabs, carriage returns and line feeds are control characters.
Invalid keys receive [`400 invalid-request`](/errors/invalid-request) before anything is charged.
Keys are never trimmed; surrounding spaces make keys different.
## Before: send the call
Choose one key for this request and keep its body and endpoint with it.
Keep every body byte the same, including spaces and JSON field order.
```bash title="cURL"
curl https://api.investorliftdata.com/v1/predict \
--request POST --max-time 30 \
--header "Authorization: Bearer $INVESTORLIFT_API_KEY" \
--header "Content-Type: application/json" \
--header "Idempotency-Key: property-42-v1" \
--data '{"address": "1026 N Beckley Ave, Dallas, TX 75203"}'
```
```javascript title="JavaScript"
const response = await fetch("https://api.investorliftdata.com/v1/predict", {
method: "POST",
signal: AbortSignal.timeout(30_000),
headers: {
Authorization: `Bearer ${process.env.INVESTORLIFT_API_KEY}`,
"Content-Type": "application/json",
"Idempotency-Key": "property-42-v1",
},
body: '{"address": "1026 N Beckley Ave, Dallas, TX 75203"}',
});
console.log(await response.json());
```
```python title="Python"
import os
import requests
response = requests.post(
"https://api.investorliftdata.com/v1/predict",
headers={
"Authorization": f"Bearer {os.environ['INVESTORLIFT_API_KEY']}",
"Content-Type": "application/json",
"Idempotency-Key": "property-42-v1",
},
data='{"address": "1026 N Beckley Ave, Dallas, TX 75203"}',
timeout=30,
)
print(response.json())
```
## After: retry the same call
If the answer is lost, resend this exact body and endpoint with the same key.
A stored success is replayed without another charge.
```bash title="cURL"
curl https://api.investorliftdata.com/v1/predict \
--request POST --max-time 30 \
--header "Authorization: Bearer $INVESTORLIFT_API_KEY" \
--header "Content-Type: application/json" \
--header "Idempotency-Key: property-42-v1" \
--data '{"address": "1026 N Beckley Ave, Dallas, TX 75203"}'
```
```javascript title="JavaScript"
const response = await fetch("https://api.investorliftdata.com/v1/predict", {
method: "POST",
signal: AbortSignal.timeout(30_000),
headers: {
Authorization: `Bearer ${process.env.INVESTORLIFT_API_KEY}`,
"Content-Type": "application/json",
"Idempotency-Key": "property-42-v1",
},
body: '{"address": "1026 N Beckley Ave, Dallas, TX 75203"}',
});
console.log(await response.json());
```
```python title="Python"
import os
import requests
response = requests.post(
"https://api.investorliftdata.com/v1/predict",
headers={
"Authorization": f"Bearer {os.environ['INVESTORLIFT_API_KEY']}",
"Content-Type": "application/json",
"Idempotency-Key": "property-42-v1",
},
data='{"address": "1026 N Beckley Ave, Dallas, TX 75203"}',
timeout=30,
)
print(response.json())
```
Show the full response (145 lines)
```json title="Full response" operation="predict"
{
"comps": [
{
"sqft": 2080,
"lotsize": 6500,
"bedrooms": 6,
"bathrooms": 2,
"sale_date": "2024-12-03",
"sale_price": 246641,
"year_built": 1945,
"address_zip": "75203",
"property_id": "00000000000000000000000000000001",
"address_city": "Dallas",
"address_state": "TX",
"property_type": "Single Family",
"address_street": "523 N Marsalis Ave",
"distance_miles": 0.4,
"similarity_rank": 1,
"price_per_square_foot": 119
},
{
"sqft": 1400,
"lotsize": 7025,
"bedrooms": 3,
"bathrooms": 1,
"sale_date": "2026-05-08",
"sale_price": 234999,
"year_built": 1930,
"address_zip": "75203",
"property_id": "00000000000000000000000000000001",
"address_city": "Dallas",
"address_state": "TX",
"property_type": "Single Family",
"address_street": "102 E 5th St",
"distance_miles": 0.09,
"similarity_rank": 2,
"price_per_square_foot": 168
},
{
"sqft": 1787,
"lotsize": 6678,
"bedrooms": 4,
"bathrooms": 2,
"sale_date": "2025-03-05",
"sale_price": 503139,
"year_built": 1945,
"address_zip": "75208",
"property_id": "00000000000000000000000000000001",
"address_city": "Dallas",
"address_state": "TX",
"property_type": "Single Family",
"address_street": "818 Elsbeth St",
"distance_miles": 0.24,
"similarity_rank": 3,
"price_per_square_foot": 282
}
],
"comp_band": {
"low": 388634,
"high": 942020,
"median": 571412
},
"cost_stack": {
"loan": 331200,
"points": 6624,
"interest": 6533,
"hold_days": 60,
"down_payment": 82800,
"closing_costs": 29562,
"target_profit": 114282
},
"rehab_budget": 0,
"target_margin": 0.2,
"valuation_mean": 1016099,
"subject_property": {
"facts": {
"bathrooms": {
"value": 2,
"source": "county-record"
},
"year_built": {
"value": 1935,
"source": "county-record"
},
"square_feet": {
"value": 2047,
"source": "county-record"
},
"property_type": {
"value": "Single Family",
"source": "county-record"
},
"lot_size_square_feet": {
"value": 7100,
"source": "county-record"
}
},
"address": {
"zip": "75203",
"city": "Dallas",
"state": "TX",
"street": "1026 N Beckley Ave"
},
"property_id": "00000000000000000000000000000001",
"address_quality": {
"code": "zip-confirmed",
"statement": "The house number matched exactly one county record, and that record sits in the same ZIP code your address resolved to. Check the address and property facts in this block before relying on these numbers."
},
"outside_model_range": {
"flagged": false,
"reasons": []
}
},
"as_is_similar_band": {
"basis": "all-comparable-sales",
"statement": "Too few comparable sales passed the size, age and recency filter this service applies to state a similar-sales range, so this price-per-square-foot range covers every comparable sale below that carries a usable sale price and square footage. `qualifying_sales` is how many passed the filter and fell short.",
"qualifying_sales": 1,
"price_per_square_foot": {
"low": 190,
"high": 460,
"median": 279
}
},
"rehab_adjusted_arv": 571000,
"max_allowable_offer": {
"amount": 414000,
"clamped": false
},
"valuation_quantiles": {
"0.02": 191787,
"0.10": 345336,
"0.20": 476815,
"0.30": 598093,
"0.40": 733771,
"0.50": 878872,
"0.60": 1110818,
"0.70": 1417051,
"0.80": 1988162,
"0.90": 3615373,
"0.98": 15505619
},
"effective_sample_size": 51.04950714111328,
"model_estimate_available": true,
"pricing_model_arv_estimate": 1417000
}
```
## Reuse a key correctly
The replay window is 24 hours; see the [formal rules](/api#idempotent-requests).
Inside it, the same request and key return the stored response.
After 24 hours, the stored response expires and a repeat is a new, charged request.
Only successful responses are stored.
A key is replayed only when the earlier call succeeded and its response was stored.
If the earlier call was refunded, ended without a stored response, or is older than the replay window, the key is taken over.
The new request runs and is charged, whatever body it carries.
Retrying after a failed or refunded `502` or `504` is a fresh attempt that spends again.
Only while an earlier success remains stored does a different body or endpoint cause [`422 idempotency-key-conflict`](/errors/idempotency-key-conflict).
Nothing is charged for that refusal; use a new key for the changed request.
An identical request still running receives [`409 duplicate-request-in-flight`](/errors/duplicate-request-in-flight), with no charge for the copy.
Wait for its `Retry-After` interval, then ask again with the same key.
## Check consent and free calls
The Terms-consent check runs before any key lookup, including a replay.
If current Terms need acceptance, [`403 terms-not-accepted`](/errors/terms-not-accepted) refuses the send without a charge.
Accepting them restores the replay while its window remains open.
The free `POST /v1/validate` call ignores `Idempotency-Key` entirely.
See [Test without spending](/production/test-without-spending) for its availability rules.
The API never retries for you.
A [charged call](/definitions), an API request with a price, can fail after it is charged, so you control each new attempt.
## Next steps
---
## Document: Handle errors
Choose the right fix from the error type, charge outcome and retry guidance.
URL: /production/handle-errors
# Handle errors
import { Card } from "zudoku/ui/Card";
import { CircleAlert, RotateCcw } from "zudoku/icons";
Branch on the error's `type` to choose the right fix before you retry.
## Read the error
Every error has a stable `type` address that opens its page in the [error catalogue](/errors).
Use that address in your error handling; HTTP status alone cannot tell you what to do.
Read `detail` for the explanation and the error page for what happened to the charge on your [balance](/definitions), the money available for API calls.
Log the [Trace ID](/definitions), the identifier for this call, from the `trace-id` response header.
Errors also carry it as `trace_id` in the body.
Quote it to `support@investorliftdata.com` when asking about a call.
## Separate the 503 errors
[`ledger-unreachable`](/errors/ledger-unreachable), [`call-not-started`](/errors/call-not-started) and [`route-unavailable`](/errors/route-unavailable) mean the call never started and nothing was charged.
For `route-unavailable`, other endpoints still work.
[`spend-indeterminate`](/errors/spend-indeterminate) means the outcome is unknown: no claim is made about what was consumed.
Any charge recorded for the call is returned to your balance.
For all four, wait for `Retry-After` before sending again.
## Fix the right 422 condition
- [`ambiguous-address`](/errors/ambiguous-address): choose a returned candidate, or add the unit number, city or ZIP.
- [`property-not-found`](/errors/property-not-found): check the identifier, or look up the property's current id.
- [`address-out-of-coverage`](/errors/address-out-of-coverage): the state is outside current coverage; changing the request cannot extend coverage.
- [`unscorable-address`](/errors/unscorable-address): retrying the same address gives the same answer; try another address.
- [`idempotency-key-conflict`](/errors/idempotency-key-conflict): use a new key for the changed request; nothing was charged for the conflict.
The first four return the charge to your balance; `detail` states whether the return is applied or pending.
They do not all have the same retry remedy.
## Separate the three 429 limits
[`rate-limited`](/errors/rate-limited) limits how fast requests arrive.
Wait for `Retry-After` and reduce your sending rate.
[`spend-cap-exceeded`](/errors/spend-cap-exceeded) limits how much your [account](/definitions), the record that owns your keys and the money you add, may spend in a rolling window.
It has no `Retry-After`; slowing down alone does not fix it.
Wait for earlier calls to leave the window, or ask support to raise the cap.
A call costing more than the entire cap cannot fit however long you wait.
[`free-call-limit`](/errors/free-call-limit) limits free calls: 500 a day per account, across the app, the API and assistants.
Free calls start again at 00:00 UTC, the time in `resets_at`; `Retry-After` gives the wait in seconds.
Paid calls are not affected.
None of the three 429s charges anything.
## Control retries
The API never retries for you.
Use an `Idempotency-Key` for [safe retries](/production/retry-safely).
For a `502` or `504`, read the refund state, then retry as a fresh attempt.
## Next steps
---
## Document: Go-live checklist
Check timeouts, retries, error handling and your available balance before launch.
URL: /production/go-live-checklist
# Go-live checklist
import { Card } from "zudoku/ui/Card";
import { ShieldCheck, Tags } from "zudoku/icons";
Check these six settings before your customers start making calls.
1. **Set a client timeout of at least 30 seconds**
Set the timeout in every client that sends calls.
See the request examples in [Retry safely](/production/retry-safely).
2. **Keep an Idempotency-Key for retries**
Reuse the key, body and endpoint for the same logical call.
Follow the [reuse rules](/production/retry-safely#reuse-a-key-correctly).
3. **Branch on type**
Choose your fix from the stable error address, not HTTP status alone.
Use [Handle errors](/production/handle-errors) and the [catalogue](/errors).
4. **Log the Trace ID**
Save the [Trace ID](/definitions), the call identifier in the `trace-id` header.
Errors repeat it as `trace_id`; quote it when asking support about a call.
See [Request IDs](/api#request-ids).
5. **Watch your Balance**
Your [balance](/definitions) is the money in dollars available to spend.
Check it in [the billing page](https://investorliftdata.com/app/billing).
Handle [`insufficient-credits`](/errors/insufficient-credits) by adding funds before sending again.
6. **Obey Retry-After**
Wait for the header's interval whenever an error sends it.
The [rate limits](/api#rate-limits) and [error guide](/production/handle-errors) explain the different limits.
## Next steps
---
## Document: Attribute calls to your customers
Tag charged calls with your own customer identifier.
URL: /production/customer-reference
# Attribute calls to your customers
import { Card } from "zudoku/ui/Card";
import { RotateCcw, CircleAlert } from "zudoku/icons";
Add `X-Customer-Reference` to identify the customer for a [charged call](/definitions), an API request with a price.
## Send your customer identifier
The header is optional on any charged call.
Send your own ID, not a name or email.
The value is 1 to 64 characters: letters, digits, `.`, `_`, `:` and `-`.
An invalid value receives [`400 invalid-request`](/errors/invalid-request) with `reason: customer-reference-form`.
Nothing is charged for that refusal.
Use the same customer ID in each language:
```bash title="cURL"
curl https://api.investorliftdata.com/v1/predict \
--request POST --max-time 30 \
--header "Authorization: Bearer $INVESTORLIFT_API_KEY" \
--header "Content-Type: application/json" \
--header "X-Customer-Reference: cust-42" \
--data '{"address": "1026 N Beckley Ave, Dallas, TX 75203"}'
```
```javascript title="JavaScript"
const response = await fetch("https://api.investorliftdata.com/v1/predict", {
method: "POST",
signal: AbortSignal.timeout(30_000),
headers: {
Authorization: `Bearer ${process.env.INVESTORLIFT_API_KEY}`,
"Content-Type": "application/json",
"X-Customer-Reference": "cust-42",
},
body: JSON.stringify({ address: "1026 N Beckley Ave, Dallas, TX 75203" }),
});
console.log(await response.json());
```
```python title="Python"
import os
import requests
response = requests.post(
"https://api.investorliftdata.com/v1/predict",
headers={
"Authorization": f"Bearer {os.environ['INVESTORLIFT_API_KEY']}",
"Content-Type": "application/json",
"X-Customer-Reference": "cust-42",
},
json={"address": "1026 N Beckley Ave, Dallas, TX 75203"},
timeout=30,
)
print(response.json())
```
Show the full response (145 lines)
```json title="Full response" operation="predict"
{
"comps": [
{
"sqft": 2080,
"lotsize": 6500,
"bedrooms": 6,
"bathrooms": 2,
"sale_date": "2024-12-03",
"sale_price": 246641,
"year_built": 1945,
"address_zip": "75203",
"property_id": "00000000000000000000000000000001",
"address_city": "Dallas",
"address_state": "TX",
"property_type": "Single Family",
"address_street": "523 N Marsalis Ave",
"distance_miles": 0.4,
"similarity_rank": 1,
"price_per_square_foot": 119
},
{
"sqft": 1400,
"lotsize": 7025,
"bedrooms": 3,
"bathrooms": 1,
"sale_date": "2026-05-08",
"sale_price": 234999,
"year_built": 1930,
"address_zip": "75203",
"property_id": "00000000000000000000000000000001",
"address_city": "Dallas",
"address_state": "TX",
"property_type": "Single Family",
"address_street": "102 E 5th St",
"distance_miles": 0.09,
"similarity_rank": 2,
"price_per_square_foot": 168
},
{
"sqft": 1787,
"lotsize": 6678,
"bedrooms": 4,
"bathrooms": 2,
"sale_date": "2025-03-05",
"sale_price": 503139,
"year_built": 1945,
"address_zip": "75208",
"property_id": "00000000000000000000000000000001",
"address_city": "Dallas",
"address_state": "TX",
"property_type": "Single Family",
"address_street": "818 Elsbeth St",
"distance_miles": 0.24,
"similarity_rank": 3,
"price_per_square_foot": 282
}
],
"comp_band": {
"low": 388634,
"high": 942020,
"median": 571412
},
"cost_stack": {
"loan": 331200,
"points": 6624,
"interest": 6533,
"hold_days": 60,
"down_payment": 82800,
"closing_costs": 29562,
"target_profit": 114282
},
"rehab_budget": 0,
"target_margin": 0.2,
"valuation_mean": 1016099,
"subject_property": {
"facts": {
"bathrooms": {
"value": 2,
"source": "county-record"
},
"year_built": {
"value": 1935,
"source": "county-record"
},
"square_feet": {
"value": 2047,
"source": "county-record"
},
"property_type": {
"value": "Single Family",
"source": "county-record"
},
"lot_size_square_feet": {
"value": 7100,
"source": "county-record"
}
},
"address": {
"zip": "75203",
"city": "Dallas",
"state": "TX",
"street": "1026 N Beckley Ave"
},
"property_id": "00000000000000000000000000000001",
"address_quality": {
"code": "zip-confirmed",
"statement": "The house number matched exactly one county record, and that record sits in the same ZIP code your address resolved to. Check the address and property facts in this block before relying on these numbers."
},
"outside_model_range": {
"flagged": false,
"reasons": []
}
},
"as_is_similar_band": {
"basis": "all-comparable-sales",
"statement": "Too few comparable sales passed the size, age and recency filter this service applies to state a similar-sales range, so this price-per-square-foot range covers every comparable sale below that carries a usable sale price and square footage. `qualifying_sales` is how many passed the filter and fell short.",
"qualifying_sales": 1,
"price_per_square_foot": {
"low": 190,
"high": 460,
"median": 279
}
},
"rehab_adjusted_arv": 571000,
"max_allowable_offer": {
"amount": 414000,
"clamped": false
},
"valuation_quantiles": {
"0.02": 191787,
"0.10": 345336,
"0.20": 476815,
"0.30": 598093,
"0.40": 733771,
"0.50": 878872,
"0.60": 1110818,
"0.70": 1417051,
"0.80": 1988162,
"0.90": 3615373,
"0.98": 15505619
},
"effective_sample_size": 51.04950714111328,
"model_estimate_available": true,
"pricing_model_arv_estimate": 1417000
}
```
## Next steps
---
## Document: Per-call prices
Every published call's price in dollars, its unit and who may buy it.
URL: /pricing/per-call-prices
# Per-call prices
Every call is charged from your balance at the price below. The list is generated from the price table the API charges by.
## Answer about a property
| Call | Price | Who can buy | Free when |
| --- | --- | --- | --- |
| [Underwrite one address](/api/scoring#predict) | $1.00 per address answered | Everyone | Not charged if it fails; Not charged if nothing matches |
| [Underwrite a batch of addresses](/api/scoring#predict-batch) | $1.00 per address answered | Everyone | Not charged if it fails; Not charged if nothing matches |
| [Read a property's history](/api/history) | $0.25 per property | Everyone | Not charged if it fails; Not charged if nothing matches |
| [Investors who buy homes like this one](/api/investors#rank-the-investors-who-buy-homes-like-this-one) | $2.00 per call, up to 25 buyers · desks only | Desks only | Not charged if it fails; Not charged if nothing matches |
## Ranked list
| Call | Price | Who can buy | Free when |
| --- | --- | --- | --- |
| [Active investors in a ZIP](/api/investors#list-the-investors-buying-in-zip-codes) | $1.00 per list · desks only | Desks only | Not charged if it fails; Refused free outside coverage |
| [Sellers in a ZIP](/api/zip-codes-and-coverage#list-the-sellers-in-a-zip-code) | $1.00 per list · desks only | Desks only | Not charged if it fails; Refused free outside coverage |
| [Lenders active in a place](/api/lenders#list-the-lenders-active-in-a-place) | $1.00 per list · desks only | Desks only | Not charged if it fails; Not charged if nothing matches; Refused free outside coverage |
## Profile or page
| Call | Price | Who can buy | Free when |
| --- | --- | --- | --- |
| [Investor purchases near a property](/api/investors#list-investor-purchases-near-a-place) | $0.50 per page · desks only | Desks only | Not charged if it fails; Not charged if nothing matches; Refused free outside coverage |
| [Investor company profile](/api/investor-companies#read-an-investor-companys-profile) | $0.50 per company · desks only | Desks only | Not charged if it fails; Not charged if nothing matches |
| [Investor company purchases](/api/investor-companies#list-an-investor-companys-purchases) | $0.50 per page · desks only | Desks only | Not charged if it fails; Not charged if nothing matches |
| [Investor company sales](/api/investor-companies#list-an-investor-companys-sales) | $0.50 per page · desks only | Desks only | Not charged if it fails; Not charged if nothing matches |
| [Lender profile](/api/lenders#read-a-lenders-profile) | $0.50 per lender · desks only | Desks only | Not charged if it fails; Not charged if nothing matches |
| [Lender loans](/api/lenders#list-a-lenders-loans) | $0.50 per page · desks only | Desks only | Not charged if it fails; Not charged if nothing matches |
| [Who a lender lends to](/api/lenders#list-who-a-lender-lends-to) | $0.50 per page · desks only | Desks only | Not charged if it fails; Not charged if nothing matches |
## Plain row
| Call | Price | Who can buy | Free when |
| --- | --- | --- | --- |
| [Find a property by address](/api/properties#find-a-property-by-address) | $0.10 per address found | Everyone | Not charged if it fails; Not charged if nothing matches; Refused free outside coverage |
| [Property record](/api/properties#read-a-propertys-record) | $0.10 per property | Everyone | Not charged if it fails; Not charged if nothing matches |
| [Ownership history](/api/properties#read-a-propertys-ownership-history) | $0.10 per property | Everyone | Not charged if it fails; Not charged if nothing matches |
| [Listing history](/api/properties#read-a-propertys-listing-history) | $0.10 per property | Everyone | Not charged if it fails; Not charged if nothing matches |
| [For-sale listings near a place](/api/listings-and-sales#list-properties-for-sale-near-a-place) | $0.25 per page | Everyone | Not charged if it fails; Not charged if nothing matches; Refused free outside coverage |
| [Rentals near a place](/api/listings-and-sales#list-rentals-near-a-place) | $0.25 per page | Everyone | Not charged if it fails; Not charged if nothing matches; Refused free outside coverage |
| [Recent sales near a place](/api/listings-and-sales#list-recent-sales-near-a-place) | $0.25 per page | Everyone | Not charged if it fails; Not charged if nothing matches; Refused free outside coverage |
| [One investor purchase](/api/investors#read-one-investor-purchase) | $0.10 per item · desks only | Desks only | Not charged if it fails; Not charged if nothing matches |
| [One listing](/api/listings-and-sales#read-one-listing-in-full) | $0.10 per item | Everyone | Not charged if it fails; Not charged if nothing matches |
## Summary
| Call | Price | Who can buy | Free when |
| --- | --- | --- | --- |
| [Investor activity summary](/api/investors#summarize-the-investor-purchases-in-a-place) | Free | Everyone | |
| [ZIP snapshot](/api/zip-codes-and-coverage#read-a-zip-codes-snapshot) | Free | Everyone | |
| [Snapshot of several ZIPs](/api/zip-codes-and-coverage#read-a-snapshot-of-several-zip-codes) | Free | Everyone | |
## Utility
| Call | Price | Who can buy | Free when |
| --- | --- | --- | --- |
| [Check a request](/api/scoring#validate) | Free | Everyone | |
| [Find an investor company by name](/api/investors#find-an-investor-company-by-name) | Free · desks only | Desks only | |
| [Where the data covers](/api/zip-codes-and-coverage#see-where-the-data-service-has-data) | Free | Everyone | |
Each assistant (MCP) tool costs what its call costs, from the same balance.
## When a call is not charged
A call that fails is not charged; any charge is returned to your balance.
An address or identifier that matches nothing is not charged.
A place outside the data’s coverage is refused before anything is charged.
An answer inside coverage is still charged when it is empty.
See [Your balance](/balance) for how your balance, packs and refunds work.
---
## Document: Upstream service unavailable
A data service we rely on could not complete this call, so no result was produced. Returned to your balance.
URL: /errors/upstream-unavailable
# Upstream service unavailable
A data service we rely on could not complete this call, so no result was produced. Returned to your balance.
| Fact | Value |
| --- | --- |
| HTTP status | `502` |
| Type | `https://docs.investorliftdata.com/errors/upstream-unavailable` |
| Charged | The call was charged; detail says whether the refund is applied or pending. |
| Retry | Send the request again. |
## Common causes
- A data service could not complete the call.
- No score was produced because that service failed.
## How to fix
1. Send the call again.
2. Read detail to see whether the refund is applied or pending.
## Example response body
**Refund applied**
```json
{
"type": "https://docs.investorliftdata.com/errors/upstream-unavailable",
"title": "Upstream service unavailable",
"status": 502,
"detail": "An upstream service could not complete this call, so no score was produced. The price of this call has been returned to the Account's balance. Send the request again.",
"trace_id": "00000000-0000-4000-8000-000000000000"
}
```
**Refund pending**
```json
{
"type": "https://docs.investorliftdata.com/errors/upstream-unavailable",
"title": "Upstream service unavailable",
"status": 502,
"detail": "An upstream service could not complete this call, so no score was produced. Returning the price of this call to the Account's balance is pending and will be applied. Send the request again.",
"trace_id": "00000000-0000-4000-8000-000000000000"
}
```
Quote the `trace_id` to `support@investorliftdata.com`.
---
## Document: Upstream service timed out
A data service we rely on did not answer in time, so no result was produced. Returned to your balance.
URL: /errors/upstream-timeout
# Upstream service timed out
A data service we rely on did not answer in time, so no result was produced. Returned to your balance.
| Fact | Value |
| --- | --- |
| HTTP status | `504` |
| Type | `https://docs.investorliftdata.com/errors/upstream-timeout` |
| Charged | The call was charged; detail says whether the refund is applied or pending. |
| Retry | Send the request again. |
## Common causes
- A data service did not answer in time.
- The call ended without a score because it was still waiting for that service.
## How to fix
1. Send the call again.
2. Read detail to see whether the refund is applied or pending.
## Example response body
**Refund applied**
```json
{
"type": "https://docs.investorliftdata.com/errors/upstream-timeout",
"title": "Upstream service timed out",
"status": 504,
"detail": "An upstream service did not respond in time, so no score was produced. The price of this call has been returned to the Account's balance. Send the request again.",
"trace_id": "00000000-0000-4000-8000-000000000000"
}
```
**Refund pending**
```json
{
"type": "https://docs.investorliftdata.com/errors/upstream-timeout",
"title": "Upstream service timed out",
"status": 504,
"detail": "An upstream service did not respond in time, so no score was produced. Returning the price of this call to the Account's balance is pending and will be applied. Send the request again.",
"trace_id": "00000000-0000-4000-8000-000000000000"
}
```
Quote the `trace_id` to `support@investorliftdata.com`.
---
## Document: Request rejected by an upstream data service
A data service we use did not accept part of this request, such as an identifier, place or option. Returned to your balance.
URL: /errors/upstream-rejected-request
# Request rejected by an upstream data service
A data service we use did not accept part of this request, such as an identifier, place or option. Returned to your balance.
| Fact | Value |
| --- | --- |
| HTTP status | `400` |
| Type | `https://docs.investorliftdata.com/errors/upstream-rejected-request` |
| Charged | The call was charged; detail says whether the refund is applied or pending. |
| Retry | Change the request as the detail directs before sending it again. |
## Common causes
- A data service rejected an identifier, option or cursor.
- The requested place or dataset is not accepted or covered by that service.
## How to fix
1. Correct the request against the API reference and send it again.
2. Check the identifier, place, cursor and options before sending again.
## Example response body
**Refund applied**
```json
{
"type": "https://docs.investorliftdata.com/errors/upstream-rejected-request",
"title": "Request rejected by an upstream data service",
"status": 400,
"detail": "An upstream data service rejected this request — an identifier, parameter, cursor, place or dataset it does not accept or does not cover — so no answer was produced. The price of this call has been returned to the Account's balance. Correct the request and send it again.",
"trace_id": "00000000-0000-4000-8000-000000000000"
}
```
**Refund pending**
```json
{
"type": "https://docs.investorliftdata.com/errors/upstream-rejected-request",
"title": "Request rejected by an upstream data service",
"status": 400,
"detail": "An upstream data service rejected this request — an identifier, parameter, cursor, place or dataset it does not accept or does not cover — so no answer was produced. Returning the price of this call to the Account's balance is pending and will be applied. Correct the request and send it again.",
"trace_id": "00000000-0000-4000-8000-000000000000"
}
```
Quote the `trace_id` to `support@investorliftdata.com`.
---
## Document: Address cannot be scored
We could not underwrite this address. Returned to your balance.
URL: /errors/unscorable-address
# Address cannot be scored
We could not underwrite this address. Returned to your balance.
| Fact | Value |
| --- | --- |
| HTTP status | `422` |
| Type | `https://docs.investorliftdata.com/errors/unscorable-address` |
| Charged | The call was charged; detail says whether the refund is applied or pending. |
| Retry | There is nothing in the request to correct; the same request gives the same answer. |
## Common causes
- The property could not be scored.
- You repeated an address that still cannot be scored.
## How to fix
1. Nothing to change in the request; try another address.
2. Do not repeatedly resend the same address expecting a different result.
## Example response body
**Refund applied**
```json
{
"type": "https://docs.investorliftdata.com/errors/unscorable-address",
"title": "Address cannot be scored",
"status": 422,
"detail": "The submitted address could not be scored. The price of this call has been returned to the Account's balance.",
"trace_id": "00000000-0000-4000-8000-000000000000"
}
```
**Refund pending**
```json
{
"type": "https://docs.investorliftdata.com/errors/unscorable-address",
"title": "Address cannot be scored",
"status": 422,
"detail": "The submitted address could not be scored. Returning the price of this call to the Account's balance is pending and will be applied.",
"trace_id": "00000000-0000-4000-8000-000000000000"
}
```
Quote the `trace_id` to `support@investorliftdata.com`.
---
## Document: No such endpoint
No endpoint exists at this path. Nothing charged.
URL: /errors/unknown-endpoint
# No such endpoint
No endpoint exists at this path. Nothing charged.
| Fact | Value |
| --- | --- |
| HTTP status | `404` |
| Type | `https://docs.investorliftdata.com/errors/unknown-endpoint` |
| Charged | Nothing is charged by a call that ends here. |
| Retry | Change the request as the detail directs before sending it again. |
## Common causes
- The path does not name an endpoint.
- The requested path differs from the API reference.
## How to fix
1. Check the path against the API reference.
2. Use the documented path and HTTP method.
## Example response body
```json
{
"type": "https://docs.investorliftdata.com/errors/unknown-endpoint",
"title": "No such endpoint",
"status": 404,
"detail": "No endpoint exists at the requested path. Nothing was charged. Check the request against the API reference.",
"trace_id": "00000000-0000-4000-8000-000000000000"
}
```
Quote the `trace_id` to `support@investorliftdata.com`.
---
## Document: Terms of Service not accepted
The Account's owner has not accepted the current Terms of Service, so the call did not start. Nothing charged.
URL: /errors/terms-not-accepted
# Terms of Service not accepted
The Account's owner has not accepted the current Terms of Service, so the call did not start. Nothing charged.
| Fact | Value |
| --- | --- |
| HTTP status | `403` |
| Type | `https://docs.investorliftdata.com/errors/terms-not-accepted` |
| Charged | Nothing is charged by a call that ends here. |
| Retry | Fix the account condition, then send the identical request again. |
## Common causes
- The account owner has not accepted the current Terms.
- The Terms changed materially after the account last accepted them.
## How to fix
1. Ask the Account's owner to accept the Terms in the app, then send the call again.
2. Use terms_url to reach the acceptance control, then send the same request again.
## Example response body
```json
{
"type": "https://docs.investorliftdata.com/errors/terms-not-accepted",
"title": "Terms of Service not accepted",
"status": 403,
"detail": "The current Terms of Service for this Account have not been accepted, so this Metered Call was not started and nothing was charged. Review and accept the current Terms of Service in the Portal, then send the request again.",
"trace_id": "00000000-0000-4000-8000-000000000000",
"terms_url": "https://investorliftdata.com/app/settings/profile"
}
```
## Extra body members
| Member | Meaning |
| --- | --- |
| `terms_url` | The Portal page where the current Terms of Service can be reviewed and accepted for this Account. |
Quote the `trace_id` to `support@investorliftdata.com`.
---
## Document: Spend outcome indeterminate
The call did not finish and we cannot yet tell whether it was charged. Any charge recorded for it will be returned to your balance.
URL: /errors/spend-indeterminate
# Spend outcome indeterminate
The call did not finish and we cannot yet tell whether it was charged. Any charge recorded for it will be returned to your balance.
| Fact | Value |
| --- | --- |
| HTTP status | `503` |
| Type | `https://docs.investorliftdata.com/errors/spend-indeterminate` |
| Charged | No claim is made about what was charged, because the outcome is not known. Any charge recorded for the call is returned to your balance. |
| Retry | Wait for the interval in `Retry-After`, then send the request again. |
## Common causes
- The call did not finish.
- The outcome of its charge could not be confirmed.
## How to fix
1. Wait for the time in the Retry-After header, then send the call again.
2. Keep the Trace ID when checking the recorded charge and any refund.
## Example response body
```json
{
"type": "https://docs.investorliftdata.com/errors/spend-indeterminate",
"title": "Spend outcome indeterminate",
"status": 503,
"detail": "This Metered Call did not complete, and the outcome of its Spend is not known. Any charge recorded for it will be returned to the Account's balance. Wait for the interval given in the Retry-After header, then send the request again.",
"trace_id": "00000000-0000-4000-8000-000000000000",
"retry_after": 5
}
```
## Extra body members
| Member | Meaning |
| --- | --- |
| `retry_after` | The retry interval in seconds — the same value as the Retry-After header, repeated in the body for clients that only parse JSON. |
Quote the `trace_id` to `support@investorliftdata.com`.
---
## Document: Daily spending cap reached
This Account reached its daily spending cap, so the call did not start. Nothing charged.
URL: /errors/spend-cap-exceeded
# Daily spending cap reached
This Account reached its daily spending cap, so the call did not start. Nothing charged.
| Fact | Value |
| --- | --- |
| HTTP status | `429` |
| Type | `https://docs.investorliftdata.com/errors/spend-cap-exceeded` |
| Charged | Nothing is charged by a call that ends here. |
| Retry | Retry once earlier calls leave the rolling window, or ask support to raise the cap. |
## Common causes
- Earlier calls have used the available allowance in the rolling window.
- This call costs more than the entire cap allows.
- A connected assistant reached its own limit for the window; cap_scope is connection.
## How to fix
1. Wait, or change the cap in Billing.
2. Compare cap, consumed, required and window_seconds in the body.
3. Slowing down alone does not raise the cap; a call larger than the cap cannot fit by waiting.
## Example response body
```json
{
"type": "https://docs.investorliftdata.com/errors/spend-cap-exceeded",
"title": "Daily spending cap reached",
"status": 429,
"detail": "This Account has reached its Spend Cap, the most it may spend in a rolling window, so this Metered Call was not started and nothing was charged. The Spend Cap, the amount already spent in the window, the amount this call requires, and the length of the window in seconds are given below. The window rolls continuously rather than resetting on a clock boundary: send the request again once earlier calls have fallen outside it, or contact support to have the Spend Cap raised.",
"trace_id": "00000000-0000-4000-8000-000000000000",
"cap": 250.00,
"consumed": 250.00,
"required": 1.00,
"window_seconds": 3600,
"cap_cents": 25000,
"consumed_cents": 25000,
"required_cents": 100,
"cap_scope": "account"
}
```
## Extra body members
| Member | Meaning |
| --- | --- |
| `cap` | The cap that refused the call — the most this Account (or, when cap_scope is connection, this one assistant connection) may spend in one rolling window, in US dollars (two decimals, for example 250.00). |
| `consumed` | What has already been spent in the window against that cap (by this Account, or by this one assistant connection when cap_scope is connection), in US dollars (two decimals). Every Metered Call counts, including calls whose price was later returned to the balance. |
| `required` | What this call costs, in US dollars (for example 1.00). |
| `window_seconds` | The length of the window in seconds. The window rolls continuously rather than resetting on a clock boundary, so room to spend becomes available again as earlier calls fall outside it. |
| `cap_cents` | The same figure as cap, in whole US cents (for example 25000). |
| `consumed_cents` | The same figure as consumed, in whole US cents. |
| `required_cents` | The same figure as required, in whole US cents (for example 100). |
| `cap_scope` | Which cap refused the call: `account` — the account's spending cap; `connection` — one assistant connection's own limit. |
Quote the `trace_id` to `support@investorliftdata.com`.
---
## Document: Endpoint temporarily switched off
This endpoint is switched off for now. Other endpoints still work, and nothing was charged.
URL: /errors/route-unavailable
# Endpoint temporarily switched off
This endpoint is switched off for now. Other endpoints still work, and nothing was charged.
| Fact | Value |
| --- | --- |
| HTTP status | `503` |
| Type | `https://docs.investorliftdata.com/errors/route-unavailable` |
| Charged | Nothing is charged by a call that ends here. |
| Retry | Wait for the interval in `Retry-After`, then send the request again. |
## Common causes
- You called an endpoint that is temporarily switched off.
- You retried that endpoint before it was switched back on.
## How to fix
1. Send the same call again later; the Retry-After header says when.
2. Other endpoints remain available; retry this one only after the stated interval.
## Example response body
```json
{
"type": "https://docs.investorliftdata.com/errors/route-unavailable",
"title": "Endpoint temporarily switched off",
"status": 503,
"detail": "This endpoint is temporarily switched off. Other endpoints are unaffected, and nothing was charged. Send the same call again later; the Retry-After header says how long to wait before trying.",
"trace_id": "00000000-0000-4000-8000-000000000000",
"retry_after": 5
}
```
## Extra body members
| Member | Meaning |
| --- | --- |
| `retry_after` | The retry interval in seconds — the same value as the Retry-After header, repeated in the body for clients that only parse JSON. |
Quote the `trace_id` to `support@investorliftdata.com`.
---
## Document: Rate limit exceeded
Too many calls arrived in a short time. Nothing charged.
URL: /errors/rate-limited
# Rate limit exceeded
Too many calls arrived in a short time. Nothing charged.
| Fact | Value |
| --- | --- |
| HTTP status | `429` |
| Type | `https://docs.investorliftdata.com/errors/rate-limited` |
| Charged | Nothing is charged by a call that ends here. |
| Retry | Wait for the interval in `Retry-After`, then send the request again. |
## Common causes
- Your account sent too many calls in a short time.
- Concurrent clients sent calls against the same account limit.
## How to fix
1. Wait for the time in the Retry-After header, then send the call again.
2. Reduce your sending rate across clients that share the account.
## Example response body
```json
{
"type": "https://docs.investorliftdata.com/errors/rate-limited",
"title": "Rate limit exceeded",
"status": 429,
"detail": "This Account has sent too many requests. Nothing was charged. Wait for the interval given in the Retry-After header, then send the request again.",
"trace_id": "00000000-0000-4000-8000-000000000000",
"retry_after": 5
}
```
## Extra body members
| Member | Meaning |
| --- | --- |
| `retry_after` | The retry interval in seconds — the same value as the Retry-After header, repeated in the body for clients that only parse JSON. |
Quote the `trace_id` to `support@investorliftdata.com`.
---
## Document: Property not found
No property record matches this identifier. Returned to your balance.
URL: /errors/property-not-found
# Property not found
No property record matches this identifier. Returned to your balance.
| Fact | Value |
| --- | --- |
| HTTP status | `422` |
| Type | `https://docs.investorliftdata.com/errors/property-not-found` |
| Charged | The call was charged; detail says whether the refund is applied or pending. |
| Retry | Change the request as the detail directs before sending it again. |
## Common causes
- No property record matches the identifier you sent.
- The identifier no longer points to a property record.
## How to fix
1. Check the identifier, or look the property up again to get its current id.
2. Use the current identifier from the lookup result.
## Example response body
**Refund applied**
```json
{
"type": "https://docs.investorliftdata.com/errors/property-not-found",
"title": "Property not found",
"status": 422,
"detail": "No property record could be found for the identifier on this request, so no recorded history was returned. The price of this call has been returned to the Account's balance. Check the identifier and send the request again.",
"trace_id": "00000000-0000-4000-8000-000000000000"
}
```
**Refund pending**
```json
{
"type": "https://docs.investorliftdata.com/errors/property-not-found",
"title": "Property not found",
"status": 422,
"detail": "No property record could be found for the identifier on this request, so no recorded history was returned. Returning the price of this call to the Account's balance is pending and will be applied. Check the identifier and send the request again.",
"trace_id": "00000000-0000-4000-8000-000000000000"
}
```
Quote the `trace_id` to `support@investorliftdata.com`.
---
## Document: Batch too large
This request carried more properties than this endpoint accepts. Nothing charged.
URL: /errors/payload-too-large
# Batch too large
This request carried more properties than this endpoint accepts. Nothing charged.
| Fact | Value |
| --- | --- |
| HTTP status | `413` |
| Type | `https://docs.investorliftdata.com/errors/payload-too-large` |
| Charged | Nothing is charged by a call that ends here. |
| Retry | Change the request as the detail directs before sending it again. |
## Common causes
- The batch contains more properties than the endpoint accepts.
- A single-property body contains an unrecognised field that is counted as another property.
## How to fix
1. Split the batch into smaller parts, or remove any body field the API reference does not list.
2. Compare limit and submitted in the body with the request you sent.
## Example response body
```json
{
"type": "https://docs.investorliftdata.com/errors/payload-too-large",
"title": "Batch too large",
"status": 413,
"detail": "This request carried more properties than this endpoint accepts. Nothing was charged. On the batch endpoint that means more entries in the properties array than the documented limit: split it and send the parts again. On an endpoint that takes a single property, every body member that is not a documented request option counts as a property, so an unrecognised member name is counted as one: check the member names against the API reference and send the request again.",
"trace_id": "00000000-0000-4000-8000-000000000000",
"limit": 1,
"submitted": 2
}
```
## Extra body members
| Member | Meaning |
| --- | --- |
| `limit` | The documented maximum number of properties a single request may submit. |
| `submitted` | The number of properties this request was counted as carrying. On the batch endpoint that is the number of entries in the `properties` array. On an endpoint that takes a single property it is the number of body members that are not documented request options — so an unrecognised member name is counted as a property and raises this number. |
Quote the `trace_id` to `support@investorliftdata.com`.
---
## Document: Ledger unreachable
Our balance records could not be reached, so the call never started. Nothing charged.
URL: /errors/ledger-unreachable
# Ledger unreachable
Our balance records could not be reached, so the call never started. Nothing charged.
| Fact | Value |
| --- | --- |
| HTTP status | `503` |
| Type | `https://docs.investorliftdata.com/errors/ledger-unreachable` |
| Charged | Nothing is charged by a call that ends here. |
| Retry | Wait for the interval in `Retry-After`, then send the request again. |
## Common causes
- The balance records could not be reached before the call started.
- A retry reached the same unavailable balance records.
## How to fix
1. Wait for the time in the Retry-After header, then send the call again.
2. Keep the same request key when retrying the same logical call.
## Example response body
```json
{
"type": "https://docs.investorliftdata.com/errors/ledger-unreachable",
"title": "Ledger unreachable",
"status": 503,
"detail": "The Ledger could not be reached, so this Metered Call was never started and nothing was charged. Wait for the interval given in the Retry-After header, then send the request again.",
"trace_id": "00000000-0000-4000-8000-000000000000",
"retry_after": 5
}
```
## Extra body members
| Member | Meaning |
| --- | --- |
| `retry_after` | The retry interval in seconds — the same value as the Retry-After header, repeated in the body for clients that only parse JSON. |
Quote the `trace_id` to `support@investorliftdata.com`.
---
## Document: Malformed request
The request does not match the documented form for this endpoint. Nothing charged.
URL: /errors/invalid-request
# Malformed request
The request does not match the documented form for this endpoint. Nothing charged.
| Fact | Value |
| --- | --- |
| HTTP status | `400` |
| Type | `https://docs.investorliftdata.com/errors/invalid-request` |
| Charged | Nothing is charged by a call that ends here. |
| Retry | Change the request as the detail directs before sending it again. |
## Common causes
- A required field is missing or has the wrong form.
- An identifier or place does not match the accepted form or coverage.
- The X-Customer-Reference header does not match its documented form.
## How to fix
1. Check the request against the API reference, correct it and send it again.
2. Read reason when present, and correct that field or header before retrying.
## Example response body
```json
{
"type": "https://docs.investorliftdata.com/errors/invalid-request",
"title": "Malformed request",
"status": 400,
"detail": "The request did not match the documented schema for this endpoint, including an identifier that is not in its documented form (look the property up again to get its current identifier) or a place outside the data's coverage. Nothing was charged. Correct the request and send it again.",
"trace_id": "00000000-0000-4000-8000-000000000000"
}
```
## Extra body members
| Member | Meaning |
| --- | --- |
| `reason` (optional; one of `identifier-form`, `place-outside-coverage`, `customer-reference-form`) | Present only when the refusal names its cause; absent otherwise. `identifier-form` — An identifier in the request is not in its documented form. Look the property up again to get its current identifier. `place-outside-coverage` — The place in the request is outside the data's coverage. `customer-reference-form` — The X-Customer-Reference header is not in its documented form: 1 to 64 letters, digits, dots, underscores, colons or hyphens. |
Quote the `trace_id` to `support@investorliftdata.com`.
---
## Document: Invalid API Key
This API key is not valid or has been revoked. Nothing charged.
URL: /errors/invalid-key
# Invalid API Key
This API key is not valid or has been revoked. Nothing charged.
| Fact | Value |
| --- | --- |
| HTTP status | `401` |
| Type | `https://docs.investorliftdata.com/errors/invalid-key` |
| Charged | Nothing is charged by a call that ends here. |
| Retry | Change the request as the detail directs before sending it again. |
## Common causes
- The key in your request is not valid.
- The key was revoked.
## How to fix
1. Check the key in your request, or create a new one in Developers → Keys.
2. Send the valid key in the Authorization bearer header.
## Example response body
```json
{
"type": "https://docs.investorliftdata.com/errors/invalid-key",
"title": "Invalid API Key",
"status": 401,
"detail": "This API Key is not valid, or it has been revoked. Nothing was charged. Check the API Key in your request, or issue a new one from the Portal.",
"trace_id": "00000000-0000-4000-8000-000000000000"
}
```
## Extra body members
| Member | Meaning |
| --- | --- |
| `reason` (optional; one of `key-revoked`) | Present only when the refusal names its cause; absent otherwise. `key-revoked` — This key was revoked, so the call was refused. Nothing charged. Create a new key in Developers → Keys and use it in your code. |
Quote the `trace_id` to `support@investorliftdata.com`.
---
## Document: Not enough balance
Your balance is not enough for this call. Nothing charged.
URL: /errors/insufficient-credits
# Not enough balance
Your balance is not enough for this call. Nothing charged.
| Fact | Value |
| --- | --- |
| HTTP status | `402` |
| Type | `https://docs.investorliftdata.com/errors/insufficient-credits` |
| Charged | Nothing is charged by a call that ends here. |
| Retry | Fix the account condition, then send the identical request again. |
## Common causes
- Your available balance is lower than this call requires.
- Earlier calls have used the balance you expected to spend.
## How to fix
1. Add funds in Billing.
2. Check balance and required in the error body before sending the same request again.
3. Read [Your balance](/balance), then add funds on the [Billing page](https://investorliftdata.com/app/billing).
## Example response body
```json
{
"type": "https://docs.investorliftdata.com/errors/insufficient-credits",
"title": "Not enough balance",
"status": 402,
"detail": "This Account's balance is not enough for this Metered Call. Nothing was charged. Add funds to the Account and send the request again.",
"trace_id": "00000000-0000-4000-8000-000000000000",
"balance": 0.00,
"required": 1.00,
"top_up_url": "https://investorliftdata.com/app/billing",
"balance_cents": 0,
"required_cents": 100
}
```
## Extra body members
| Member | Meaning |
| --- | --- |
| `balance` | Your balance now, in US dollars (two decimals, for example 74.20). |
| `required` | What this call costs, in US dollars (for example 1.00). |
| `top_up_url` | The Portal page where funds can be added to the Account. |
| `balance_cents` | The same figure as balance, in whole US cents (for example 7420). |
| `required_cents` | The same figure as required, in whole US cents (for example 100). |
| `pending_cents` (optional; whole US cents) | Present only while the account's first payment is being checked, as whole US cents. Your balance is added when the check passes. |
| `paid_only` (optional; one of `true`) | Present only when the refusal names its cause; absent otherwise. `true` — Present, and `true`, when this is a desk-only call and the paid part of the balance is too low: bonus balance never pays for a desk-only call. |
Quote the `trace_id` to `support@investorliftdata.com`.
---
## Document: Idempotency-Key conflict
This Idempotency-Key was already used for a different request. Nothing charged.
URL: /errors/idempotency-key-conflict
# Idempotency-Key conflict
This Idempotency-Key was already used for a different request. Nothing charged.
| Fact | Value |
| --- | --- |
| HTTP status | `422` |
| Type | `https://docs.investorliftdata.com/errors/idempotency-key-conflict` |
| Charged | Nothing is charged by a call that ends here. |
| Retry | Change the request as the detail directs before sending it again. |
## Common causes
- A stored success used this key with a different body.
- A stored success used this key on a different endpoint.
## How to fix
1. Send the call again with a new Idempotency-Key.
2. Keep the original key only for retries of the same body and endpoint.
## Example response body
```json
{
"type": "https://docs.investorliftdata.com/errors/idempotency-key-conflict",
"title": "Idempotency-Key conflict",
"status": 422,
"detail": "The Idempotency-Key on this request was already used for a different request — a different body, or the same body on a different endpoint — so it cannot be replayed. Nothing was charged. Send the request again with a new Idempotency-Key.",
"trace_id": "00000000-0000-4000-8000-000000000000"
}
```
Quote the `trace_id` to `support@investorliftdata.com`.
---
## Document: Save a card to unlock free calls
Free calls on this account need a saved card. We check the card and don't charge it. Nothing charged.
URL: /errors/free-calls-locked
# Save a card to unlock free calls
Free calls on this account need a saved card. We check the card and don't charge it. Nothing charged.
| Fact | Value |
| --- | --- |
| HTTP status | `403` |
| Type | `https://docs.investorliftdata.com/errors/free-calls-locked` |
| Charged | Nothing is charged by a call that ends here. |
| Retry | Fix the account condition, then send the identical request again. |
## Common causes
- No card has been saved on the account yet.
- A card save was started and has not finished.
## How to fix
1. Save a card in the app, then send the same request again.
## Example response body
```json
{
"type": "https://docs.investorliftdata.com/errors/free-calls-locked",
"title": "Save a card to unlock free calls",
"status": 403,
"detail": "Free calls on this account need a saved card. We check the card and don't charge it. Nothing was charged.",
"trace_id": "00000000-0000-4000-8000-000000000000"
}
```
Quote the `trace_id` to `support@investorliftdata.com`.
---
## Document: Today's free calls are used up
Your account has made 500 free calls today, across the app, the API and assistants. Nothing charged.
URL: /errors/free-call-limit
# Today's free calls are used up
Your account has made 500 free calls today, across the app, the API and assistants. Nothing charged.
| Fact | Value |
| --- | --- |
| HTTP status | `429` |
| Type | `https://docs.investorliftdata.com/errors/free-call-limit` |
| Charged | Nothing is charged by a call that ends here. |
| Retry | Wait for the interval in `Retry-After`, then send the request again. |
## Common causes
- Your account has made its free calls for the UTC day, across the app, the API and assistants (scope is account).
- Free calls for the UTC day have reached their overall limit (scope is all).
## How to fix
1. Free calls start again at 00:00 UTC. Paid calls are not affected.
2. Send free calls again after resets_at, a UTC time; Retry-After gives the same wait in seconds.
## Example response body
```json
{
"type": "https://docs.investorliftdata.com/errors/free-call-limit",
"title": "Today's free calls are used up",
"status": 429,
"detail": "Today's free calls are used up. Nothing was charged. Free calls start again at 00:00 UTC. Paid calls are not affected.",
"trace_id": "00000000-0000-4000-8000-000000000000",
"scope": "account",
"limit": 500,
"resets_at": "2026-01-02T00:00:00Z",
"retry_after": 3600
}
```
## Extra body members
| Member | Meaning |
| --- | --- |
| `scope` | `account` when this account's free calls for the UTC day are used up; `all` when the overall daily limit on free calls is reached. |
| `limit` | The number of free calls that was reached for the UTC day: per account when scope is account, for all accounts together when scope is all. |
| `resets_at` | When free calls start again: the next 00:00 UTC, as an ISO 8601 UTC time (for example 2026-01-02T00:00:00Z). |
| `retry_after` | The retry interval in seconds — the same value as the Retry-After header, repeated in the body for clients that only parse JSON. |
Quote the `trace_id` to `support@investorliftdata.com`.
---
## Document: Duplicate request in flight
The same request with this Idempotency-Key is still running. Nothing charged for this copy.
URL: /errors/duplicate-request-in-flight
# Duplicate request in flight
The same request with this Idempotency-Key is still running. Nothing charged for this copy.
| Fact | Value |
| --- | --- |
| HTTP status | `409` |
| Type | `https://docs.investorliftdata.com/errors/duplicate-request-in-flight` |
| Charged | Nothing is charged by a call that ends here. |
| Retry | Wait for the interval in `Retry-After`, then send the request again. |
## Common causes
- An identical request with this key is still running.
- Another client sent the same request before the first one finished.
## How to fix
1. Wait for the time in the Retry-After header, then ask for the result again with the same key.
2. Do not switch to a new key just to bypass the running request.
## Example response body
```json
{
"type": "https://docs.investorliftdata.com/errors/duplicate-request-in-flight",
"title": "Duplicate request in flight",
"status": 409,
"detail": "An identical request carrying this Idempotency-Key is already in flight. Nothing was charged for this one. Wait for the interval given in the Retry-After header, then ask for the result again.",
"trace_id": "00000000-0000-4000-8000-000000000000",
"retry_after": 5
}
```
## Extra body members
| Member | Meaning |
| --- | --- |
| `retry_after` | The retry interval in seconds — the same value as the Retry-After header, repeated in the body for clients that only parse JSON. |
Quote the `trace_id` to `support@investorliftdata.com`.
---
## Document: This call needs a Desk
Buyer and lender data is sold only with a Desk. Your account buys with packs, which cover underwrites, property files, listings and summaries. Nothing charged.
URL: /errors/desk-only
# This call needs a Desk
Buyer and lender data is sold only with a Desk. Your account buys with packs, which cover underwrites, property files, listings and summaries. Nothing charged.
| Fact | Value |
| --- | --- |
| HTTP status | `403` |
| Type | `https://docs.investorliftdata.com/errors/desk-only` |
| Charged | Nothing is charged by a call that ends here. |
| Retry | Fix the account condition, then send the identical request again. |
## Common causes
- You called buyer and lender data, which is sold only with a Desk.
- Your account is a pack account (self-serve).
## How to fix
1. To talk about a Desk, write to `support@investorliftdata.com`.
## Example response body
```json
{
"type": "https://docs.investorliftdata.com/errors/desk-only",
"title": "This call needs a Desk",
"status": 403,
"detail": "Buyer and lender data is sold only with a Desk. Your account buys with packs, which cover underwrites, property files, listings and summaries. Nothing was charged.",
"trace_id": "00000000-0000-4000-8000-000000000000"
}
```
Quote the `trace_id` to `support@investorliftdata.com`.
---
## Document: Call never started
This call never started. Nothing charged.
URL: /errors/call-not-started
# Call never started
This call never started. Nothing charged.
| Fact | Value |
| --- | --- |
| HTTP status | `503` |
| Type | `https://docs.investorliftdata.com/errors/call-not-started` |
| Charged | Nothing is charged by a call that ends here. |
| Retry | Wait for the interval in `Retry-After`, then send the request again. |
## Common causes
- The service could not start your call.
- For a connected app, the connection approval could not be checked.
## How to fix
1. Wait for the time in the Retry-After header, then send the call again.
2. Keep the same request key when retrying the same logical call.
## Example response body
```json
{
"type": "https://docs.investorliftdata.com/errors/call-not-started",
"title": "Call never started",
"status": 503,
"detail": "This call was never started, and nothing was charged. Wait for the interval given in the Retry-After header, then send the request again.",
"trace_id": "00000000-0000-4000-8000-000000000000",
"retry_after": 5
}
```
## Extra body members
| Member | Meaning |
| --- | --- |
| `retry_after` | The retry interval in seconds — the same value as the Retry-After header, repeated in the body for clients that only parse JSON. |
Quote the `trace_id` to `support@investorliftdata.com`.
---
## Document: Address is ambiguous
This address matches several properties. Returned to your balance.
URL: /errors/ambiguous-address
# Address is ambiguous
This address matches several properties. Returned to your balance.
| Fact | Value |
| --- | --- |
| HTTP status | `422` |
| Type | `https://docs.investorliftdata.com/errors/ambiguous-address` |
| Charged | The call was charged; detail says whether the refund is applied or pending. |
| Retry | Change the request as the detail directs before sending it again. |
## Common causes
- The address matches several property records.
- The address lacks enough detail to choose one record.
## How to fix
1. Send the property's id from the candidate list, or add the unit number, city or ZIP.
2. Check the returned candidates before choosing which property to send again.
## Example response body
**Refund applied**
```json
{
"type": "https://docs.investorliftdata.com/errors/ambiguous-address",
"title": "Address is ambiguous",
"status": 422,
"detail": "The submitted address matched more than one property, so no score was produced. The price of this call has been returned to the Account's balance. Choose one of the candidates below, or add a city or ZIP, and send the request again.",
"trace_id": "00000000-0000-4000-8000-000000000000",
"candidates": [
{
"id": "sample-property-1",
"address": "1026 N Beckley Ave, Dallas, TX 75203"
},
{
"id": "sample-property-2",
"address": "100 Sample Street, Dallas, TX 75203"
}
]
}
```
**Refund pending**
```json
{
"type": "https://docs.investorliftdata.com/errors/ambiguous-address",
"title": "Address is ambiguous",
"status": 422,
"detail": "The submitted address matched more than one property, so no score was produced. Returning the price of this call to the Account's balance is pending and will be applied. Choose one of the candidates below, or add a city or ZIP, and send the request again.",
"trace_id": "00000000-0000-4000-8000-000000000000",
"candidates": [
{
"id": "sample-property-1",
"address": "1026 N Beckley Ave, Dallas, TX 75203"
},
{
"id": "sample-property-2",
"address": "100 Sample Street, Dallas, TX 75203"
}
]
}
```
## Extra body members
| Member | Meaning |
| --- | --- |
| `candidates` | The properties the submitted address matched, each carrying an id and the stored address of that property record. Choose one, or add a city or ZIP, and send the request again. |
Quote the `trace_id` to `support@investorliftdata.com`.
---
## Document: Address outside current data coverage
This address is in a state we do not cover yet. Returned to your balance.
URL: /errors/address-out-of-coverage
# Address outside current data coverage
This address is in a state we do not cover yet. Returned to your balance.
| Fact | Value |
| --- | --- |
| HTTP status | `422` |
| Type | `https://docs.investorliftdata.com/errors/address-out-of-coverage` |
| Charged | The call was charged; detail says whether the refund is applied or pending. |
| Retry | There is nothing in the request to correct; the same request gives the same answer. |
## Common causes
- The address is in a state outside current data coverage.
- You repeated an address that remains outside that coverage.
## How to fix
1. Nothing to change in the request; the docs list the states we cover.
2. Check covered_states in the body before choosing another address.
## Example response body
**Refund applied**
```json
{
"type": "https://docs.investorliftdata.com/errors/address-out-of-coverage",
"title": "Address outside current data coverage",
"status": 422,
"detail": "The submitted address is in a state this API does not cover yet, so no score was produced. The price of this call has been returned to the Account's balance.",
"trace_id": "00000000-0000-4000-8000-000000000000",
"covered_states": [
"TX"
]
}
```
**Refund pending**
```json
{
"type": "https://docs.investorliftdata.com/errors/address-out-of-coverage",
"title": "Address outside current data coverage",
"status": 422,
"detail": "The submitted address is in a state this API does not cover yet, so no score was produced. Returning the price of this call to the Account's balance is pending and will be applied.",
"trace_id": "00000000-0000-4000-8000-000000000000",
"covered_states": [
"TX"
]
}
```
## Extra body members
| Member | Meaning |
| --- | --- |
| `covered_states` | The two-letter codes of the states currently inside data coverage. |
Quote the `trace_id` to `support@investorliftdata.com`.
---
## Document: Check a property's history
Read recorded sales, short holds, ownership, permits and listing changes for one property.
URL: /data/property-history
# Check a property's history
import { Card } from "zudoku/ui/Card";
import { Calculator, RotateCcw, CircleAlert } from "zudoku/icons";
Read one property's recorded sales, detected flips, ownership, permits and listing changes.
## Choose the property
Send exactly one of `address` or `property_id` to `POST /v1/history`.
Both or neither returns 400 before charging.
Member presence counts: an empty `address` alongside `property_id` still means both.
An address resolves the property using the same matching, ambiguity and coverage rules as underwriting.
A `property_id` reads the known property directly, without address matching.
Take that identifier from an underwritten subject or comparable property; single-property underwriting accepts an address or property identifier.
Check `subject_property` to confirm the returned house.
For identifier lookup, `address_quality.code` is `identified-by-property-id`.
The house number and ZIP code were not compared on that path.
No other inputs are accepted.
Underwriting options `condition`, `rehab_budget`, `target_margin` and `overrides` are rejected.
## Send the request
Save your [API key](/definitions), the credential identifying you, in `INVESTORLIFT_API_KEY`.
Set your client timeout to at least 30 seconds.
These examples use the address input.
```bash title="cURL"
curl https://api.investorliftdata.com/v1/history \
--request POST --max-time 30 \
--header "Authorization: Bearer $INVESTORLIFT_API_KEY" \
--header "Content-Type: application/json" \
--data '{"address": "1026 N Beckley Ave, Dallas, TX 75203"}'
```
```javascript title="JavaScript"
const response = await fetch("https://api.investorliftdata.com/v1/history", {
method: "POST",
signal: AbortSignal.timeout(30_000),
headers: {
Authorization: `Bearer ${process.env.INVESTORLIFT_API_KEY}`,
"Content-Type": "application/json",
},
body: JSON.stringify({"address": "1026 N Beckley Ave, Dallas, TX 75203"}),
});
console.log(await response.json());
```
```python title="Python"
import os
import requests
response = requests.post(
"https://api.investorliftdata.com/v1/history",
headers={
"Authorization": f"Bearer {os.environ['INVESTORLIFT_API_KEY']}",
"Content-Type": "application/json",
},
json={"address": "1026 N Beckley Ave, Dallas, TX 75203"},
timeout=30,
)
print(response.json())
```
## Charges and empty records
This is a [charged call](/definitions), an API request with a price.
Each successful history call is charged the price on its [reference page](/api/history) and in the [per-call price list](/pricing/per-call-prices).
:::warning
A property with nothing on file is a charged answer.
When available county records contain no transactions, the call returns 200 with an empty `transactions` array.
Its `records_statement` says the records were checked and nothing was on file.
Unread or incomplete transaction records are a failure, not an empty history.
The call returns a 5xx and the charge is returned to your balance.
:::
## Read the answer
Show the full response (159 lines)
```json title="Full response" operation="history"
{
"flips": [],
"permits": {
"hvac": {
"work_status": null,
"most_recent_date": null,
"most_recent_job_value": null
},
"roof": {
"work_status": null,
"most_recent_date": null,
"most_recent_job_value": null
},
"permits": [
{
"tags": [
"tags_electrical",
"tags_solar"
],
"file_date": "2023-10-05",
"job_value": 16000,
"final_date": "2025-04-26",
"issue_date": "2023-10-05",
"work_status": "completed",
"construction_duration": 569
},
{
"tags": [
"tags_electrical"
],
"file_date": null,
"job_value": null,
"final_date": null,
"issue_date": "2023-04-21",
"work_status": "open",
"construction_duration": null
},
{
"tags": [
"tags_plumbing"
],
"file_date": null,
"job_value": null,
"final_date": null,
"issue_date": "2016-03-08",
"work_status": "completed",
"construction_duration": null
}
],
"plumbing": {
"work_status": "completed",
"most_recent_date": "2016-03-08",
"most_recent_job_value": null
},
"statement": "Building-permit records were retrieved for this property. The permits below are the most recent recorded work in each trade, together with any permit still open and any demolition. Each category is a conclusion derived from the county record, not a statement made by the county.",
"demolition": {
"work_status": null,
"most_recent_date": null,
"most_recent_job_value": null
},
"electrical": {
"work_status": "completed",
"most_recent_date": "2023-10-05",
"most_recent_job_value": 16000
},
"water_heater": {
"work_status": null,
"most_recent_date": null,
"most_recent_job_value": null
},
"coverage_known": true,
"has_open_permit": true
},
"summary": {
"market_state": "off-market",
"flip_detected": false,
"open_permit_work": true,
"long_tenured_owner": true,
"demolition_recorded": false,
"asking_price_reduced": null,
"last_recorded_sale_kind": "family-transfer",
"recorded_loan_available": false,
"last_recorded_buyer_type": "individual",
"permit_records_available": true,
"has_recorded_transactions": true
},
"transactions": [
{
"kind": "family-transfer",
"reo_sale": null,
"sale_date": "2008-03-29",
"buyer_type": "individual",
"sale_price": 0,
"foreclosure": null,
"inter_family": true,
"price_is_estimated": null
}
],
"market_status": {
"state": "off-market",
"sold_date": null,
"sold_price": null,
"price_reduced": null,
"days_on_market": null,
"list_price_low": null,
"rental_listing": false,
"list_price_high": null,
"failed_listing_date": "2023-08-09",
"list_price_low_date": null,
"list_price_high_date": null,
"original_listing_date": null
},
"seller_position": {
"basis": "No recorded loan is available for this property, so no payoff estimate and no equity estimate can be made. Nothing is inferred from that absence: it does not mean the property is owned free and clear, and it does not mean the last purchase was made in cash. What this section does report is how long the current owner has held title, taken from the chain of title above.",
"days_owned": 6783,
"owned_since": "2008-03-29",
"recorded_loan_available": false
},
"subject_property": {
"facts": {
"bathrooms": {
"value": 2,
"source": "county-record"
},
"year_built": {
"value": 1935,
"source": "county-record"
},
"square_feet": {
"value": 2047,
"source": "county-record"
},
"property_type": {
"value": "Single Family",
"source": "county-record"
},
"lot_size_square_feet": {
"value": 7100,
"source": "county-record"
}
},
"address": {
"zip": "75203",
"city": "Dallas",
"state": "TX",
"street": "1026 N Beckley Ave"
},
"property_id": "00000000000000000000000000000001",
"address_quality": {
"code": "zip-confirmed",
"statement": "The house number matched exactly one county record, and that record sits in the same ZIP code your address resolved to. Check the address and property facts in this block before relying on these numbers."
},
"outside_model_range": {
"flagged": false,
"reasons": []
}
},
"records_statement": "The recorded sale and deed records for this property were checked. The transactions below are what is on file, newest first. Each category and each buyer type is a conclusion derived from the county record, not a statement made by the county."
}
```
The response contains `subject_property`, `records_statement`, `transactions`, `flips`, `seller_position`, `permits`, `market_status` and `summary` only.
The [reference](/api/history) describes each member.
## Transactions
`transactions` lists recorded events newest first.
A missing date or price is `null` and does not remove the event.
Events without `sale_date` appear last; they are not identified as the oldest.
The `kind` is `standard-sale`, `foreclosure`, `bank-repo`, `family-transfer`, `new-construction` or `unknown`.
The `buyer_type` is `individual`, `company`, `trust` or `unknown`.
These categories are derived conclusions from documents, not facts asserted by the county.
Treat `unknown` as not established.
A nominal-price transfer is never a `standard-sale`.
`price_is_estimated: true` means the record's own estimate, not recorded consideration.
## Flips
`flips` lists buy/sell pairs with **540 days or fewer** between recorded sale dates.
The boundary is inclusive: a hold of exactly 540 days is a flip.
Both legs need real market prices; family transfers are excluded.
`days_held` can be zero for a same-day close.
`percentage_gain` can be negative; `0.4195` means a 41.95% gain.
`seller_posture` is `professional`, `private` or `unknown`.
A company or trust is professional; an individual is private.
An unrecorded seller is `unknown`, not automatically private.
## Ownership and loans
`seller_position.owned_since` uses the newest dated transaction.
`seller_position.days_owned` is whole days since that date.
Unestablished ownership length or a future sale date gives `null`, not a negative duration.
`recorded_loan_available` is `false`: no per-property loan, mortgage or lien record is available here.
There is no payoff or equity estimate.
Missing loan records do not establish free-and-clear ownership or a cash purchase.
An estimate from a loan recorded at the last sale would miss later refinances.
It would need a range, not a single figure.
Ownership length comes from the transaction chapter.
## Permits
Read `coverage_known`, not the array length.
It is `true` only when at least one permit record was returned.
Empty records cannot distinguish no permits, unavailable county records or a nonexistent property.
Unavailable or failed permit lookups return an empty `permits` array, `coverage_known: false` and a plain `statement`.
The status facts are `null`; these records never establish that the property has no permits.
A failed permit lookup leaves the other history chapters intact, and the successful call is still charged.
The list selects the most recent permit in each category, plus every open permit and every demolition.
Categories are `roof`, `hvac`, `water_heater`, `electrical`, `plumbing` and `demolition`.
Each category has `most_recent_date`, `most_recent_job_value` and `work_status`; missing values are `null`.
Each permit has recorded dates, `job_value`, `tags` and `construction_duration`.
Missing dates or values are `null`; tags are fixed categories, not county free text.
`construction_duration` has no established unit; do not assume days or weeks.
`work_status` is `completed`, `inactive`, `open`, `in-review` or `unknown`.
Unknown status does not mean completed work.
`has_open_permit` is `true` for open or in-review work.
It is `false` only when records were retrieved and every status was readable.
Unavailable records or any unreadable status give `null` when no open work is established.
No permit number or reference identifier is returned, because either could retrieve a public record naming the applicant.
No permit count is returned: the underlying count is a page size, not a total.
## Listing changes
`market_status` describes the current listing.
A rental is not a sale listing: `rental_listing` marks it, and sale-specific dates, prices and days on market are withheld.
Unknown status remains `unknown`; a cancelled listing is not reported as `failed`.
`price_reduced` is `true` only when the low asking price was recorded after the high price.
A low price recorded first means the price went up.
If either price or either date is missing, the result is `null`.
## Summary
`summary` is computed from the chapters, not a second read of the records.
Its members are booleans, fixed categories or `null`, with no free text.
Map these values to your own fields or decision rules.
A value not established by its chapter is `null` or `unknown`, never `false`.
`false` means checked and no.
Unavailable permit records leave `open_permit_work` and `demolition_recorded` as `null`.
`long_tenured_owner` uses the configured ownership-duration threshold.
It is `null` when ownership length cannot be established.
## Names and identifiers
This history response omits buyer, seller and owner names, their mailing addresses and recorder document numbers.
It publishes derived categories instead.
## Errors and safe retries
The [Trace ID](/definitions), the identifier for a call, is in every response's `trace-id` header.
Quote it when contacting support.
`property-not-found` returns 422 when no record matches the identifier; the charge is returned to your balance.
History never returns `unscorable-address`, because it does not underwrite the property.
Send an `Idempotency-Key` to replay a stored successful answer without another charge.
Refunded calls or calls without a stored answer run again; the replay window also matters.
[Retry safely](/production/retry-safely) explains each case.
Reusing a stored history key and body for `/v1/predict` is a conflict, not a replay.
The endpoints answer different questions.
## Next steps
---
## Document: Look up property and market data
Find published property and market data calls by the information you hold.
URL: /data/property-and-market-data
# Look up property and market data
import { Card } from "zudoku/ui/Card";
import { History, CircleAlert, RotateCcw } from "zudoku/icons";
Find the published call that matches the property or market information you hold.
## Find a call
| If you hold… | Call | Price |
| --- | --- | --- |
| An address | [Find a property by address](/api/properties#find-a-property-by-address) | $0.10 per address found |
| A property | [Property record](/api/properties#read-a-propertys-record) | $0.10 per property |
| | [Ownership history](/api/properties#read-a-propertys-ownership-history) | $0.10 per property |
| | [Listing history](/api/properties#read-a-propertys-listing-history) | $0.10 per property |
| | [Investors who buy homes like this one](/api/investors#rank-the-investors-who-buy-homes-like-this-one) | $2.00 per call, up to 25 buyers · desks only |
| A place (a point and radius, a street line with a ZIP, a list of ZIPs, or a box) | [For-sale listings near a place](/api/listings-and-sales#list-properties-for-sale-near-a-place) | $0.25 per page |
| | [Rentals near a place](/api/listings-and-sales#list-rentals-near-a-place) | $0.25 per page |
| | [Recent sales near a place](/api/listings-and-sales#list-recent-sales-near-a-place) | $0.25 per page |
| | [Investor purchases near a property](/api/investors#list-investor-purchases-near-a-place) | $0.50 per page · desks only |
| | [Active investors in a ZIP](/api/investors#list-the-investors-buying-in-zip-codes) | $1.00 per list · desks only |
| | [Investor activity summary](/api/investors#summarize-the-investor-purchases-in-a-place) | Free |
| | [ZIP snapshot](/api/zip-codes-and-coverage#read-a-zip-codes-snapshot) | Free |
| | [Snapshot of several ZIPs](/api/zip-codes-and-coverage#read-a-snapshot-of-several-zip-codes) | Free |
| | [Sellers in a ZIP](/api/zip-codes-and-coverage#list-the-sellers-in-a-zip-code) | $1.00 per list · desks only |
| | [Lenders active in a place](/api/lenders#list-the-lenders-active-in-a-place) | $1.00 per list · desks only |
| An investor company (its identifier, or its name) | [Find an investor company by name](/api/investors#find-an-investor-company-by-name) | Free · desks only |
| | [Investor company profile](/api/investor-companies#read-an-investor-companys-profile) | $0.50 per company · desks only |
| | [Investor company purchases](/api/investor-companies#list-an-investor-companys-purchases) | $0.50 per page · desks only |
| | [Investor company sales](/api/investor-companies#list-an-investor-companys-sales) | $0.50 per page · desks only |
| A lender | [Lender profile](/api/lenders#read-a-lenders-profile) | $0.50 per lender · desks only |
| | [Lender loans](/api/lenders#list-a-lenders-loans) | $0.50 per page · desks only |
| | [Who a lender lends to](/api/lenders#list-who-a-lender-lends-to) | $0.50 per page · desks only |
| A purchase or a listing | [One investor purchase](/api/investors#read-one-investor-purchase) | $0.10 per item · desks only |
| | [One listing](/api/listings-and-sales#read-one-listing-in-full) | $0.10 per item |
| Nothing yet (start here to see where the data covers) | [Where the data covers](/api/zip-codes-and-coverage#see-where-the-data-service-has-data) | Free |
## Properties
Read property records and their recorded history. Choose the call matching your input, then open its reference for the request and response details.
## Listings and sales
Use listing and sale records to research a property or place. Each published call above links to its own request parameters and answer.
## Investors
Research investor activity with the published calls above. Open a call’s reference to see which inputs it accepts and what it returns.
## Investor companies
Research an investor company using its identifier. The linked references explain the records available from each published call.
## Lenders
Research lenders through the published calls above. Use each reference to check its required inputs and the details of its answer.
## ZIP codes and coverage
Explore records by ZIP code or check where the data is available. Use the finder to select a published call and read its reference.
## Coverage
A place outside the data’s coverage is refused before anything is charged, as [`400 invalid-request`](/errors/invalid-request).
## The identifiers
Properties, investor purchases and listings are identified by 32 lowercase hexadecimal characters, with no prefix. An identifier obtained before 9 October 2026 no longer works: look the property up again. A value outside that form is refused before anything is charged. An identifier can also change when the data is refreshed: if one stops working, look the property up again.
## Next steps
---
## Document: Use these docs with an AI tool
Give your AI tool plain-text guides and the API description.
URL: /agents/docs-for-ai
# Use these docs with an AI tool
import { Card } from "zudoku/ui/Card";
import { Rocket, Bot } from "zudoku/icons";
These are the developer docs for the Investorlift Data Services API. Start with /quickstart. The API reference is generated from the OpenAPI description at https://docs.investorliftdata.com/openapi.json. Error types are stable URLs under /errors/. Each call is charged its listed price from your balance (see /pricing/per-call-prices); /v1/validate is free.
## Choose a plain-text copy
Give your tool [the guide list](/llms.txt), or [all guides in one file](/llms-full.txt).
Every guide has a `.md` copy at its page address, such as [quickstart.md](/quickstart.md).
These copies keep the example requests and full answers.
For calls, parameters and response fields, Download the API description (OpenAPI).
The guide files do not list the API reference's operations.
## Open a guide in your assistant
Open **More actions** beside **Copy page** at the top of a guide.
Choose **Use in Claude** or **Use in ChatGPT** to take that page into your assistant.
You can also copy the page as Markdown.
## Next steps
---
## Document: Connect an AI assistant
Connect Claude, Cursor, VS Code or another MCP client with a key or account sign-in.
URL: /agents/connect
# Connect an AI assistant
import { Card } from "zudoku/ui/Card";
import { KeyRound, RotateCcw, BookOpen } from "zudoku/icons";
Connect an assistant with an API key or by signing in to your [account](/definitions), the record that owns your keys and the money you add.
Model Context Protocol (MCP) lets an assistant discover tools and call the API on your behalf.
Use this server address:
```text title="Server address"
https://api.investorliftdata.com/mcp
```
There are two ways to connect:
- **With an API key:** send it as an `Authorization: Bearer` header, as your own code does.
- **By signing in:** use [Connecting with your account instead of a key](#connecting-with-your-account-instead-of-a-key) when your client offers that choice.
## Set up your client
The key examples use `YOUR_API_KEY` as a placeholder.
Replace it with your own key before connecting.
Keep settings containing your key out of source control and shared files.
Clients with sign-in can use the server address without pasting a key.
### Claude
In Claude Code, add **Investorlift Data Services** using the local name `investorliftDataServices`:
```bash title="Investorlift Data Services · Claude Code"
claude mcp add --transport http investorliftDataServices 'https://api.investorliftdata.com/mcp' --header 'Authorization: Bearer YOUR_API_KEY'
```
For Claude's account connection, add a custom connector named **Investorlift Data Services**, paste the server address, and follow the sign-in steps below.
### Cursor
Add this to your MCP settings:
```json title="Cursor"
{
"mcpServers": {
"Investorlift Data Services": {
"url": "https://api.investorliftdata.com/mcp",
"headers": {
"Authorization": "Bearer YOUR_API_KEY"
}
}
}
}
```
### VS Code
Add **Investorlift Data Services** with the local name `investorliftDataServices` in your MCP settings:
```json title="Investorlift Data Services · VS Code"
{
"servers": {
"investorliftDataServices": {
"type": "http",
"url": "https://api.investorliftdata.com/mcp",
"headers": {
"Authorization": "Bearer YOUR_API_KEY"
}
}
}
}
```
### Another client
For a client that accepts `mcpServers` settings, use:
```json title="Generic client"
{
"mcpServers": {
"Investorlift Data Services": {
"url": "https://api.investorliftdata.com/mcp",
"headers": {
"Authorization": "Bearer YOUR_API_KEY"
}
}
}
}
```
Other clients may ask for the name, URL and header separately.
The [MCP reference](/api/mcp) also provides setup options.
## Use the four tools
**Four tools are available.**
- `score_property` scores a property by address or identifier, like `POST /v1/predict`.
- `score_property_batch` scores a list, like `POST /v1/predict/batch`, with an answer for each property.
- `property_history` reads recorded transactions, flips, listings and permits, like `POST /v1/history`.
- `validate_request` checks a request without underwriting it, like `POST /v1/validate`; it is free.
Each tool costs what its corresponding call costs.
Charged tools are paid from the same account's [balance](/balance).
There is no separate MCP price or separate balance.
The same validation, [errors](/errors), [refund rules](/production/handle-errors) and [safe retries](/production/retry-safely) apply.
`score_property`, `validate_request` and `property_history` each take **exactly one** of `address` or `property_id`.
Both or neither is rejected before charging.
A property identifier from an underwriting answer can be sent back to read that record's history.
`score_property` and `validate_request` also take the optional `partial_answer` flag, on the same terms and at the same cost as the HTTP call ([Accuracy and limits](/scoring/accuracy-and-limits)).
A tool is not a versioned path.
Tools re-enter the **current** version's operations and always return the current answer shape.
There is no older-shaped tool.
The [version-path notice window](/api#versioning) does not apply to tools.
## Connecting with your account instead of a key
Some clients, including Claude, can connect by signing you in, so you never handle a key.
If your client offers a **Connect** button, use it to sign in and approve access on the Investorlift Data Services screen.
The client never sees your API key or password.
A connected app can act as your account and read account details, including your [balance](/balance), the money available to spend.
Each underwrite is paid from that same balance at your account's price.
The approval screen explains that access before you agree.
Find approved connections under **Settings → Security** in the app, with their connection dates.
Disconnecting an app cuts off its access: its next call fails authentication and spends nothing.
Server-to-server integrations can keep using a key when nobody is present to sign in.
## Allow the connection through your firewall
Allow `/v1` and `/mcp`, plus `/.well-known/oauth-protected-resource` and `/.well-known/oauth-protected-resource/mcp`.
The discovery document at those last two paths is read before assistant sign-in.
Blocking it stops sign-in before the assistant can connect.
## Next steps