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

<CodeTabs syncKey="request-language" hideIcon>

```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())
```

</CodeTabs>

## Read each result

<details>
<summary>Show the full response (100 lines)</summary>

```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"
    }
  ]
}
```

</details>

`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

<div className="docs-next-steps">

<Card className="docs-next-card"><a href="/scoring/score-a-property"><Code2 className="docs-next-icon" size={18} strokeWidth={1.5} aria-hidden="true" focusable="false" /><strong>Score a property</strong><span className="docs-next-description">Make the default call and read its answer.</span><span className="docs-next-arrow" aria-hidden="true">→</span></a></Card>

<Card className="docs-next-card"><a href="/production/retry-safely"><RotateCcw className="docs-next-icon" size={18} strokeWidth={1.5} aria-hidden="true" focusable="false" /><strong>Retry safely</strong><span className="docs-next-description">Protect retries with a request key.</span><span className="docs-next-arrow" aria-hidden="true">→</span></a></Card>

<Card className="docs-next-card"><a href="/production/handle-errors"><CircleAlert className="docs-next-icon" size={18} strokeWidth={1.5} aria-hidden="true" focusable="false" /><strong>Handle errors</strong><span className="docs-next-description">Choose the right fix for a refusal.</span><span className="docs-next-arrow" aria-hidden="true">→</span></a></Card>

</div>
