Metadata-Version: 2.5
Name: htag-sdk
Version: 3.2.0
Summary: Official Python SDK for the HtAG Location Intelligence API — address search, property data, and market analytics for Australia
Project-URL: Homepage, https://developer.htagai.com
Project-URL: Documentation, https://developer.htagai.com
Project-URL: Repository, https://github.com/HtaG-Analytics/htag-sdk-python
Project-URL: Issues, https://github.com/HtaG-Analytics/htag-sdk-python/issues
Author-email: Sasa Savic <sasa.savic@htag.com.au>
License-Expression: MIT
License-File: LICENSE
Keywords: address,api,australia,htag,location-intelligence,market-data,property,real-estate,sdk
Classifier: Development Status :: 4 - Beta
Classifier: Intended Audience :: Developers
Classifier: Programming Language :: Python :: 3
Classifier: Programming Language :: Python :: 3.9
Classifier: Programming Language :: Python :: 3.10
Classifier: Programming Language :: Python :: 3.11
Classifier: Programming Language :: Python :: 3.12
Classifier: Programming Language :: Python :: 3.13
Classifier: Typing :: Typed
Requires-Python: >=3.9
Requires-Dist: httpx>=0.27
Requires-Dist: pydantic>=2.0
Description-Content-Type: text/markdown

# htag-sdk

