Docs
Integration guides
Copy a starting point, replace YOUR_KEY and test with your form. API reference. Current release: AUG 2026 - Geoscape G-NAF - GDA2020.
Incorporates or developed using G-NAF © Geoscape Australia licensed by the Commonwealth of Australia under the Open Geo-coded National Address File (G-NAF) End User Licence Agreement.
Show this line wherever you publish address data. Verify addresses against a secondary source before compiling mailing lists. Keep postcodes as strings. REST accepts x-api-key or Authorization: Bearer; never put keys in URLs.
Each successful lookup spends quota. Bulk counts each query, jobs each successfully processed row. Error rows cost nothing. Debounce typing, respect your key's burst limit (5 requests a second on the free plan) and wait for Retry-After on 429 responses. Use dedicated browser keys; browser code cannot hide them. Allowed websites accept HTTPS origins or hostnames. For local development, allow http://localhost:3000, http://127.0.0.1:8765 or http://[::1]:3000, with the port matching your application. Plain HTTP is allowed only for these loopback hosts.
Example address record
Attribution: display the G-NAF attribution line in your product.
{
"uprn": "GANSW718028803",
"line1": "9 George Street",
"line2": null,
"suburb": "Sydney",
"state": "NSW",
"postcode": "2000",
"latitude": -33.86580248,
"longitude": 151.20700223
}Plain HTML
Add labelled fields and this script. The widget fills mapped fields and fires sa:select on selection; event.detail holds the record.
HTML embed
Attribution: display the G-NAF attribution line in your product.
<!-- Attribution: display the G-NAF attribution line in your product. -->
<label for="address">Address</label><input id="address" autocomplete="off">
<label for="suburb">Suburb</label><input id="suburb">
<label for="state">State</label><input id="state">
<label for="postcode">Postcode</label><input id="postcode" inputmode="numeric">
<script src="https://yepthere.au/widget.js" defer
data-key="YOUR_KEY" data-input="#address"
data-fields='{"line1":"#address","suburb":"#suburb","state":"#state","postcode":"#postcode"}'></script>
<script>
document.getElementById('address').addEventListener('sa:select', (event) => {
console.log(event.detail.uprn); // Store the stable address ID if needed.
});
</script>
<small>Incorporates or developed using G-NAF © Geoscape Australia licensed by the Commonwealth of Australia under the Open Geo-coded National Address File (G-NAF) End User Licence Agreement.</small>What to check
Display the attribution line. Quota: 5,000 free lookups per UTC month; check your plan and Retry-After. Browser keys are visible; use a dedicated key or server proxy.
React
This component calls fetch directly, waits 300 ms after typing and cancels stale requests. Users tab through native result buttons. Keep a manual address option.
AddressInput.jsx
Attribution: display the G-NAF attribution line in your product.
"use client";
// Attribution: display the G-NAF attribution line in your product.
import { useEffect, useId, useState } from 'react';
export default function AddressInput({ onSelect = () => {} }) {
const id = useId();
const [query, setQuery] = useState('');
const [chosen, setChosen] = useState(false);
const [rows, setRows] = useState([]);
const [message, setMessage] = useState('');
useEffect(() => {
setRows([]);
setMessage('');
if (chosen || query.trim().length < 3) return;
const controller = new AbortController();
const timer = setTimeout(async () => {
setMessage('Searching');
try {
const response = await fetch("https://yepthere.au/v1/addresses?limit=5&q=" + encodeURIComponent(query.trim()), {
headers: { 'x-api-key': 'YOUR_KEY' },
signal: controller.signal
});
const data = await response.json();
if (!response.ok || !data.ok) throw new Error(data.message || 'Search failed');
if (controller.signal.aborted) return;
setRows(data.results);
setMessage(data.results.length ? 'Tab to a result and press Enter.' : 'No matches');
} catch (error) {
if (!controller.signal.aborted) setMessage(error.message);
}
}, 300);
return () => { clearTimeout(timer); controller.abort(); };
}, [query, chosen]);
return <div>
<label htmlFor={id}>Australian address</label>
<input id={id} value={query} autoComplete="off" maxLength={200}
aria-describedby={id + '-status'} onChange={(event) => {
setChosen(false); setRows([]); setQuery(event.target.value);
}} />
<p id={id + '-status'} role="status">{message}</p>
<ul aria-label="Address suggestions">{rows.map((record) => <li key={record.uprn}>
<button type="button" onClick={() => {
setChosen(true); setQuery(record.line1); setRows([]);
document.getElementById(id)?.focus(); onSelect(record);
}}>{[record.line1, record.suburb, record.state, record.postcode].join(', ')}</button>
</li>)}</ul>
</div>;
}What to check
Display the attribution line. Quota: 5,000 free lookups per UTC month; check your plan and Retry-After. Browser keys are visible; use a dedicated key or server proxy.
Vue 3
Load the widget before mounting Vue 3.5 or later. Keep the input uncontrolled; emit the selected record to the parent.
Host page
Attribution: display the G-NAF attribution line in your product.
<!-- Attribution: display the G-NAF attribution line in your product. --> <script src="https://yepthere.au/widget.js"></script> <!-- Mount your Vue app after this script. Set YOUR_KEY in AddressInput.vue. -->
AddressInput.vue
Attribution: display the G-NAF attribution line in your product.
<script setup>
// Attribution: display the G-NAF attribution line in your product.
import { onMounted, ref, useId } from 'vue';
const input = ref(null);
const id = useId();
const emit = defineEmits(['select']);
onMounted(() => {
window.Yepthere.attach(input.value, {
key: 'YOUR_KEY', baseUrl: 'https://yepthere.au',
onSelect: (record) => emit('select', record)
});
});
</script>
<template>
<label :for="id">Australian address</label>
<input :id="id" ref="input" autocomplete="off">
</template>What to check
Display the attribution line. Quota: 5,000 free lookups per UTC month; check your plan and Retry-After. Browser keys are visible; use a dedicated key or server proxy.
Next.js
Use an App Router route handler and the client component below. Keep the key in server secrets, without a NEXT_PUBLIC_ prefix. Apply your application’s session checks or rate limits to the proxy before public use.
Server route
Attribution: display the G-NAF attribution line in your product.
// app/api/addresses/route.js
// Attribution: display the G-NAF attribution line in your product.
// Set SESSIONAL_ADDRESSES_KEY=YOUR_KEY in .env.local or server secrets.
export async function GET(request) {
const q = new URL(request.url).searchParams.get('q')?.trim() || '';
const headers = { 'Cache-Control': 'no-store' };
if (q.length < 3 || q.length > 200) {
return Response.json({ ok: false, results: [], message: 'Use 3 to 200 characters.' }, { status: 400, headers });
}
const key = process.env.SESSIONAL_ADDRESSES_KEY;
if (!key) return Response.json({ ok: false, results: [], message: 'Search unavailable.' }, { status: 503, headers });
try {
const upstream = await fetch('https://yepthere.au/v1/addresses?limit=5&q=' + encodeURIComponent(q), {
headers: { 'x-api-key': key }, cache: 'no-store', signal: AbortSignal.timeout(8000)
});
for (const name of ['Retry-After', 'X-RateLimit-Limit', 'X-RateLimit-Remaining', 'X-RateLimit-Reset']) {
if (upstream.headers.has(name)) headers[name] = upstream.headers.get(name);
}
return Response.json(await upstream.json(), { status: upstream.status, headers });
} catch {
return Response.json({ ok: false, results: [], message: 'Search unavailable. Try again later.' }, { status: 502, headers });
}
}Client AddressInput.jsx
Attribution: display the G-NAF attribution line in your product.
"use client";
// Attribution: display the G-NAF attribution line in your product.
import { useEffect, useId, useState } from 'react';
export default function AddressInput({ onSelect = () => {} }) {
const id = useId();
const [query, setQuery] = useState('');
const [chosen, setChosen] = useState(false);
const [rows, setRows] = useState([]);
const [message, setMessage] = useState('');
useEffect(() => {
setRows([]);
setMessage('');
if (chosen || query.trim().length < 3) return;
const controller = new AbortController();
const timer = setTimeout(async () => {
setMessage('Searching');
try {
const response = await fetch('/api/addresses?q=' + encodeURIComponent(query.trim()), {
signal: controller.signal
});
const data = await response.json();
if (!response.ok || !data.ok) throw new Error(data.message || 'Search failed');
if (controller.signal.aborted) return;
setRows(data.results);
setMessage(data.results.length ? 'Tab to a result and press Enter.' : 'No matches');
} catch (error) {
if (!controller.signal.aborted) setMessage(error.message);
}
}, 300);
return () => { clearTimeout(timer); controller.abort(); };
}, [query, chosen]);
return <div>
<label htmlFor={id}>Australian address</label>
<input id={id} value={query} autoComplete="off" maxLength={200}
aria-describedby={id + '-status'} onChange={(event) => {
setChosen(false); setRows([]); setQuery(event.target.value);
}} />
<p id={id + '-status'} role="status">{message}</p>
<ul aria-label="Address suggestions">{rows.map((record) => <li key={record.uprn}>
<button type="button" onClick={() => {
setChosen(true); setQuery(record.line1); setRows([]);
document.getElementById(id)?.focus(); onSelect(record);
}}>{[record.line1, record.suburb, record.state, record.postcode].join(', ')}</button>
</li>)}</ul>
</div>;
}What to check
Display the attribution line. Quota: 5,000 free lookups per UTC month; check your plan and Retry-After. Browser keys are visible; use a dedicated key or server proxy.
WordPress and WooCommerce
Save this 40-line file as wp-content/plugins/yepthere/yepthere.php and activate it. It maps both address groups on classic checkout and rebinds replaced fields. Checkout blocks need a separate block integration.
Checkout plugin
Attribution: display the G-NAF attribution line in your product.
<?php
/** Plugin Name: yepthere checkout addresses */
// Attribution: display the G-NAF attribution line in your checkout.
defined('ABSPATH') || exit;
add_action('wp_enqueue_scripts', function () {
if (!function_exists('is_checkout') || !is_checkout() || is_order_received_page()) return;
wp_enqueue_script('yepthere', 'https://yepthere.au/widget.js', ['jquery'], null, true);
$script = <<<'JS'
(function ($) {
function bind() {
['billing', 'shipping'].forEach(function (prefix) {
var input = document.getElementById(prefix + '_address_1');
if (!input || input.dataset.yepthereBound) return;
input.dataset.yepthereBound = 'true';
Yepthere.attach(input, {
key: 'YOUR_KEY',
baseUrl: 'https://yepthere.au',
onSelect: function (record) {
var values = { country: 'AU', address_1: record.line1,
address_2: record.line2 || '', city: record.suburb,
state: record.state, postcode: record.postcode };
Object.keys(values).forEach(function (field) {
$('#' + prefix + '_' + field).val(values[field]).trigger('input').trigger('change');
});
$(document.body).trigger('update_checkout');
}
});
});
}
$(bind);
$(document.body).on('updated_checkout', bind);
})(jQuery);
JS;
wp_add_inline_script('yepthere', $script);
});
add_action('woocommerce_after_checkout_form', function () {
echo '<p class="yepthere-attribution">Incorporates or developed using G-NAF © Geoscape Australia licensed by the Commonwealth of Australia under the Open Geo-coded National Address File (G-NAF) End User Licence Agreement.</p>';
});
// Use the classic checkout shortcode, not the Checkout block.
// Keep postcode values as strings so 0800 keeps its leading zero.What to check
Display the attribution line. Quota: 5,000 free lookups per UTC month; check your plan and Retry-After. Browser keys are visible; use a dedicated key or server proxy.
Webflow
Set these field IDs in the Designer and paste this embed after your form. Test on the published site.
Custom code embed
Attribution: display the G-NAF attribution line in your product.
<!-- Attribution: display the G-NAF attribution line beside the form. -->
<!-- Existing labelled fields: address, suburb, state, postcode. -->
<script src="https://yepthere.au/widget.js" defer
data-key="YOUR_KEY" data-input="#address"
data-fields='{"line1":"#address","suburb":"#suburb","state":"#state","postcode":"#postcode"}'></script>
<small>Incorporates or developed using G-NAF © Geoscape Australia licensed by the Commonwealth of Australia under the Open Geo-coded National Address File (G-NAF) End User Licence Agreement.</small>What to check
Display the attribution line. Quota: 5,000 free lookups per UTC month; check your plan and Retry-After. Browser keys are visible; use a dedicated key or server proxy.
HubSpot forms
Use onFormReady with a legacy raw HTML form. Copy region, portal and form IDs from your embed settings. New iframe forms need HubSpot’s current forms API and cannot use this DOM hook.
Legacy form embed
Attribution: display the G-NAF attribution line in your product.
<!-- Attribution: display the G-NAF attribution line beside the form. -->
<div id="hubspot-form"></div>
<script src="https://yepthere.au/widget.js"></script>
<script src="https://js.hsforms.net/forms/embed/v2.js"></script>
<script>
hbspt.forms.create({
region: 'ap1', portalId: 'YOUR_PORTAL_ID', formId: 'YOUR_FORM_ID',
target: '#hubspot-form',
onFormReady: function ($form) {
const form = $form[0] || $form;
const input = form.querySelector('[name="address"]');
if (!input) return;
Yepthere.attach(input, {
key: 'YOUR_KEY', baseUrl: 'https://yepthere.au',
onSelect(record) {
const values = { address: record.line1, city: record.suburb,
state: record.state, zip: record.postcode };
for (const [name, value] of Object.entries(values)) {
const field = form.querySelector('[name="' + name + '"]');
if (!field) continue;
field.value = value;
field.dispatchEvent(new Event('input', { bubbles: true }));
field.dispatchEvent(new Event('change', { bubbles: true }));
}
}
});
}
});
</script>
<small>Incorporates or developed using G-NAF © Geoscape Australia licensed by the Commonwealth of Australia under the Open Geo-coded National Address File (G-NAF) End User Licence Agreement.</small>What to check
Display the attribution line. Quota: 5,000 free lookups per UTC month; check your plan and Retry-After. Browser keys are visible; use a dedicated key or server proxy.
Squarespace and Wix
Use Squarespace footer code injection on a plan that supports scripts; confirm the selectors on your published form. For Wix, put the second embed in an HTML component. Its selectors belong inside the iframe; native Wix fields use $w and need a Velo message bridge. Test navigation and keep manual entry.
Squarespace address field
Attribution: display the G-NAF attribution line in your product.
<!-- Attribution: display the G-NAF attribution line beside the form. -->
<!-- Confirm these selectors in your form block before publishing. -->
<script src="https://yepthere.au/widget.js" defer
data-key="YOUR_KEY" data-input=".form-wrapper .field.address input[name='address1']"
data-fields='{ "line1": ".form-wrapper .field.address input[name=address1]", "suburb": ".form-wrapper .field.address input[name=city]", "state": ".form-wrapper .field.address input[name=state]", "postcode": ".form-wrapper .field.address input[name=zip]" }'></script>
<small>Incorporates or developed using G-NAF © Geoscape Australia licensed by the Commonwealth of Australia under the Open Geo-coded National Address File (G-NAF) End User Licence Agreement.</small>Wix HTML component
Attribution: display the G-NAF attribution line in your product.
<!-- Attribution: display the G-NAF attribution line inside the component. -->
<label for="wix-address">Address</label><input id="wix-address">
<label for="wix-suburb">Suburb</label><input id="wix-suburb">
<label for="wix-state">State</label><input id="wix-state">
<label for="wix-postcode">Postcode</label><input id="wix-postcode">
<script src="https://yepthere.au/widget.js" defer
data-key="YOUR_KEY" data-input="#wix-address"
data-fields='{"line1":"#wix-address","suburb":"#wix-suburb","state":"#wix-state","postcode":"#wix-postcode"}'></script>
<small>Incorporates or developed using G-NAF © Geoscape Australia licensed by the Commonwealth of Australia under the Open Geo-coded National Address File (G-NAF) End User Licence Agreement.</small>What to check
Display the attribution line. Quota: 5,000 free lookups per UTC month; check your plan and Retry-After. Browser keys are visible; use a dedicated key or server proxy.
Zapier and Make
Add a Webhooks by Zapier GET step or Make HTTP request. Map your source address into q. Parse JSON, stop on errors or empty results, then map the first result’s line1, suburb, state and postcode. A first match still needs review.
HTTP step settings
Attribution: display the G-NAF attribution line in your product.
Attribution: display the G-NAF attribution line in the destination. Method: GET URL: https://yepthere.au/v1/addresses Header: x-api-key: YOUR_KEY Query: q = address from the previous step Query: limit = 1 Parse response: JSON Continue only when: HTTP 200, ok = true, results has an item First item: results[0] in JavaScript; get(results; 1) in Make Map: line1 -> address, suburb -> city, state -> state, postcode -> postcode On HTTP 429: wait Retry-After seconds before retrying
What to check
Display the attribution line. Quota: 5,000 free lookups per UTC month; check your plan and Retry-After. Browser keys are visible; use a dedicated key or server proxy.
Spreadsheets
In Google Sheets, open Extensions, Apps Script and save this function. Use =YEPTHERE_ADDRESS(A2). Sheet editors can read the key; recalculation spends quota. In Excel, use Power Query’s blank query and Advanced Editor; keep the workbook private and preserve postcode as text.
Google Sheets Apps Script
Attribution: display the G-NAF attribution line in your product.
// Attribution: display the G-NAF attribution line in a visible sheet note.
/** @customfunction */
function YEPTHERE_ADDRESS(value) {
const q = String(value || '').trim();
if (q.length < 3 || q.length > 200) return '';
const cache = CacheService.getScriptCache();
const cacheKey = 'address:' + q;
const cached = cache.get(cacheKey);
if (cached !== null) return cached;
const response = UrlFetchApp.fetch('https://yepthere.au/v1/addresses?limit=1&q=' + encodeURIComponent(q), {
headers: { 'x-api-key': 'YOUR_KEY' }, muteHttpExceptions: true
});
const data = JSON.parse(response.getContentText());
if (response.getResponseCode() !== 200 || !data.ok) {
throw new Error(data.message || 'Address search failed');
}
const record = data.results[0];
const result = record ? [record.line1, record.suburb, record.state, record.postcode].join(', ') : '';
cache.put(cacheKey, result, 600);
return result;
}Excel Power Query
Attribution: display the G-NAF attribution line in your product.
// Attribution: display the G-NAF attribution line in the workbook.
let
Response = Json.Document(Web.Contents("https://yepthere.au", [
RelativePath = "v1/addresses",
Query = [q = "9 George Street Sydney", limit = "1"],
Headers = [#"x-api-key" = "YOUR_KEY"]
])),
First = if List.IsEmpty(Response[results]) then null else Response[results]{0}
in
FirstWhat to check
Display the attribution line. Quota: 5,000 free lookups per UTC month; check your plan and Retry-After. Browser keys are visible; use a dedicated key or server proxy.
CSV clean-up
Jobs need an account key with jobs scope and configured storage. Upload UTF-8 CSV, up to 10,485,760 bytes and 50,000 rows. Submission and status responses include estimatedSecondsRemaining and estimatedReadyAt (ISO 8601 UTC), including scheduled work ahead. Estimates tighten during processing. Poll the returned job ID, then download job.downloadUrl with your account key when done. The route checks access and redirects to a five-minute storage URL. Review low confidence matches and import untrusted original columns as text.
Submit and check a CSV job
Attribution: display the G-NAF attribution line in your product.
# Attribution: display the G-NAF attribution line with exported results. curl --fail-with-body 'https://yepthere.au/v1/jobs' \ -H 'x-api-key: YOUR_KEY' -H 'Content-Type: text/csv' \ -H 'x-job-column: address' -H 'x-job-filename: addresses.csv' \ --data-binary @addresses.csv # Read job.id from the 202 response and replace JOB_ID below. curl --fail-with-body 'https://yepthere.au/v1/jobs/JOB_ID' \ -H 'x-api-key: YOUR_KEY' # Repeat at a modest interval until job.status is done or failed. # Download before job.expiresAt. curl strips Authorization on cross-origin redirects. curl --fail-with-body --location 'https://yepthere.au/v1/jobs/JOB_ID/download' \ -H 'Authorization: Bearer YOUR_KEY' --output results.csv
What to check
Display the attribution line. Quota: 5,000 free lookups per UTC month; check your plan and Retry-After. Browser keys are visible; use a dedicated key or server proxy.
AI assistants via MCP
Hosted: POST /mcp, Bearer key. Local: node sdk/mcp/index.js. Tools: search_addresses, resolve_address, nearest_addresses, validate_address, address_boundaries, postcode_summary, postcode_suburbs, search_postcodes, parse_address (parse scope), bulk_search (one lookup per query).
The free plan includes up to 500 MCP lookups a month. Paid plans use their full allowance. Upgrade your plan.
Claude Desktop: claude_desktop_config.json
Attribution: display the G-NAF attribution line in your product.
{
"mcpServers": {
"yepthere": {
"command": "npx",
"args": [
"-y",
"mcp-remote",
"https://yepthere.au/mcp",
"--transport",
"http-only",
"--header",
"Authorization:Bearer ${SESSIONAL_ADDRESSES_KEY}"
],
"env": {
"SESSIONAL_ADDRESSES_KEY": "YOUR_KEY"
}
}
}
}Local stdio alternative
Attribution: display the G-NAF attribution line in your product.
{
"mcpServers": {
"yepthere": {
"command": "node",
"args": [
"/ABSOLUTE/PATH/addresses/sdk/mcp/index.js"
],
"env": {
"SESSIONAL_ADDRESSES_KEY": "YOUR_KEY",
"SESSIONAL_ADDRESSES_BASE_URL": "https://yepthere.au",
"BRAND": "yepthere",
"BRAND_NAME": "yepthere",
"MCP_SERVER_NAME": "yepthere",
"CLIENT_USER_AGENT_PREFIX": "yepthere-mcp"
}
}
}
}Cursor: ~/.cursor/mcp.json
Attribution: display the G-NAF attribution line in your product.
{
"mcpServers": {
"yepthere": {
"url": "https://yepthere.au/mcp",
"headers": {
"Authorization": "Bearer YOUR_KEY"
}
}
}
}Claude Code HTTP or local stdio
Attribution: display the G-NAF attribution line in your product.
# Attribution: display the G-NAF attribution line with published results. claude mcp add --transport http yepthere https://yepthere.au/mcp \ --header 'Authorization: Bearer YOUR_KEY' # Or, from the repository root, use the local server under a different name: claude mcp add --env SESSIONAL_ADDRESSES_KEY=YOUR_KEY --env BRAND='yepthere' --env BRAND_NAME='yepthere' --env MCP_SERVER_NAME='yepthere' --env CLIENT_USER_AGENT_PREFIX='yepthere-mcp' --transport stdio \ yepthere-local -- node sdk/mcp/index.js
What to check
Display the attribution line. Quota: 5,000 free lookups per UTC month; check your plan and Retry-After. Browser keys are visible; use a dedicated key or server proxy.
Server-side clients
These MIT clients are vendored by copy; the packages are not published. Keep the repository’s MIT licence with your copy. Save each file at the named path. The source below is read from disk at module load. The SDK jobs methods upload raw CSV, pass filename and column as headers, and return a job object. Read job.id and poll until job.status is done or failed, then call jobs.download(job.id). JavaScript returns a body stream; Python returns bytes. Both strip download credentials before storage redirects. Use boundaries(layer, code) for boundary lookups. Python parse() preserves response.parsed. Safe requests retry 429 and 5xx up to six total attempts, adding full exponential jitter to the capped Retry-After delay.
JavaScript usage, Node 22 ESM
Attribution: display the G-NAF attribution line in your product.
// Attribution: display the G-NAF attribution line with published results.
import { Addresses } from './sdk/js/index.js';
const client = new Addresses({ apiKey: 'YOUR_KEY', baseUrl: "https://yepthere.au" });
const response = await client.search('9 George Street Sydney', { limit: 5 });
console.log(response.results, response.rateLimit.remaining);Python usage, run with PYTHONPATH=sdk/python
Attribution: display the G-NAF attribution line in your product.
# Attribution: display the G-NAF attribution line with published results.
from sessional_addresses import Addresses
client = Addresses(api_key="YOUR_KEY", base_url="https://yepthere.au")
response = client.search("9 George Street Sydney", limit=5)
print(response.results, response.rate_limit.remaining)sdk/js/index.js
Attribution: display the G-NAF attribution line in your product.
export const VERSION = '0.1.0';
const DEFAULT_USER_AGENT_PREFIX = 'sessional-addresses';
export class AddressesError extends Error {
constructor(message, { code = 'request-failed', status = 0, retryAfter = null, attempts = 1, cause } = {}) {
super(message, { cause });
this.name = 'AddressesError';
this.code = code;
this.status = status;
this.retryAfter = retryAfter;
this.attempts = attempts;
}
}
function numericHeader(headers, name) {
const value = headers.get(name);
if (value === null || !/^\d+$/.test(value)) return null;
const number = Number(value);
return Number.isSafeInteger(number) ? number : null;
}
export function retryDelay(headers) {
const seconds = numericHeader(headers, 'retry-after');
if (seconds !== null) return seconds;
const value = headers.get('retry-after');
const at = value && /[a-z]/i.test(value) ? Date.parse(value) : NaN;
return Number.isFinite(at) ? Math.max(0, Math.ceil((at - Date.now()) / 1000)) : null;
}
function sleep(ms, signal) {
return new Promise((resolve, reject) => {
const abort = () => { clearTimeout(timer); reject(signal.reason); };
const timer = setTimeout(() => { signal?.removeEventListener('abort', abort); resolve(); }, ms);
signal?.addEventListener('abort', abort, { once: true });
if (signal?.aborted) abort();
});
}
export function isTransportError(error) {
const code = error?.cause?.code ?? error?.code;
return ['ECONNRESET', 'ECONNREFUSED', 'EPIPE', 'ETIMEDOUT', 'ENOTFOUND', 'EAI_AGAIN', 'ENETUNREACH', 'EHOSTUNREACH',
'UND_ERR_SOCKET', 'UND_ERR_CONNECT_TIMEOUT', 'UND_ERR_HEADERS_TIMEOUT', 'UND_ERR_BODY_TIMEOUT'].includes(code)
|| ['TimeoutError', 'NetworkError'].includes(error?.name)
|| (error instanceof TypeError && !error.cause && /fetch|network/i.test(error.message));
}
/** Internal retry runner, also used by the checkout-based stdio package. */
export class RetryPolicy {
constructor({ retries = 5, backoff = {}, sleep: wait = sleep, random = Math.random } = {}) {
if (!Number.isInteger(retries) || retries < 0 || retries > 5) throw new TypeError('retries must be an integer from 0 to 5');
const { baseMs = 250, capMs = 8000 } = backoff;
if (![baseMs, capMs].every((value) => Number.isFinite(value) && value >= 0 && value <= 2147483647 / 4)) {
throw new TypeError('backoff values must be finite, non-negative milliseconds within the timer range');
}
if (typeof wait !== 'function' || typeof random !== 'function') throw new TypeError('sleep and random must be functions');
Object.assign(this, { retries, baseMs, capMs, sleep: wait, random });
}
async run(operation, { safe = true, signal } = {}) {
let attempts = 0;
let retryAfter = null;
const cancelled = (cause = signal?.reason) => new AddressesError('The address request was cancelled.', {
code: 'request-aborted', attempts, retryAfter, cause,
});
for (;;) {
if (signal?.aborted) throw cancelled();
attempts += 1;
try {
return await operation();
} catch (cause) {
if (signal?.aborted || cause?.name === 'AbortError') throw cancelled(cause);
const error = cause instanceof AddressesError ? cause : new AddressesError('Could not reach the address service.', {
code: isTransportError(cause) ? 'network-error' : 'request-failed', cause,
});
error.attempts = attempts;
retryAfter = error.retryAfter;
const retryable = (error.status === 429 || error.status >= 500 && error.status < 600)
|| (error.code === 'network-error' && (!error.status || (error.status >= 200 && error.status < 300)));
if (!safe || attempts > this.retries || !retryable) throw error;
const retryAfterMs = (retryAfter ?? 0) * 1000;
if (retryAfterMs > this.capMs * 4) throw error;
const delay = retryAfterMs + this.random() * Math.min(this.capMs, this.baseMs * 2 ** (attempts - 1));
try { await this.sleep(delay, signal); } catch (cause) {
if (signal?.aborted || cause?.name === 'AbortError') throw cancelled(cause);
throw new AddressesError('Could not wait before retrying.', { attempts, retryAfter, cause });
}
}
}
}
}
export class Addresses {
#apiKey;
#baseUrl;
#userAgent;
#retry;
/**
* @param {object} options
* @param {string} options.apiKey API key, never included in the URL.
* @param {string} options.baseUrl HTTP or HTTPS API base URL.
* @param {number} [options.retries=5] Additional attempts, an integer from 0 to 5.
* @param {{baseMs?: number, capMs?: number}} [options.backoff] Defaults to 250 ms and 8000 ms.
* @param {(ms: number, signal?: AbortSignal) => Promise<void>} [options.sleep] Injectable wait in milliseconds.
* @param {() => number} [options.random] Injectable uniform sample in [0, 1), default Math.random.
* Retries GET and bulk POST on 429/5xx or transport failures. Other
* POSTs require retryUnsafe: true in the method options. Retry-After (capped at four times capMs) is added to full exponential
* jitter (capped at capMs), with at most six attempts.
* Every method accepts signal for cancellation, including during the default wait.
*/
constructor({ apiKey, baseUrl, userAgentPrefix = DEFAULT_USER_AGENT_PREFIX, ...retryOptions } = {}) {
if (typeof apiKey !== 'string' || !apiKey.trim() || /[\r\n]/.test(apiKey)) throw new TypeError('apiKey is required');
let url;
try { url = new URL(baseUrl); } catch { throw new TypeError('baseUrl must be an HTTP or HTTPS URL'); }
if (!['http:', 'https:'].includes(url.protocol) || url.username || url.password || url.search || url.hash) throw new TypeError('baseUrl must be an HTTP or HTTPS URL without credentials, query or fragment');
if (typeof userAgentPrefix !== 'string' || !/^[a-z0-9][a-z0-9._-]*$/i.test(userAgentPrefix)) userAgentPrefix = DEFAULT_USER_AGENT_PREFIX;
this.#userAgent = `${userAgentPrefix}-js/${VERSION}`;
this.#apiKey = apiKey;
this.#baseUrl = url.href.replace(/\/+$/, '');
this.#retry = new RetryPolicy(retryOptions);
this.jobs = Object.freeze({
submit: (csvText, { filename, column, ...options } = {}) => this.#request('POST', '/v1/jobs', undefined, csvText, options, { filename, column }),
download: (id, options) => this.#request('GET', `/v1/jobs/${encodeURIComponent(id)}/download`, undefined, undefined, options, undefined, true),
get: (id, options) => this.#request('GET', `/v1/jobs/${encodeURIComponent(id)}`, undefined, undefined, options),
});
}
async #request(method, path, query, body, { signal, retryUnsafe = false } = {}, csv, download = false) {
const url = new URL(`${this.#baseUrl}${path}`);
for (const [key, value] of Object.entries(query ?? {})) {
if (value !== undefined && value !== null) url.searchParams.set(key, String(value));
}
const headers = { accept: download ? 'text/csv' : 'application/json', 'user-agent': this.#userAgent,
...(download ? { authorization: `Bearer ${this.#apiKey}` } : { 'x-api-key': this.#apiKey }) };
if (body !== undefined) headers['content-type'] = csv ? 'text/csv' : 'application/json';
if (csv?.filename !== undefined) headers['x-job-filename'] = encodeURIComponent(csv.filename);
if (csv?.column !== undefined) headers['x-job-column'] = csv.column;
const data = body === undefined ? undefined : csv ? body : JSON.stringify(body);
return this.#retry.run(async () => {
const response = await fetch(url, { method, headers, redirect: download ? 'follow' : 'error', signal, ...(data === undefined ? {} : { body: data }) });
const retryAfter = retryDelay(response.headers);
if (download && response.ok) return response.body;
let payload, raw = '';
try { raw = await response.text(); payload = JSON.parse(raw); } catch (cause) {
if (isTransportError(cause)) throw new AddressesError('Could not read the address response.', { code: 'network-error', status: response.status, retryAfter, cause });
throw new AddressesError(`The address service returned an invalid JSON response (HTTP ${response.status}): ${raw.slice(0, 200)}`, { code: response.ok ? 'invalid-response' : 'http-error', status: response.status, retryAfter, cause });
}
if (!response.ok || payload?.ok === false) {
throw new AddressesError(`${typeof payload?.message === 'string' ? payload.message : 'The address request failed.'} (HTTP ${response.status}): ${raw.slice(0, 200)}`, { code: typeof payload?.error === 'string' ? payload.error : 'http-error', status: response.status, retryAfter });
}
const isJob = path === '/v1/jobs' || path.startsWith('/v1/jobs/');
const valid = isJob ? payload?.job && typeof payload.job === 'object' && !Array.isArray(payload.job)
&& typeof payload.job.id === 'string' && typeof payload.job.status === 'string' : Array.isArray(payload?.results);
if (!payload || payload.ok !== true || !valid) {
throw new AddressesError('The address service returned an unexpected response.', { code: 'invalid-response', status: response.status, retryAfter });
}
return { ...payload, rateLimit: { limit: numericHeader(response.headers, 'x-ratelimit-limit'), remaining: numericHeader(response.headers, 'x-ratelimit-remaining'), reset: response.headers.get('x-ratelimit-reset') } };
}, { signal, safe: method === 'GET' || path === '/v1/addresses/bulk' || retryUnsafe === true });
}
search(q, { limit, ...options } = {}) { return this.#request('GET', '/v1/addresses', { q, limit }, undefined, options); }
byPostcode(postcode, { limit, offset, ...options } = {}) { return this.#request('GET', '/v1/addresses', { postcode, limit, offset }, undefined, options); }
get(id, options) { return this.#request('GET', `/v1/addresses/${encodeURIComponent(id)}`, undefined, undefined, options); }
nearest(lat, lng, { limit, ...options } = {}) { return this.#request('GET', '/v1/nearest', { lat, lng, limit }, undefined, options); }
postcode(code, options) { return this.#request('GET', `/v1/postcodes/${encodeURIComponent(code)}`, undefined, undefined, options); }
searchPostcodes(q, options) { return this.#request('GET', '/v1/postcodes', { q }, undefined, options); }
boundaries(layer, code, options) { return this.#request('GET', `/v1/boundaries/${encodeURIComponent(layer)}/${encodeURIComponent(code)}`, undefined, undefined, options); }
bulk(queries, options) { return this.#request('POST', '/v1/addresses/bulk', undefined, { queries }, options); }
parse(q, { limit, ...options } = {}) { return this.#request('POST', '/v1/parse', undefined, { q, limit }, options); }
}
export default Addresses;
sdk/python/sessional_addresses/__init__.py
Attribution: display the G-NAF attribution line in your product.
"""Australian address API client. Only Python standard-library modules are used."""
import re
import errno
import json
import math
import time
from dataclasses import dataclass, field
from email.utils import parsedate_to_datetime
from http.client import BadStatusLine, HTTPException, IncompleteRead
from random import random as _random
from socket import gaierror
from typing import Any
from urllib.error import HTTPError, URLError
from urllib.parse import quote, urlencode, urlsplit
from urllib.request import HTTPRedirectHandler, Request, build_opener
VERSION = "0.1.0"
_PREFIX = "sessional-addresses"
_ORIGIN = "https://addresses.sessional.au"
USER_AGENT = f"{_PREFIX}-python/{VERSION} (+{_ORIGIN})"
@dataclass
class RateLimit:
limit: int | None = None
remaining: int | None = None
reset: str | None = None
@dataclass
class Result:
ok: bool
release: str | None
results: list[dict[str, Any]]
rate_limit: RateLimit = field(default_factory=RateLimit)
parsed: dict[str, Any] | None = None
@dataclass
class JobResult:
ok: bool
job: dict[str, Any]
rate_limit: RateLimit = field(default_factory=RateLimit)
class AddressesError(Exception):
def __init__(self, message, *, code="request-failed", status=0, retry_after=None, attempts=1):
super().__init__(message)
self.code = code
self.status = status
self.retry_after = retry_after
self.attempts = attempts
class _NoRedirect(HTTPRedirectHandler):
def redirect_request(self, req, fp, code, msg, headers, newurl):
return None
def _integer_header(headers, name):
value = headers.get(name)
return int(value) if value and value.isascii() and value.isdigit() else None
def _retry_after(headers):
seconds = _integer_header(headers, "retry-after")
if seconds is not None:
return seconds
try:
return max(0, math.ceil(parsedate_to_datetime(headers.get("retry-after")).timestamp() - time.time()))
except (TypeError, ValueError, AttributeError, OverflowError):
return None
def _is_transport_error(error):
reason = error.reason if isinstance(error, URLError) else error
return (isinstance(reason, (ConnectionError, TimeoutError, gaierror, BadStatusLine, IncompleteRead))
or isinstance(reason, OSError) and reason.errno in (
errno.ECONNRESET, errno.ECONNREFUSED, errno.EPIPE, errno.ETIMEDOUT,
errno.ENETUNREACH, errno.EHOSTUNREACH))
class _Jobs:
def __init__(self, client):
self._client = client
def submit(self, csv_text, *, filename=None, column=None, retry_unsafe=False):
return self._client._request("POST", "/v1/jobs", body=csv_text, retry_unsafe=retry_unsafe, csv={"filename": filename, "column": column})
def download(self, id):
return self._client._request("GET", "/v1/jobs/" + quote(str(id), safe="") + "/download", download=True)
def get(self, id):
return self._client._request("GET", "/v1/jobs/" + quote(str(id), safe=""))
class Addresses:
"""Standard-library client with bounded retries for GET and bulk POST.
retries is 0..5 (default 5); backoff uses baseMs=250 and capMs=8000.
sleep receives seconds (default time.sleep); random returns a uniform [0, 1)
sample. Both are injectable. Parse and job submission need retry_unsafe=True.
Retry-After, capped at four times capMs, is added to full exponential
jitter capped at capMs. At most six attempts are made. KeyboardInterrupt interrupts requests and waits.
"""
def __init__(self, *, api_key, base_url, timeout=30, retries=5, backoff=None, sleep=None, random=None, user_agent=None):
if not isinstance(api_key, str) or not api_key.strip() or "\r" in api_key or "\n" in api_key:
raise ValueError("api_key is required")
url = urlsplit(base_url)
if url.scheme not in ("http", "https") or not url.hostname or url.username or url.password or url.query or url.fragment:
raise ValueError("base_url must be an HTTP or HTTPS URL without credentials, query or fragment")
self._user_agent = user_agent if user_agent is not None else USER_AGENT
if not self._user_agent or re.search(r"[\x00-\x1f\x7f]", self._user_agent):
raise ValueError("user_agent must be a non-empty HTTP header value")
self._base_url = base_url.rstrip("/")
self._api_key = api_key
self._timeout = timeout
if type(retries) is not int or not 0 <= retries <= 5:
raise ValueError("retries must be an integer from 0 to 5")
backoff = {} if backoff is None else backoff
self._base_ms = backoff.get("baseMs", 250)
self._cap_ms = backoff.get("capMs", 8000)
if not all(isinstance(value, (int, float)) and not isinstance(value, bool)
and math.isfinite(value) and 0 <= value <= 2147483647 / 4
for value in (self._base_ms, self._cap_ms)):
raise ValueError("backoff values must be finite, non-negative milliseconds within the timer range")
self._retries = retries
self._sleep = time.sleep if sleep is None else sleep
self._random = _random if random is None else random
if not callable(self._sleep) or not callable(self._random):
raise TypeError("sleep and random must be callable")
self._opener = build_opener(_NoRedirect())
self._download_opener = build_opener()
self.jobs = _Jobs(self)
def _request(self, method, path, query=None, body=None, *, retry_unsafe=False, csv=None, download=False):
safe = method == "GET" or path == "/v1/addresses/bulk" or retry_unsafe is True
for attempt in range(self._retries + 1):
try:
return self._request_once(method, path, query, body, csv, download)
except AddressesError as error:
error.attempts = attempt + 1
retryable = ((error.status == 429 or 500 <= error.status < 600)
or error.code == "network-error" and (not error.status or 200 <= error.status < 300))
if not safe or not retryable or attempt == self._retries:
raise
retry_after_ms = (error.retry_after or 0) * 1000
if retry_after_ms > self._cap_ms * 4:
raise
delay_ms = retry_after_ms + self._random() * min(self._cap_ms, self._base_ms * 2 ** attempt)
self._sleep(delay_ms / 1000)
def _request_once(self, method, path, query=None, body=None, csv=None, download=False):
url = self._base_url + path
if query:
query = {key: value for key, value in query.items() if value is not None}
if query:
url += "?" + urlencode(query)
headers = {"Accept": "text/csv" if download else "application/json", "User-Agent": self._user_agent}
if not download:
headers["x-api-key"] = self._api_key
data = None
if body is not None:
headers["Content-Type"] = "text/csv" if csv is not None else "application/json"
data = (body if csv is not None else json.dumps({key: value for key, value in body.items() if value is not None})).encode("utf-8")
if csv is not None:
headers.update({"x-job-" + key: quote(str(value), safe="") if key == "filename" else value for key, value in csv.items() if value is not None})
request = Request(url, data=data, headers=headers, method=method)
if download:
# urllib copies ordinary headers across origins. Never redirect credentials.
request.add_unredirected_header("Authorization", "Bearer " + self._api_key)
status = 0
response_headers = {}
try:
try:
response = (self._download_opener if download else self._opener).open(request, timeout=self._timeout)
except HTTPError as error:
response = error
with response:
status = response.code
response_headers = response.headers
raw = response.read()
except (URLError, OSError, HTTPException) as error:
raise AddressesError("Could not reach the address service.",
code="network-error" if _is_transport_error(error) else "request-failed",
status=status, retry_after=_retry_after(response_headers)) from error
retry_after = _retry_after(response_headers)
if download and 200 <= status < 300:
return raw
detail = f" (HTTP {status}): {raw.decode('utf-8', errors='replace')[:200]}"
try:
payload = json.loads(raw)
except (ValueError, UnicodeError) as error:
raise AddressesError("The address service returned an invalid JSON response." + detail, code="invalid-response" if 200 <= status < 300 else "http-error", status=status, retry_after=retry_after) from error
if not 200 <= status < 300 or isinstance(payload, dict) and payload.get("ok") is False:
details = payload if isinstance(payload, dict) else {}
message = details.get("message")
code = details.get("error")
raise AddressesError((message if isinstance(message, str) else "The address request failed.") + detail, code=code if isinstance(code, str) else "http-error", status=status, retry_after=retry_after)
is_job = path == "/v1/jobs" or path.startswith("/v1/jobs/")
job = payload.get("job") if isinstance(payload, dict) else None
valid = (isinstance(job, dict) and isinstance(job.get("id"), str) and isinstance(job.get("status"), str)) if is_job else isinstance(payload, dict) and isinstance(payload.get("results"), list)
if not isinstance(payload, dict) or payload.get("ok") is not True or not valid:
raise AddressesError("The address service returned an unexpected response.", code="invalid-response", status=status, retry_after=retry_after)
rate_limit = RateLimit(
limit=_integer_header(response_headers, "x-ratelimit-limit"),
remaining=_integer_header(response_headers, "x-ratelimit-remaining"),
reset=response_headers.get("x-ratelimit-reset"),
)
if is_job:
return JobResult(ok=True, job=job, rate_limit=rate_limit)
return Result(ok=True, release=payload.get("release"), results=payload["results"], parsed=payload.get("parsed"), rate_limit=rate_limit)
def search(self, q, *, limit=None):
return self._request("GET", "/v1/addresses", {"q": q, "limit": limit})
def by_postcode(self, postcode, *, limit=None, offset=None):
return self._request("GET", "/v1/addresses", {"postcode": postcode, "limit": limit, "offset": offset})
def get(self, id):
return self._request("GET", "/v1/addresses/" + quote(str(id), safe=""))
def nearest(self, lat, lng, *, limit=None):
return self._request("GET", "/v1/nearest", {"lat": lat, "lng": lng, "limit": limit})
def postcode(self, code):
return self._request("GET", "/v1/postcodes/" + quote(str(code), safe=""))
def search_postcodes(self, q):
return self._request("GET", "/v1/postcodes", {"q": q})
def boundaries(self, layer, code):
return self._request("GET", "/v1/boundaries/" + quote(str(layer), safe="") + "/" + quote(str(code), safe=""))
def bulk(self, queries):
return self._request("POST", "/v1/addresses/bulk", body={"queries": queries})
def parse(self, q, *, limit=None, retry_unsafe=False):
return self._request("POST", "/v1/parse", body={"q": q, "limit": limit}, retry_unsafe=retry_unsafe)
__all__ = ["Addresses", "AddressesError", "RateLimit", "Result", "JobResult", "VERSION"]
What to check
Display the attribution line. Quota: 5,000 free lookups per UTC month; check your plan and Retry-After. Browser keys are visible; use a dedicated key or server proxy.
Platform references: Next.js route handlers, HubSpot legacy forms, Wix iframe limits, Apps Script HTTP requests, Cursor MCP, Claude Code MCP.