htag-sdk 0.8.0
Official Python SDK for the HtAG Location Intelligence API — address search, property data, and market analytics for Australia
Requires Python: >=3.9
Keywords: address, api, australia, htag, location-intelligence, market-data, property, real-estate, sdk

# htag-sdk

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

Provides typed, ergonomic access to Australian address data, property 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.search("100 George St Sydney")
for r in results.results:
    print(f"{r.address_label}  (score {r.score:.2f})")
```

## 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")
```

### 3. Make Requests

```python
# Search for addresses
results = client.address.search("15 Miranda Court Noble Park")
print(results.total, "matches")

# Get insights for an address
insights = client.address.insights(address="15 Miranda Court, Noble Park VIC 3174")
for record in insights.results:
    print(f"Bushfire: {record.bushfire}, Flood: {record.flood}")

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

## Usage

### Address Search

Find addresses by free-text query with fuzzy matching.

```python
results = client.address.search(
    "100 Hickox St Traralgon",
    threshold=0.3,   # minimum match score (0.1 - 1.0)
    limit=5,         # max results (1 - 50)
)

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

### Address Insights

Retrieve enriched data for addresses including risk flags, SEIFA scores, and zoning.

Provide exactly one of `address`, `address_keys`, or `legal_parcel_id`:

```python
# By address string
insights = client.address.insights(
    address="15 Miranda Court, Noble Park VIC 3174"
)

# By GNAF address keys (up to 50)
insights = client.address.insights(
    address_keys=["100102HICKOXSTREETTRARALGONVIC3844"]
)

# By legal parcel ID
insights = client.address.insights(
    legal_parcel_id="2\\TP574754"
)

for record in insights.results:
    print(f"Address: {record.address_label}")
    print(f"  Bushfire risk: {record.bushfire}")
    print(f"  Flood risk: {record.flood}")
    print(f"  Heritage: {record.heritage}")
    print(f"  SEIFA (IRSAD): {record.IRSAD}")
    print(f"  Zoning: {record.zoning}")
```

### 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}")
```

### 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"{sold.total} properties found")
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 | `"house"`, `"unit"`, `"townhouse"`, `"land"`, `"rural"` |
| `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 |

### Market Snapshots

Get current market metrics at suburb or LGA level.

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

for snap in snapshots.results:
    print(f"{snap.suburb} ({snap.state_name})")
    print(f"  Typical price: ${snap.typical_price:,}")
    print(f"  Rent: ${snap.rent}/wk")
    print(f"  Yield: {snap.yield_val:.1%}" if snap.yield_val else "")
    print(f"  1Y growth: {snap.one_y_price_growth:.1%}" if snap.one_y_price_growth else "")
```

### Market Query (Advanced)

Run complex market searches with filter logic using AND/OR/NOT operators.

```python
from htag_sdk import AdvancedSearchBody

# Using a dict
results = client.markets.query({
    "level": "suburb",
    "mode": "search",
    "property_types": ["house"],
    "typical_price_min": 500_000,
    "typical_price_max": 1_500_000,
    "limit": 20,
})

# Or using the typed model
body = AdvancedSearchBody(
    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},
        ]
    },
)
results = client.markets.query(body)
```

### 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"])

# Supply & demand (inventory, vacancies, clearance rate)
supply = client.markets.trends.supply_demand(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"])

# Performance essentials (price, rent, sales, rentals, yield)
perf = client.markets.trends.performance(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"])
```

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 or list[str] | Bedroom filter |
| `limit` | int | Max results (default 100, max 1000) |
| `offset` | int | Pagination offset |

## 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.search("100 George St Sydney")
    insights = await client.address.insights(address_keys=["GANSW716626498"])
    sold = await client.property.sold_search(address="100 George St Sydney")
    prices = await client.markets.trends.price(level="suburb", area_id=["SAL10001"])

    await client.close()

asyncio.run(main())
```

### Context Manager

Both clients support context managers for automatic cleanup:

```python
# Sync
with HtAgApi(api_key="sk-...") as client:
    results = client.address.search("Sydney")

# Async
async with AsyncHtAgApi(api_key="sk-...") as client:
    results = await client.address.search("Sydney")
```

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

try:
    results = client.address.search("Syd")
except AuthenticationError as e:
    # 401 or 403 — bad API key
    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
    print(f"Invalid request: {e.message}")
    print(f"Details: {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)

## 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-...",
    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 | `"prod"` | `"dev"` or `"prod"` |
| `base_url` | str | — | Custom base URL (overrides 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