The official Python SDK for the [HtAG](https://htagai.com) Location Intelligence API.

Provides typed, ergonomic access to Australian address data, property valuations, sales records, and market analytics with both synchronous and asynchronous clients.

```python
from htag_sdk import HtAgApi

client = HtAgApi(api_key="sk-...", environment="prod")

results = client.address.geocode("100 George St Sydney")
for r in results.results:
    print(f"{r.address_label}  ({r.address_key})")
```

## What's new in 3.2.0

Sold and rented search now say how many rows match and where the next page
starts. `SoldPropertiesResponse` and `RentedPropertiesResponse` gain
`total_count` (every match, across all pages), `has_more`, `next_offset`, and
the `limit` / `offset` the server applied (a shared `PaginationMeta` model).
`total` is unchanged: the rows in this page, and the billable row count.
New `iter_sold_search()` / `iter_rented_search()` iterators (sync and async)
follow `next_offset` for you; `max_rows` caps what is fetched and billed. See
[Pagination](#pagination). Additive: 3.1 code keeps working, and the new fields
read `None` against a server that predates them.

`GeocodeRecord` gains the nullable `score` the API documents: the trigram
similarity (0-1) between the result's `address_key` and the normalised input.

## What's new in 3.1.0

Failed async agent jobs now carry a structured error. `AgentJobStatus.error` stays a
string (the message to show), and the new `AgentJobStatus.error_detail` holds the
`AgentJobError` envelope (`error` code, `message`, optional `details`); it is `None`
for jobs that failed before the change. `job.error_code` gives the stable code
(`suburb_not_found`, `no_market_data`, `internal_error`, ...). Additive: 3.0 code
that treats `error` as a string keeps working.

## What's new in 3.0.0

3.0.0 is a **major** release: a response model changed and implicit page sizes
changed (see [Breaking changes / migrating from 2.0.x](#breaking-changes--migrating-from-20x)).
It keeps everything in 2.0.0 and 2.0.1 (see
[Migrating to 2.0.0](#migrating-to-200) if you are coming from 1.9.x) and adds
the work that landed on `main` after 2.0.1. The npm package shipped 2.0.0
only; both packages now share the version number 3.0.0.

### Breaking changes / migrating from 2.0.x

**1. `StandardiseResult.standardised_address` is a different model.**
It was `AustralianAddressComponents`, a 14-field GNAF-style record whose names
do not match what `POST /v1/address/standardise` returns, so most components
validated to `None`. It is now `StandardisedAddressComponents` (exported from
`htag_sdk`), which has exactly the seven fields the API sends.

| `AustralianAddressComponents` attribute (2.0.x) | `StandardisedAddressComponents` (3.0.0) |
|---|---|
| `flat_number` | `flat_or_unit_number` |
| `number_first` | `street_number` |
| `locality_name` | `suburb_or_locality` |
| `street_name` | `street_name` (unchanged) |
| `street_type` | `street_type` (unchanged) |
| `state` | `state` (unchanged) |
| `postcode` | `postcode` (unchanged) |
| `building_name`, `flat_type`, `level_type`, `level_number`, `number_last`, `lot_number`, `street_suffix` | **removed**, no replacement: the API does not return them |

`StandardiseResult` also gains `input_index`, and `BatchStandardiseResponse`
gains `total`. `AustralianAddressComponents` is still exported but is no
longer used by `StandardiseResult`. Code that read a removed attribute now
raises `AttributeError` instead of silently getting `None`.

```python
# 2.0.x
addr = row.standardised_address
print(addr.number_first, addr.street_name, addr.locality_name, addr.flat_number)

# 3.0.0
addr = row.standardised_address
print(addr.street_number, addr.street_name, addr.suburb_or_locality, addr.flat_or_unit_number)
```

**2. An omitted `limit` no longer sends a value of its own.** In 2.0.x these
methods sent `limit=100` (`limit=1000` on Spatial H3) when you did not pass
`limit`. In 3.0.0 the signature default is `None`, nothing is sent, and the
**server default** applies. Defaults are taken from the API spec:

| Methods (sync and async) | Was sent | Server default now |
|---|---|---|
| `markets.summary`, `growth_cumulative`, `growth_annualised`, `supply`, `supply_long_slope`, `supply_short_slope`, `demand`, `demand_long_slope`, `demand_short_slope`, `scores`, `fundamentals`, `cycle`, `risk`, `commentary` | 100 | **25** |
| `markets.trends.price`, `rent`, `yield_history`, `search_index`, `hold_period`, `growth_rates`, `years_to_own`, `demand_profile`, `stock_on_market`, `inventory`, `days_on_market`, `clearance_rate`, `vacancy`, `building_approvals` | 100 | **25** |
| `reference.locality`, `lga`, `address_keys` | 100 | **25** |
| `spatial.h3.price_rent`: `median_price`, `median_rent`, `median_yield`, `median_years_to_own` | 1000 | **100** |
| `spatial.h3.market`: `median_dom`, `median_dorm`, `median_hold_period`, `median_lot_size`, `median_estimated_age`, `median_holding_return`, `median_discounting`, `median_listed_price` | 1000 | **100** |
| `spatial.h3.socio_env`: `irsd`, `irsad`, `ier`, `ieo`, `public_housing`, `private_rental`, `pct_flood`, `pct_bushfire` | 1000 | **100** |
| `spatial.h3.concordance`: `lga_to_h3`, `locality_to_h3`, `sa2_to_h3`, `sa3_to_h3`, `sa4_to_h3`, `gcc_to_h3` | 1000 | **100** |

`offset` on the Spatial H3 methods is likewise no longer sent unless you pass
it (the server default is 0).

What to do: pass `limit` explicitly wherever you relied on the old implicit
page size, and page by advancing `offset` by the `limit` you sent until a
response returns fewer rows than that (on the property searches, until
`total < limit`). Do not assume a page size.

The property searches (`sold_search`, `rented_search`) were already sending no
`limit`, so they are unaffected.

**3. Markets responses no longer invent `limit` and `offset`.** Every
`markets.*` and `markets.trends.*` method returns `BaseResponse`, which mirrors
what the API sends: `{results, total}`. In 2.0.x the SDK filled the missing
`limit` and `offset` with `0`, so `resp.limit == 0` even when you asked for 50
rows. They are now `None` unless the response actually carries them; use the
`limit` and `offset` you sent. `markets.query` is the one endpoint that echoes
them, and it now returns the new `PaginatedResponse`, where both are always set.

```python
resp = client.markets.trends.price(level="suburb", area_id=["SAL10001"], limit=50)
print(resp.total, resp.limit)   # 2.0.x: <rows> 0    3.0.0: <rows> None

page = client.markets.query({"level": "suburb", "property_types": ["house"], "limit": 5})
print(page.limit, page.offset)  # 5 0 (echoed by the API)
```

### Added

* `property.rented_search(...)` accepts the range filters `bedrooms_min`,
  `bedrooms_max`, `bathrooms_min`, `bathrooms_max`, `car_spaces_min`,
  `car_spaces_max`, `land_area_min`, `land_area_max` and the flag
  `include_same_suburb_no_lat_lon` (sync and async).
* `property.sold_search(...)` accepts `include_same_suburb_no_lat_lon`
  (sync and async), already in the API spec for sold search.
* `StandardisedAddressComponents` is exported.
* `PaginatedResponse` (exported from `htag_sdk`), returned by `markets.query`.
* `reference.address_keys(...)` accepts `own_status` (sync and async), e.g.
  `own_status="rented"`. The API already supported this filter.
* `PriceHistoryOut.bedrooms`: `markets.trends.price` rows carry the bedroom
  bucket (`"All"`, `"3"`, ...), which the model used to drop.
* The package now ships its MIT `LICENSE` file.

### Fixed

* **Sold search sends the canonical filter names** (carried forward from
  2.0.0): `propertyType`, `saleValueMin`, `saleValueMax`,
  `includeSaleValueUnknown`, `bedroomsMin`, `bedroomsMax`, `bathroomsMin`,
  `bathroomsMax`, `carSpacesMin`, `carSpacesMax`, `startDate`, `endDate`,
  `landAreaMin`, `landAreaMax`, `includeSameSuburbNoLatLon`. `address_key`
  stays snake_case. Public Python argument names are unchanged, and `False`
  and `0` are sent rather than dropped.
* **Fractional `land_area`** (carried forward from 2.0.1): sold-search
  responses accept values such as `418.5` without failing the whole page; the
  field is `Optional[float]`.
* **Validation errors name the field.** `ValidationError.message` used to be
  just `Invalid request`; it now includes the first failing field, e.g.
  `Invalid request (query.address: Field required)`, with `; +N more` when
  several failed (the full list stays in `e.body["errors"]`). An object-valued
  `detail` (such as `unknown_query_parameters`) gives its `message` instead of
  a Python dict repr.
* **Spec/live field-name drift is tolerated.** `YieldHistoryOut.yield_val`
  also accepts `yield`, and `DemandProfileOut.property_type` also accepts
  `dwelling_type` (the published spec uses those names; the live API sends
  `yield_val` and `property_type`). Attribute names are unchanged.

### Corrected documentation and API behaviour

Responses document nullable `street_address`, `suburb`, `state`, `postcode`
and `property_type` on sold/rented search, and sold-search `property_type` is
now the canonical bucket (`house`, `unit`, `semi-detached`, `land`,
`lifestyle-retirement`, `rural`, `other`). These are API behaviours; the
models already accepted them.

## Migrating to 2.0.0

2.0.0 is a **major** release: public bedroom-filter types are narrower and
unsupported inputs can now fail locally before a request is sent. Review the
migration changes below before upgrading from 1.9.6.

### Breaking changes in this package

| Change | 1.9.6 | 2.0.0 | What to do |
|--------|-------|-------|------------|
| `bedrooms` annotation on public bedroom-filtered trends and `markets.summary` | `Optional[Union[str, List[str]]]` | `Optional[str]` | Pass one value. `bedrooms=["3"]` is still accepted and unwrapped at runtime; `bedrooms=["3", "4"]` is not. |
| `markets.trends.price/rent/yield_history/years_to_own`, `markets.summary` with two bedroom values | repeated the key on the wire; the API silently kept whichever value arrived last | raises `InvalidParameterError` before sending | Issue one request per bedroom count and combine the results. |
| `markets.trends.demand_profile(property_type=…)` (also `period_end_min` / `period_end_max`) | issued a request; the API did not apply the filter | raises `InvalidParameterError` before sending | Remove the unsupported argument; it does not provide server-side filtering. |
| `intent_hub.list_event_types(category=…)`, `internal.address.insights(street_loc_pid=…)` | silently ignored by the API | raise `InvalidParameterError` | Drop the argument. |

`InvalidParameterError` subclasses both `HtAgError` and `ValueError`, so an
existing `except ValueError:` guard keeps working.

### Request and page-size changes

* **Sold-search filter names are corrected in this package.**
  `property.sold_search` sends the API's canonical camelCase filter names,
  including `startDate`, `endDate`, `saleValueMin` and `saleValueMax`.
  Public SDK parameter names are unchanged. `address_key` stays snake_case;
  rented-search and other endpoint mappings are unchanged by this correction.
* Internal AVM `address` and `address_key` lists are sent as repeated keys,
  preserving commas inside each address. Other Python list parameters already
  used repeated keys in the checked 1.9.6 package.
* **`property.rented_search` no longer pins `limit=500`.** Omitting it lets
  the server choose the page size (25 in the current search contract).
  Pass an explicit limit of 500 to preserve the old requested page size.

### Compatibility and verification scope

The sold wire correction targets both the pre-alias route source `6d549400`
and current-main route source `9877c67e`: both declare the canonical camelCase
filter names. This removes the package's dependency on the newer server aliases.
Local real-handler replay tests cover these two source baselines; they are not
authenticated live DEV or production tests, and `6d549400` is **not** a verified
production API image identifier.

Compatibility checks also cover a captured set of valid calls from the published
1.9.6 npm and PyPI packages. Those tested calls are not rejected by the newer
unknown-parameter guard. This is not a guarantee for every 1.9.6 input, older
versions, or custom HTTP clients. Existing installed clients do not receive this
package correction until upgraded.

### Server-side changes are separate

PR #381 added sold-search snake_case aliases and rejects unknown query names on
both sold and rented search. It has been deployed to production since
2026-09-19. Installing this package does not deploy server changes; a
self-hosted or pinned older API may still behave as described below.
The sold wire correction above works with either reviewed route version.

* The pre-alias sold route declares camelCase filters; undeclared snake_case
  filters can be ignored. The newer route accepts both and prefers camelCase
  when both are supplied.
* The reviewed rented route already accepts both naming styles. It is still
  affected by the newer **unknown-parameter validation**, like sold search.
* With the newer validation, unknown names return HTTP 400 before repository
  work. Without it, an undeclared name can be ignored. Check your target
  environment's API contract before relying on this server-side validation.

### Known limitations

* The reviewed API accepts `limit` and `offset` on six reference concordance
  `*-to-h3` routes; those parameters were absent from the reviewed spec.
* Client-side refusals of unsupported filters are package behavior, not evidence
  of a server deployment. No live test or registry-publication outcome is implied
  by these migration notes.

## Installation

```bash
pip install htag-sdk
```

Or with your preferred package manager:

```bash
uv add htag-sdk
poetry add htag-sdk
```

Requires Python 3.9+.

## Quick Start

### 1. Get an API Key

Sign up at [developer.htagai.com](https://developer.htagai.com) and create an API key from the Settings page.

### 2. Create a Client

```python
from htag_sdk import HtAgApi

client = HtAgApi(
    api_key="sk-org--your-org-id-your-key-value",
    environment="prod",   # "dev" or "prod"
)
```

Or use a custom base URL:

```python
client = HtAgApi(api_key="sk-...", base_url="https://api.staging.htagai.com/v1")
```

`base_url` is used verbatim as the prefix for every request path, and the
Python method paths do not include `/v1` (unlike the TypeScript SDK), so
include the version prefix. The internal, Intent Hub and agents namespaces use
the same base when `base_url` is set; use `environment=` if you need those.

### 3. Make Requests

```python
# Geocode an address
results = client.address.geocode("15 Miranda Court Noble Park")
print(results.total, "matches")

# Get property estimates
est = client.property.estimates(address_key="15MIRANDACOURTNOBLEPARKvic3174")
for record in est.results:
    print(f"Price estimate: ${record.price_estimate:,}")
    print(f"Last sold: ${record.last_sold_price:,} on {record.last_sold_date}")

# Close when done (or use a context manager)
client.close()
```

## Usage

### Address Geocode

Resolve a free-text address to structured location data with geographic identifiers.

```python
results = client.address.geocode(
    "100 Hickox St Traralgon",
    limit=5,         # max results (1 - 50)
)

for match in results.results:
    print(f"{match.address_label}")
    print(f"  Key: {match.address_key}")
    print(f"  Location: {match.lat}, {match.lon}")
```

### Address Standardisation

Standardise raw address strings into structured, canonical components.

```python
result = client.address.standardise([
    "12 / 100-102 HICKOX STR TRARALGON, VIC 3844",
    "15a smith st fitzroy vic 3065",
])

for item in result.results:
    if item.error:
        print(f"Failed: {item.input_address} -- {item.error}")
    else:
        addr = item.standardised_address
        print(f"{item.input_address}")
        print(f"  -> {addr.street_number} {addr.street_name} {addr.street_type}")
        print(f"     {addr.suburb_or_locality} {addr.state} {addr.postcode}")
        print(f"  Key: {item.address_key}")
```

### Address Environment

Retrieve environmental risk data for an address including flood, bushfire, heritage, and zoning.

```python
env = client.address.environment(address="15 Miranda Court, Noble Park VIC 3174")
for record in env.results:
    print(f"Bushfire: {record.bushfire}, Flood: {record.flood}")
    print(f"Heritage: {record.heritage}, Zoning: {record.zoning}")
```

### Address Demographics

Retrieve socio-economic indices (SEIFA) and housing tenure data.

```python
demo = client.address.demographics(address="15 Miranda Court, Noble Park VIC 3174")
for record in demo.results:
    print(f"IRSAD: {record.IRSAD}, IER: {record.IER}")
```

### Property Summary

Retrieve physical property attributes for an address.

```python
summary = client.property.summary(address_key="100102HICKOXSTREETTRARALGONVIC3844")
for record in summary.results:
    print(f"Type: {record.property_type}")
    print(f"Beds: {record.beds}, Baths: {record.baths}, Parking: {record.parking}")
    print(f"Land: {record.lot_size} sqm, Floor: {record.floor_area} sqm")
```

### Property Estimates

Retrieve valuation estimates and transaction history for an address.

```python
est = client.property.estimates(address_key="100102HICKOXSTREETTRARALGONVIC3844")
for record in est.results:
    print(f"Price estimate: ${record.price_estimate:,}")
    print(f"Rent estimate: ${record.rent_estimate}/wk")
    print(f"Last sold: ${record.last_sold_price:,} on {record.last_sold_date}")
```

### Property Market

Retrieve market position indicators for an address.

```python
mkt = client.property.market(address_key="100102HICKOXSTREETTRARALGONVIC3844")
for record in mkt.results:
    print(f"Rental %: {record.rental_percentage:.0%}")
    print(f"Years to own: {record.years_to_own}")
    print(f"Hold period: {record.hold_period} years")
```

### Sold Property Search

Search for recently sold properties near an address or coordinates.

```python
sold = client.property.sold_search(
    address="100 George St, Sydney NSW 2000",
    radius=2000,              # metres
    property_type="house",
    sale_value_min=500_000,
    sale_value_max=2_000_000,
    bedrooms_min=3,
    start_date="2024-01-01",
)

print(f"showing {sold.total} of {sold.total_count} sales")
for prop in sold.results:
    price = f"${prop.sold_price:,.0f}" if prop.sold_price else "undisclosed"
    print(f"  {prop.street_address}, {prop.suburb} -- {price} ({prop.sold_date})")
```

All filter parameters are optional:

| Parameter | Type | Description |
|-----------|------|-------------|
| `address` | str | Free-text address to centre the search on |
| `address_key` | str | GNAF address key |
| `lat`, `lon` | float | Coordinates for point-based search |
| `radius` | int | Search radius in metres (default 2000, max 5000) |
| `proximity` | str | `"any"`, `"sameStreet"`, or `"sameSuburb"` |
| `property_type` | str \| list[str] | Canonical groups: `"house"`, `"unit"`, `"semi-detached"`, `"land"`, `"lifestyle-retirement"`, `"rural"`, `"other"` |
| `sale_value_min`, `sale_value_max` | float | Price range filter (AUD) |
| `bedrooms_min`, `bedrooms_max` | int | Bedroom count range |
| `bathrooms_min`, `bathrooms_max` | int | Bathroom count range |
| `car_spaces_min`, `car_spaces_max` | int | Car space range |
| `start_date`, `end_date` | str | Date range (ISO 8601, e.g. `"2024-01-01"`) |
| `land_area_min`, `land_area_max` | int | Land area in sqm |
| `include_same_suburb_no_lat_lon` | bool | With `address_key`, also return same-suburb sales that have no coordinates (server default `False`) |
| `limit` | int | Rows per page (1-1000). Omitted → server default **25** |
| `offset` | int | Rows to skip before `limit` (>= 0). Omitted → **0** |

> Sold-search filters are sent using the API's canonical camelCase names.
> The public Python parameter names above are unchanged; `address_key` remains snake_case.

#### Pagination

`limit` and `offset` work the same way on `sold_search()` and
`rented_search()`. Both default to `limit=25, offset=0` **on the server** —
the SDK sends nothing when you omit them, so you always get the documented
behaviour. `offset` is a *row* offset applied after ordering, not a page
number: `limit=25, offset=50` returns rows 51-75.

`total` is the number of rows in the page you just received — not the count of
all matching records — and it is the billable row count, so rows skipped by
`offset` are never charged. Each response also carries `total_count` (every
row that matches, across all pages; not billed), `has_more` and `next_offset`.
Request the next page with `offset=next_offset` until `has_more` is false. An
empty page always has `has_more=False`.

Results are ordered newest first (`sold_date` / `list_date`), with a fixed
order for rows that share a date, so following `next_offset` returns each row
once while the data does not change between requests.

The iterators do this for you, on the sync and the async client:

```python
for prop in client.property.iter_sold_search(
    address_key="100102HICKOXSTREETTRARALGONVIC3844",
    page_size=100,     # rows per request (1-1000)
    max_rows=500,      # optional cap on rows fetched AND billed
):
    print(prop.sold_date, prop.sold_price)
```

`max_rows` caps every request, not just what is yielded: each page asks for
`min(page_size, max_rows - rows_so_far)`, so `max_rows=30, page_size=25` sends
`limit=25` and then `limit=5`. Every keyword `sold_search()` accepts except
`limit` / `offset` is passed through; `start_offset` resumes a previous run.
With `AsyncHtAgApi`, use `async for prop in client.property.iter_sold_search(...)`.

The same loop by hand:

```python
offset = 0
while True:
    page = client.property.sold_search(
        address_key="100102HICKOXSTREETTRARALGONVIC3844",
        limit=100,
        offset=offset,
    )
    for prop in page.results:
        ...
    if not page.has_more or page.next_offset is None:
        break
    offset = page.next_offset
```

`total` keeps its page-row meaning, so a 3.1-era loop that stops when
`total < limit` still works.

> **Server default:** the API's default `limit` on both searches is **25**
> (it was 100 before issue #417, and the server previously returned 500 rows
> on rented search and ignored `offset` on sold search). That server change
> predates this SDK release; the SDK sends no `limit` of its own, so the
> server default applies. Pass `limit` explicitly if you need a particular
> page size.

### Address Keys for an Area

Enumerate the address keys in one geography, with optional dwelling filters.

```python
keys = client.reference.address_keys(
    area_type="loc_pid",       # or sa1_code21, sa2_code21, lga_pid
    area_id="NSW983",
    property_group="unit",
    own_status="rented",       # case-insensitive; NULL is reported as "owner occupied"
    limit=50,
)
for row in keys.results:
    print(row.address_key, row.own_status)
```

### Market Summary

Get headline market metrics at suburb or LGA level.

```python
summary = client.markets.summary(
    level="suburb",
    area_id=["SAL10001"],
    property_type=["house"],
)

for record in summary.results:
    print(f"{record.area_id} {record.property_type} ({record.period_end})")
    print(f"  Typical price: ${record.typical_price:,.0f}")
    print(f"  Rent: ${record.rent}/wk, gross yield {record.gross_yield:.2%}")
```

Every markets method returns a `BaseResponse` with `results` and `total`
(rows in this page). `limit` and `offset` are `None` because the API does not
echo them; `markets.query` returns a `PaginatedResponse` where they are set.

### Market Growth

Retrieve cumulative or annualised growth rates for price, rent, and yield.

```python
growth = client.markets.growth_cumulative(
    level="suburb",
    area_id=["SAL10001"],
    property_type=["house"],
)

for record in growth.results:
    print(f"1Y price growth: {record.price_1y_growth:.1%}")
    print(f"5Y price growth: {record.price_5y_growth:.1%}")
```

### Market Trends

Access historical trend data via `client.markets.trends`. All trend methods share the same parameter signature:

```python
# Price history
prices = client.markets.trends.price(
    level="suburb",
    area_id=["SAL10001"],
    property_type=["house"],
    period_end_min="2020-01-01",
    limit=50,
)
for p in prices.results:
    print(f"{p.period_end}: ${p.typical_price:,} ({p.sales} sales)")

# Rent history
rents = client.markets.trends.rent(level="suburb", area_id=["SAL10001"])

# Yield history
yields = client.markets.trends.yield_history(level="suburb", area_id=["SAL10001"])

# Search interest index (buy/rent search indices)
search = client.markets.trends.search_index(level="suburb", area_id=["SAL10001"])

# Hold period
hold = client.markets.trends.hold_period(level="suburb", area_id=["SAL10001"])

# Growth rates (price, rent, yield changes)
growth = client.markets.trends.growth_rates(level="suburb", area_id=["SAL10001"])

# Demand profile (sales by dwelling type and bedrooms)
demand = client.markets.trends.demand_profile(level="suburb", area_id=["SAL10001"])

# Stock on market
som = client.markets.trends.stock_on_market(level="suburb", area_id=["SAL10001"])

# Days on market
dom = client.markets.trends.days_on_market(level="suburb", area_id=["SAL10001"])

# Clearance rate
cr = client.markets.trends.clearance_rate(level="suburb", area_id=["SAL10001"])

# Vacancy rate
vac = client.markets.trends.vacancy(level="suburb", area_id=["SAL10001"])
```

Common trend parameters:

| Parameter | Type | Description |
|-----------|------|-------------|
| `level` | str | `"suburb"` or `"lga"` (required) |
| `area_id` | list[str] | Area identifiers (required) |
| `property_type` | list[str] | `["house"]`, `["unit"]`, etc. |
| `period_end_min` | str | Filter from this date |
| `period_end_max` | str | Filter up to this date |
| `bedrooms` | str | One bedroom count (`"All"`, `"1"`-`"5"`) on `price`, `rent`, `yield_history`, `years_to_own`; see [below](#bedrooms-is-a-single-value-on-the-public-api) |
| `limit` | int | Max results (default 25, max 1000) |
| `offset` | int | Pagination offset |

## Internal API

Some endpoints require the `internal_api` scope on your API key. These are accessed via the `client.internal` namespace:

```python
# Address search (trigram similarity matching)
results = client.internal.address.search("100 George St Sydney")

# Address insights (enriched address data)
insights = client.internal.address.insights(
    address="15 Miranda Court, Noble Park VIC 3174"
)

# Automated Valuation Model (batch, up to 50 properties)
avm = client.internal.property.avm(
    address_key=["100102HICKOXSTREETTRARALGONVIC3844"]
)

# Market snapshots with filtering
snapshots = client.internal.markets.snapshots(
    level="suburb",
    property_type=["house"],
    area_id=["SAL10001"],
)

# Advanced market query with logical filters
results = client.internal.markets.query({
    "level": "suburb",
    "mode": "search",
    "property_types": ["house"],
    "typical_price_min": 500_000,
    "logic": {
        "and": [
            {"field": "one_y_price_growth", "gte": 0.05},
            {"field": "vacancy_rate", "lte": 0.03},
        ]
    },
})

# Internal trend endpoints
supply = client.internal.markets.trends.supply_demand(
    level="suburb", area_id=["SAL10001"]
)
perf = client.internal.markets.trends.performance(
    level="suburb", area_id=["SAL10001"]
)
```

If you call an internal method without the required scope, the API will return a 403 error
(`AuthenticationError`). On DEV, `internal.address.search` has also been seen to time out
with a 504 instead, which surfaces as `ServerError`.

## Async Usage

Every method is available as an async equivalent:

```python
import asyncio
from htag_sdk import AsyncHtAgApi

async def main():
    client = AsyncHtAgApi(api_key="sk-...", environment="prod")

    # All the same methods, just with await
    results = await client.address.geocode("100 George St Sydney")
    est = await client.property.estimates(address_key="...")
    sold = await client.property.sold_search(address="100 George St Sydney")
    prices = await client.markets.trends.price(level="suburb", area_id=["SAL10001"])

    # Internal methods also available
    insights = await client.internal.address.insights(address="100 George St Sydney")

    await client.close()

asyncio.run(main())
```

### Context Manager

Both clients support context managers for automatic cleanup:

```python
import asyncio

# Sync
with HtAgApi(api_key="sk-...", environment="prod") as client:
    results = client.address.geocode("Sydney")

# Async (`async with` must run inside a coroutine)
async def main():
    async with AsyncHtAgApi(api_key="sk-...", environment="prod") as client:
        return await client.address.geocode("Sydney")

results = asyncio.run(main())
```

## Parameter Semantics

Behaviour that is easy to get wrong, and what this SDK does about it.

### List parameters are sent as repeated keys

`area_id=["VIC3121", "VIC3141"]` goes on the wire as
`?area_id=VIC3121&area_id=VIC3141`. This client always did that for list parameters; what
changed is the internal AVM methods, which used to comma-join `address_key`
and `address` into one string. Commas inside a free-text address are now
preserved, so two addresses stay two addresses.

### `bedrooms` is a single value on the public API

`markets.trends.price` / `rent` / `yield_history` / `years_to_own` and
`markets.summary` filter on one bedroom count. The published client always
wrapped the value in a list, so httpx repeated the key and the API silently
kept whichever value arrived last. Passing two now raises
`InvalidParameterError` before the request is sent:

```python
client.markets.trends.price("suburb", ["SAL10001"], bedrooms="3")        # ok
client.markets.trends.price("suburb", ["SAL10001"], bedrooms=["3", "4"])  # raises
```

Issue one request per bedroom count and combine the results. A one-element
list is still accepted and unwrapped at runtime, but type-checked callers
should pass a scalar. The **internal** trend endpoints genuinely do accept a list and
still do.

### Filters the API does not implement

The checked older SDK accepted these filters, but the reviewed API routes do
not implement them. They now raise `InvalidParameterError` before any request:

| Method | Parameter | Why |
|--------|-----------|-----|
| `markets.trends.demand_profile` | `property_type`, `period_end_min`, `period_end_max` | the endpoint filters on `level`, `areaId`, `limit`, `offset` only |
| `intent_hub.list_event_types` | `category` | the route declares no query parameters |
| `internal.address.insights` | `street_loc_pid` | the route accepts `address`, `address_keys`, `legal_parcel_id`, `mb_category_2021` |

Omitting them, or passing `None` / `[]`, is still a no-op.
`InvalidParameterError` also subclasses `ValueError`, so an existing
`except ValueError` guard keeps working.

### Sold and rented search

These rules describe the reviewed API source contract; package installation is
not a server deployment. See [verification scope](#compatibility-and-verification-scope).

* `property.sold_search` sends canonical camelCase sold filters; `address_key`
  remains snake_case. The newer server additionally accepts snake_case aliases,
  with explicit camelCase values (including false and zero) taking precedence.
  Rented search accepts both spellings in both reviewed route versions.
* Omitting `start_date` applies a **default** window of 90 days before the
  effective `end_date`. It is not a maximum lookback; an explicit earlier
  date is passed through for server-side filtering.
* Sale price bounds are inclusive and exclude unknown prices.
  `include_sale_value_unknown` applies only when no price bound is set.
* The newer server rejects unknown query names on **both** searches with
  HTTP 400 `unknown_query_parameters`, before repository work. For example,
  `bedrooms=3` is not a sold-search filter; use `bedroomsMin`/`bedroomsMax`.
  Typoed names are not automatically corrected. Older route versions may
  silently ignore unknown names. This server behavior is separate from the
  SDK's client-side validation.
* `total` counts rows in the returned page, not all matches; `total_count`
  counts every match. Page with `offset=next_offset` until `has_more` is false,
  using the same filters for each request, or use `iter_sold_search()` /
  `iter_rented_search()`.

### Staying on an older release

Upgrading to 2.0.0 applies the sold wire correction. If you need direct REST
instead, use canonical camelCase sold filter names, accepted by both reviewed
source versions. For example, this is the request path and query (authentication
must be configured separately in your client):

```text
/v1/property/sold/search?address_key=AK&startDate=2025-09-10&endDate=2026-08-26&saleValueMin=600000&propertyType=house&propertyType=unit
```

Repeat list parameters rather than comma-joining them. Do not assume a naming
style supported by one endpoint is accepted by every endpoint.

## Error Handling

The SDK raises typed exceptions for API errors:

```python
from htag_sdk import (
    HtAgApi,
    AuthenticationError,
    RateLimitError,
    ValidationError,
    ServerError,
    ConnectionError,
)

client = HtAgApi(api_key="sk-...", environment="prod")

try:
    results = client.address.geocode("Syd")
except AuthenticationError as e:
    # 401 or 403 -- bad API key or insufficient scope
    print(f"Auth failed: {e.message}")
except RateLimitError as e:
    # 429 -- throttled (after exhausting retries)
    print(f"Rate limited. Retry after: {e.retry_after}s")
except ValidationError as e:
    # 400 or 422 -- bad request params. The message names the first failing
    # field, e.g. "Invalid request (query.address: Field required)".
    print(e.message)
    print(f"All errors: {e.body}")
except ServerError as e:
    # 5xx -- upstream failure (after exhausting retries)
    print(f"Server error: {e.status_code}")
except ConnectionError as e:
    # Network/DNS/TLS failure
    print(f"Connection failed: {e.message}")
```

All exceptions carry:
- `message` -- human-readable description
- `status_code` -- HTTP status (if applicable)
- `body` -- raw response body
- `request_id` -- request identifier (if returned by the API)

**Not found.** A nonexistent `address_key` is not an error: the API answers
200 with empty `results` (`total == 0`), and `property.history` returns empty
`sales` and `rentals`. Check `total` / `results` rather than catching
`NotFoundError`, which is raised only for a real HTTP 404 (for example a
mistyped `base_url` path).

## Retries

The SDK automatically retries transient failures:

- **Retried statuses**: 429, 500, 502, 503, 504
- **Max retries**: 3 (configurable)
- **Backoff**: exponential (0.5s base, 2x multiplier, 25% jitter, 30s cap)
- **429 handling**: respects `Retry-After` header

Configure retry behaviour:

```python
client = HtAgApi(
    api_key="sk-...",
    environment="prod",
    max_retries=5,    # default is 3
    timeout=120.0,    # request timeout in seconds (default 60)
)
```

## Configuration Reference

| Parameter | Type | Default | Description |
|-----------|------|---------|-------------|
| `api_key` | str | required | Your HtAG API key |
| `environment` | str | -- | `"dev"` or `"prod"`. **Required unless `base_url` is given**: there is no default, and construction with neither raises `ValueError` |
| `base_url` | str | -- | Custom base URL (takes precedence over `environment`) |
| `timeout` | float | `60.0` | Request timeout in seconds |
| `max_retries` | int | `3` | Maximum retry attempts |

## Requirements

- Python >= 3.9
- [httpx](https://www.python-httpx.org/) >= 0.27
- [Pydantic](https://docs.pydantic.dev/) >= 2.0

## License

MIT
