htag-sdk
v2.0.0
Published
Official TypeScript SDK for HtAG Location Intelligence APIs — address search, property data, and market analytics for Australia
Maintainers
Readme
htag-sdk
The official TypeScript SDK for the HtAG Location Intelligence API.
Provides typed access to Australian address data, property valuations, sales records, and market analytics. Zero runtime dependencies -- uses native fetch.
import { HtAgApiClient } from 'htag-sdk';
const client = new HtAgApiClient({
apiKey: process.env.HTAG_API_KEY!,
environment: 'prod',
});
const results = await client.address.geocode({ address: '100 George St Sydney' });
for (const r of results.results) {
console.log(`${r.address_label} (${r.address_key})`);
}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 type on public bedroom-filtered trends and markets.summary | string \| string[] | BedroomsFilter ('All' \| '1'…'5' \| (string & {})) | Pass a scalar value. Arrays no longer type-check; a singleton array is only accepted and unwrapped at runtime. |
| markets.trends.price/rent/yieldHistory/yearsToOwn, markets.summary with two bedroom values | issued a comma-joined value, not two independently applied bedroom filters | throws InvalidParameterError before sending | Issue one request per bedroom count and combine the results. |
| markets.trends.demandProfile({ propertyType }) (also periodEndMin / periodEndMax) | issued a request; the API did not apply the filter | throws InvalidParameterError before sending | Remove the unsupported parameter; it does not provide server-side filtering. |
| intentHub.listEventTypes({ category }), internal.address.insights({ streetLocPid }) | silently ignored by the API | throws InvalidParameterError | Drop the parameter. |
InvalidParameterError is exported and extends the SDK's HtAgError, so it is
catchable alongside the other typed errors.
Request and page-size changes
- Sold-search filter names are corrected in this package.
property.soldSearchsends the API's canonical camelCase filter names, includingstartDate,endDate,saleValueMinandsaleValueMax. Public SDK parameter names are unchanged.address_keystays snake_case; rented-search and other endpoint mappings are unchanged by this correction. - Array parameters are sent as repeated keys, not comma-joined values.
areaId: ['A', 'B']becomesarea_id=A&area_id=B. Commas inside a value are preserved. mbCategory2021on address geocoding is sent asmb_category_2021.- Pagination is controlled by the server unless explicitly supplied.
TypeScript 1.9.6 and 2.0.0 both omit a client-side default limit on rented
search. The reviewed server contract defaults to 100; set
limitexplicitly when your application needs a particular 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 adds sold-search snake_case aliases and rejects unknown query names on both sold and rented search. Its recorded deployment is DEV-only; installing this package does not deploy these server changes or establish their live status. 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
own_statuson address keys is separate work (PR #265), not part of this release.- The reviewed API accepts
limitandoffseton six reference concordance*-to-h3routes; 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
npm install htag-sdkOr with your preferred package manager:
yarn add htag-sdk
pnpm add htag-sdkRequires Node.js 18+ (for native fetch).
Quick Start
1. Get an API Key
Sign up at developer.htagai.com and create an API key from the Settings page.
2. Create a Client
import { HtAgApiClient } from 'htag-sdk';
const client = new HtAgApiClient({
apiKey: 'sk-org--your-org-id-your-key-value',
environment: 'prod', // 'dev' or 'prod'
});Or use a custom base URL:
const client = new HtAgApiClient({
apiKey: 'sk-...',
baseUrl: 'https://api.staging.htagai.com',
});3. Make Requests
// Geocode an address
const results = await client.address.geocode({ address: '15 Miranda Court Noble Park' });
console.log(`${results.total} matches`);
// Get property estimates
const est = await client.property.estimates({
addressKey: '15MIRANDACOURTNOBLEPARKvic3174',
});
for (const record of est.results) {
console.log(`Price estimate: $${record.price_estimate?.toLocaleString()}`);
console.log(`Last sold: $${record.last_sold_price?.toLocaleString()} on ${record.last_sold_date}`);
}Usage
Address Geocode
Resolve a free-text address to structured location data with geographic identifiers.
const results = await client.address.geocode({
address: '100 Hickox St Traralgon',
limit: 5, // max results (1 - 50)
});
for (const match of results.results) {
console.log(match.address_label);
console.log(` Key: ${match.address_key}`);
console.log(` Location: ${match.lat}, ${match.lon}`);
}Address Standardisation
Standardise raw address strings into structured, canonical components.
const result = await client.address.standardise({
addresses: [
'12 / 100-102 HICKOX STR TRARALGON, VIC 3844',
'15a smith st fitzroy vic 3065',
],
});
for (const item of result.results) {
if (item.error) {
console.log(`Failed: ${item.input_address} -- ${item.error}`);
} else {
const addr = item.standardised_address!;
console.log(item.input_address);
console.log(` -> ${addr.street_number} ${addr.street_name} ${addr.street_type}`);
console.log(` ${addr.suburb_or_locality} ${addr.state} ${addr.postcode}`);
console.log(` Key: ${item.address_key}`);
}
}Address Environment
Retrieve environmental risk data for an address including flood, bushfire, heritage, and zoning.
const env = await client.address.environment({
address: '15 Miranda Court, Noble Park VIC 3174',
});
for (const record of env.results) {
console.log(`Bushfire: ${record.bushfire}, Flood: ${record.flood}`);
console.log(`Heritage: ${record.heritage}, Zoning: ${record.zoning}`);
}Address Demographics
Retrieve socio-economic indices (SEIFA) and housing tenure data.
const demo = await client.address.demographics({
address: '15 Miranda Court, Noble Park VIC 3174',
});
for (const record of demo.results) {
console.log(`IRSAD: ${record.IRSAD}, IER: ${record.IER}`);
}Property Summary
Retrieve physical property attributes for an address.
const summary = await client.property.summary({
addressKey: '100102HICKOXSTREETTRARALGONVIC3844',
});
for (const record of summary.results) {
console.log(`Type: ${record.property_type}`);
console.log(`Beds: ${record.beds}, Baths: ${record.baths}, Parking: ${record.parking}`);
console.log(`Land: ${record.lot_size} sqm, Floor: ${record.floor_area} sqm`);
}Property Estimates
Retrieve valuation estimates and transaction history for an address.
const est = await client.property.estimates({
addressKey: '100102HICKOXSTREETTRARALGONVIC3844',
});
for (const record of est.results) {
console.log(`Price estimate: $${record.price_estimate?.toLocaleString()}`);
console.log(`Rent estimate: $${record.rent_estimate}/wk`);
console.log(`Last sold: $${record.last_sold_price?.toLocaleString()} on ${record.last_sold_date}`);
}Property Market
Retrieve market position indicators for an address.
const mkt = await client.property.market({
addressKey: '100102HICKOXSTREETTRARALGONVIC3844',
});
for (const record of mkt.results) {
console.log(`Rental %: ${record.rental_percentage}`);
console.log(`Years to own: ${record.years_to_own}`);
console.log(`Hold period: ${record.hold_period} years`);
}Sold Property Search
Search for recently sold properties near an address or coordinates.
const sold = await client.property.soldSearch({
address: '100 George St, Sydney NSW 2000',
radius: 2000, // metres
propertyType: 'house',
saleValueMin: 500_000,
saleValueMax: 2_000_000,
bedroomsMin: 3,
startDate: '2024-01-01',
});
console.log(`${sold.total} properties found`);
for (const prop of sold.results) {
const price = prop.sold_price ? `$${prop.sold_price.toLocaleString()}` : 'undisclosed';
console.log(` ${prop.street_address}, ${prop.suburb} -- ${price} (${prop.sold_date})`);
}All filter parameters are optional:
| Parameter | Type | Description |
|-----------|------|-------------|
| address | string | Free-text address to centre the search on |
| addressKey | string | GNAF address key |
| lat, lon | number | Coordinates for point-based search |
| radius | number | Search radius in metres (default 2000, max 5000) |
| proximity | string | 'any', 'sameStreet', or 'sameSuburb' |
| propertyType | string | 'house', 'unit', 'townhouse', 'land', 'rural' |
| saleValueMin, saleValueMax | number | Price range filter (AUD) |
| bedroomsMin, bedroomsMax | number | Bedroom count range |
| bathroomsMin, bathroomsMax | number | Bathroom count range |
| carSpacesMin, carSpacesMax | number | Car space range |
| startDate, endDate | string | Date range (ISO 8601, e.g. '2024-01-01') |
| landAreaMin, landAreaMax | number | Land area in sqm |
| limit | number | Rows per page (1-1000). Omitted → server default 100 |
| offset | number | Rows to skip before limit (>= 0). Omitted → 0 |
Sold-search filters are sent using the API's canonical camelCase names. The public TypeScript parameter names above are unchanged;
address_keyremains snake_case.
Pagination
limit and offset behave identically on soldSearch() and
rentedSearch(). Both default to limit=100, 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.
const limit = 100;
let offset = 0;
for (;;) {
const page = await client.property.soldSearch({
addressKey: '100102HICKOXSTREETTRARALGONVIC3844',
limit,
offset,
});
for (const prop of page.results) {
// ...
}
if (page.total < limit) break; // short page → last page
offset += limit;
}Changed in this release: the server default for
limiton both searches is now 100 (previously 500 on rented search, andoffsetwas ignored entirely on sold search). Passlimit: 500explicitly if you relied on the old page size.
Market Summary
Get headline market metrics at suburb or LGA level.
const summary = await client.markets.summary({
level: 'suburb',
areaId: ['SAL10001'],
propertyType: ['house'],
});
for (const record of summary.results) {
console.log(`${record.suburb} (${record.state_name})`);
console.log(` Typical price: $${record.typical_price?.toLocaleString()}`);
console.log(` Rent: $${record.rent}/wk`);
}Market Growth
Retrieve cumulative or annualised growth rates for price, rent, and yield.
const growth = await client.markets.growthCumulative({
level: 'suburb',
areaId: ['SAL10001'],
propertyType: ['house'],
});
for (const record of growth.results) {
console.log(`1Y price growth: ${record.one_y_price_growth}`);
console.log(`5Y price growth: ${record.five_y_price_growth}`);
}Market Trends
Access historical trend data via client.markets.trends. All trend methods share the same parameter signature:
// Price history
const prices = await client.markets.trends.price({
level: 'suburb',
areaId: ['SAL10001'],
propertyType: ['house'],
periodEndMin: '2020-01-01',
limit: 50,
});
for (const p of prices.results) {
console.log(`${p.period_end}: $${p.typical_price?.toLocaleString()} (${p.sales} sales)`);
}
// Rent history
const rents = await client.markets.trends.rent({
level: 'suburb', areaId: ['SAL10001'],
});
// Yield history
const yields = await client.markets.trends.yieldHistory({
level: 'suburb', areaId: ['SAL10001'],
});
// Search interest index (buy/rent search indices)
const search = await client.markets.trends.searchIndex({
level: 'suburb', areaId: ['SAL10001'],
});
// Hold period
const hold = await client.markets.trends.holdPeriod({
level: 'suburb', areaId: ['SAL10001'],
});
// Growth rates (price, rent, yield changes)
const growth = await client.markets.trends.growthRates({
level: 'suburb', areaId: ['SAL10001'],
});
// Demand profile (sales by dwelling type and bedrooms)
const demand = await client.markets.trends.demandProfile({
level: 'suburb', areaId: ['SAL10001'],
});
// Stock on market
const som = await client.markets.trends.stockOnMarket({
level: 'suburb', areaId: ['SAL10001'],
});
// Days on market
const dom = await client.markets.trends.daysOnMarket({
level: 'suburb', areaId: ['SAL10001'],
});
// Clearance rate
const cr = await client.markets.trends.clearanceRate({
level: 'suburb', areaId: ['SAL10001'],
});
// Vacancy rate
const vac = await client.markets.trends.vacancy({
level: 'suburb', areaId: ['SAL10001'],
});Common trend parameters:
| Parameter | Type | Description |
|-----------|------|-------------|
| level | 'suburb' | 'lga' | Geographic level (required) |
| areaId | string[] | Area identifiers (required) |
| propertyType | string[] | ['house'], ['unit'], etc. |
| periodEndMin | string | Filter from this date |
| periodEndMax | string | Filter up to this date |
| bedrooms | string | string[] | Bedroom filter |
| limit | number | Max results (default 100, max 1000) |
| offset | number | Pagination offset |
Internal API
Some endpoints require the internal_api scope on your API key. These are accessed via the client.internal namespace:
// Address search (trigram similarity matching)
const results = await client.internal.address.search({ q: '100 George St Sydney' });
// Address insights (enriched address data)
const insights = await client.internal.address.insights({
address: '15 Miranda Court, Noble Park VIC 3174',
});
// Automated Valuation Model (batch, up to 50 properties)
const avm = await client.internal.property.avm({
addressKey: ['100102HICKOXSTREETTRARALGONVIC3844'],
});
// Market snapshots with filtering
const snapshots = await client.internal.markets.snapshots({
level: 'suburb',
propertyType: ['house'],
areaId: ['SAL10001'],
});
// Advanced market query with logical filters
const queryResults = await 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
const supply = await client.internal.markets.trends.supplyDemand({
level: 'suburb', areaId: ['SAL10001'],
});
const perf = await client.internal.markets.trends.performance({
level: 'suburb', areaId: ['SAL10001'],
});If you call an internal method without the required scope, the API will return a 403 error.
Request Cancellation
All methods accept an AbortSignal for cancellation:
const controller = new AbortController();
// Cancel after 5 seconds
setTimeout(() => controller.abort(), 5000);
try {
const results = await client.address.geocode({
address: '100 George St',
signal: controller.signal,
});
} catch (err) {
if (err instanceof HtAgError && err.message === 'Request aborted') {
console.log('Request was cancelled');
}
}Parameter Semantics
Behaviour that is easy to get wrong, and what this SDK does about it.
List parameters are sent as repeated keys
{ areaId: ['VIC3121', 'VIC3141'] } goes on the wire as
?area_id=VIC3121&area_id=VIC3141. The checked 1.9.6 package comma-joined
multiple values, which could be interpreted as one identifier rather than
separate filters.
Commas inside a value are preserved, which matters for free-text parameters
such as the internal AVM address list.
bedrooms is a single value on the public API
markets.trends.price / rent / yieldHistory / yearsToOwn and
markets.summary
filter on one bedroom count. Arrays no longer type-check. At runtime, a list
with multiple values raises InvalidParameterError before the request is sent:
await client.markets.trends.price({ level: 'suburb', areaId: ['SAL10001'], bedrooms: '3' }); // ok
await client.markets.trends.price({ ...same, bedrooms: ['3', '4'] }); // throwsIssue one request per bedroom count and combine the results. A one-element
array is still accepted and unwrapped at runtime, but typed callers must
pass a scalar. The internal trend endpoints genuinely do accept a list and
still take string[].
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.demandProfile | propertyType, periodEndMin, periodEndMax | the endpoint filters on level, areaId, limit, offset only |
| intentHub.listEventTypes | category | the route declares no query parameters |
| internal.address.insights | streetLocPid | the route accepts address, addressKeys, legalParcelId, mbCategory2021 |
Omitting them, or passing undefined / [], is still a no-op.
Sold and rented search
These rules describe the reviewed API source contract; package installation is not a server deployment. See verification scope.
property.soldSearchsends canonical camelCase sold filters;address_keyremains 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
startDateapplies a default window of 90 days before the effectiveendDate. 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.
includeSaleValueUnknownapplies 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=3is not a sold-search filter; usebedroomsMin/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. totalcounts rows in the returned page, not all matches. Page untiltotal < limit, using the same explicit limit and filters for each request.
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):
/v1/property/sold/search?address_key=AK&startDate=2025-09-10&endDate=2026-08-26&saleValueMin=600000&propertyType=house&propertyType=unitRepeat 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 errors for API failures:
import {
HtAgApiClient,
HtAgError,
AuthenticationError,
RateLimitError,
ValidationError,
ServerError,
} from 'htag-sdk';
const client = new HtAgApiClient({ apiKey: 'sk-...' });
try {
const results = await client.address.geocode({ address: 'Syd' });
} catch (err) {
if (err instanceof AuthenticationError) {
// 401 or 403 -- bad API key or insufficient scope
console.error(`Auth failed (HTTP ${err.status})`);
} else if (err instanceof RateLimitError) {
// 429 -- throttled (after exhausting retries)
console.error('Rate limited, try again later');
} else if (err instanceof ValidationError) {
// 400 or 422 -- bad request params
console.error(`Invalid request: ${err.message}`);
console.error('Details:', err.body);
} else if (err instanceof ServerError) {
// 5xx -- upstream failure (after exhausting retries)
console.error(`Server error (HTTP ${err.status})`);
} else if (err instanceof HtAgError) {
// Network/timeout/other
console.error(`Request failed: ${err.message}`);
}
}All errors carry:
message-- human-readable descriptionstatus-- HTTP status code (if applicable)body-- raw response bodyurl-- the request URL that failedcause-- the underlying error (for network failures)
Retries
The SDK automatically retries transient failures:
- Retried statuses: 429, 500, 502, 503, 504
- Network errors: connection failures, timeouts
- Max retries: 3 (configurable)
- Backoff: exponential (0.5s base, 2x multiplier, random jitter)
Configure retry behaviour:
const client = new HtAgApiClient({
apiKey: 'sk-...',
maxRetries: 5, // default is 3
timeout: 120_000, // request timeout in ms (default 30000)
});Configuration Reference
| Option | Type | Default | Description |
|--------|------|---------|-------------|
| apiKey | string | required | Your HtAG API key |
| environment | 'dev' | 'prod' | 'prod' | API environment |
| baseUrl | string | -- | Custom base URL (overrides environment) |
| timeout | number | 30000 | Request timeout in milliseconds |
| maxRetries | number | 3 | Maximum retry attempts |
| retryBaseDelay | number | 500 | Base delay between retries in ms |
CommonJS
The package ships with both ESM and CommonJS builds:
// ESM (recommended)
import { HtAgApiClient } from 'htag-sdk';
// CommonJS
const { HtAgApiClient } = require('htag-sdk');TypeScript
All types are exported for use in your application:
import type {
AddressRecord,
GeocodeRecord,
SoldPropertyRecord,
PropertySummaryRecord,
PropertyEstimatesRecord,
PropertyMarketRecord,
MarketSummaryRecord,
PriceHistoryOut,
RentHistoryOut,
BaseResponse,
LevelEnum,
PropertyTypeEnum,
} from 'htag-sdk';Requirements
- Node.js >= 18 (for native
fetch) - No runtime dependencies
License
MIT
