Australian address autocomplete and verification in PHP
WattleAddr is an address API for Australian street addresses. The data is G-NAF, the national address file, and the API is hosted in Australia. This guide shows the calls in PHP: search as someone types, retrieve the address they pick, verify an address you already hold, find the nearest addresses to a point, check a form on your server, and handle errors. For the full quickstart, see the documentation.
Before you start
- Create a secret key in the console and set it as
WATTLEADDR_KEY. In a terminal, export WATTLEADDR_KEY=your_secret_key. On a web server, set it in the environment, not in your code. - Tested on PHP 8.3 with the curl extension. There are no packages to install.
- Secret keys stay on your server. Never put one in a web page or an app. For a type-ahead in the browser, use the widget or
@wattleaddr/jswith a publishable key. See Add it to your site. - Test keys are never charged. They stop working 15 days after your account was created (
402 test_mode_expired).
Search and retrieve
Two calls make up one lookup. autocomplete(q, session) returns suggestions as the person types, and retrieve(id, session) returns the full address for the one they pick. Use the same session for both. Keystrokes are fair use and are not billed on their own. One session is one lookup, charged when you retrieve. A retrieve without a session is charged on its own. This is a small wattleaddr.php file that the other files include.
<?php
// wattleaddr.php. Tested on PHP 8.3 with the curl extension, no packages.
// Reads your secret key from the WATTLEADDR_KEY environment variable.
const WATTLEADDR_API = 'https://api.wattleaddr.com.au';
class WattleAddrError extends RuntimeException
{
public function __construct(public int $status, public ?string $errorCode, string $message)
{
parent::__construct($status . ' ' . $errorCode . ': ' . $message);
}
}
function wa_request(string $method, string $path, array $params = [], ?array $body = null, bool $retry = true): array
{
$url = WATTLEADDR_API . $path . ($params ? '?' . http_build_query($params) : '');
$key = getenv('WATTLEADDR_KEY');
if ($key === false || $key === '') {
throw new WattleAddrError(0, 'missing_key', 'Set WATTLEADDR_KEY to your secret key.');
}
$headers = ['Authorization: Bearer ' . $key];
$retryAfter = 0;
$ch = curl_init($url);
curl_setopt_array($ch, [
CURLOPT_CUSTOMREQUEST => $method,
CURLOPT_RETURNTRANSFER => true,
CURLOPT_TIMEOUT => 10,
CURLOPT_HEADERFUNCTION => function ($ch, $line) use (&$retryAfter) {
if (stripos($line, 'Retry-After:') === 0) {
$retryAfter = (int) trim(substr($line, 12));
}
return strlen($line);
},
]);
if ($body !== null) {
$headers[] = 'Content-Type: application/json';
curl_setopt($ch, CURLOPT_POSTFIELDS, json_encode($body));
}
curl_setopt($ch, CURLOPT_HTTPHEADER, $headers);
$raw = curl_exec($ch);
if ($raw === false) {
throw new WattleAddrError(0, 'network_error', curl_error($ch));
}
$status = curl_getinfo($ch, CURLINFO_RESPONSE_CODE);
$data = json_decode($raw, true) ?? [];
if ($status >= 200 && $status < 300) {
return $data;
}
if ($retry && in_array($status, [429, 503], true)) {
// Wait as long as the API asks, then try once more.
sleep(min($retryAfter ?: 1, 30));
return wa_request($method, $path, $params, $body, false);
}
throw new WattleAddrError($status, $data['error']['code'] ?? null, $data['error']['message'] ?? '');
}
// One session per address. Use it for every keystroke and for the retrieve.
function wa_new_session(): string
{
return bin2hex(random_bytes(16));
}
function wa_autocomplete(string $q, string $session): array
{
return wa_request('GET', '/v1/addresses/autocomplete', ['q' => $q, 'session' => $session])['suggestions'];
}
function wa_retrieve(string $id, string $session): array
{
return wa_request('GET', '/v1/addresses/' . rawurlencode($id), ['session' => $session]);
}
Then use it like this.
<?php
// search.php. Run: WATTLEADDR_KEY=your_secret_key php search.php
require __DIR__ . '/wattleaddr.php';
$session = wa_new_session();
// Call this as the person types, from 3 characters. Keystrokes are fair use.
$suggestions = wa_autocomplete('1 martin place sydney', $session);
foreach ($suggestions as $s) {
echo $s['id'], ' ', $s['formatted'], "\n";
}
if (count($suggestions) === 0) {
exit("No suggestions\n");
}
// Retrieve the one they picked, with the same session. This is the lookup that is charged.
$address = wa_retrieve($suggestions[0]['id'], $session);
echo $address['formatted'], ' ', $address['components']['postcode'], ' billed: ', var_export($address['billed'], true), "\n";
In your app, retrieve the suggestion the person picked, not the first one. Suggestions start from three characters. PO Boxes are not supported, because G-NAF holds physical addresses only.
Verify an address you already hold
Use POST /v1/addresses/verify for free text you already have, such as an address from a form, a CRM or a spreadsheet. Send JSON with address and a session. It needs a secret key, so call it from your server only. A verify that finds no match is not billed.
Do not verify an address the person just picked from autocomplete. It is already a G-NAF record, and you paid for it when you retrieved it.
Add this to the same file.
// Add to wattleaddr.php.
// Secret key only. Do not call this for an address the person just picked from autocomplete.
function wa_verify(string $address, string $session): array
{
return wa_request('POST', '/v1/addresses/verify', [], ['address' => $address, 'session' => $session]);
}
function wa_check_address(string $text): array
{
$result = wa_verify($text, wa_new_session());
$verdict = $result['verdict']; // never branch on the deprecated "confidence"
$match = $result['match'];
if (($verdict === 'verified' || $verdict === 'corrected') && $match) {
return [
'verdict' => $verdict,
'ok' => true,
'address' => $match,
// For corrected, show this to the person. Spelling or abbreviations may have been fixed.
'show' => $verdict === 'corrected' ? $match['formatted'] : null,
// Parts we replaced, if any. Spelling fixes are not listed here.
'changed' => $result['changed_elements'],
];
}
// The match is one of the choices. alternatives does not include it.
$options = array_merge($match ? [$match] : [], $result['alternatives']);
if ($verdict === 'ambiguous' && $options) {
return ['verdict' => $verdict, 'ok' => false, 'ask' => 'pick', 'options' => $options];
}
return ['verdict' => $verdict, 'ok' => false, 'ask' => 'check'];
}
Branch on verdict. The older confidence field is deprecated.
verdict | What to do |
|---|---|
verified | Accept match. |
corrected | Accept match. Show the person match.formatted, because spelling or abbreviations may have been fixed. changed_elements lists any part we replaced. |
ambiguous | Do not accept it automatically. Ask the person to pick from match and alternatives. |
unverified | Ask the person to check the address. There is usually no match. |
Try it on four addresses, one for each verdict.
<?php
// verify_demo.php. Run: WATTLEADDR_KEY=your_secret_key php verify_demo.php
require __DIR__ . '/wattleaddr.php';
$inputs = ['35 Bergin Circuit Leppington NSW 2179', '35 Bergin Cct Leppingtn NSW 2179', '1 martin place sydney nsw 2000', '1 martn pl sydny nsw 2000'];
foreach ($inputs as $text) {
$result = wa_check_address($text);
if ($result['ok']) {
echo $result['verdict'], ' -> ', $result['address']['formatted'], ' ', json_encode($result['changed']), "\n";
} elseif ($result['ask'] === 'pick') {
echo $result['verdict'], ' -> ask the person to pick one of ', count($result['options']), "\n";
foreach ($result['options'] as $option) {
echo ' ', $option['id'], ' ', $option['formatted'], "\n";
}
} else {
echo $result['verdict'], " -> ask the person to check the address\n";
}
}
A verified address matches a G-NAF record. That is not a promise that mail can be delivered to it. The full list of response fields is in the verify documentation.
Reverse geocode a location
Use GET /v1/addresses/reverse to turn a point into the nearest addresses. Send lat and lng. It needs a secret key, so call it from your server only. The search is 100 metres by default, and limit is 1 by default. A call that returns no address is not billed. Add this to the same file.
// Add to wattleaddr.php.
// Secret key only, so call it from your server.
// Each call that finds an address is one lookup. A session does not make repeat calls free.
function wa_reverse_geocode(float $lat, float $lng, int $radius = 100, int $limit = 1): array
{
$params = ['lat' => $lat, 'lng' => $lng, 'radius' => $radius, 'limit' => $limit];
return wa_request('GET', '/v1/addresses/reverse', $params);
}
Then use it like this.
<?php
// reverse_demo.php. Run: WATTLEADDR_KEY=your_secret_key php reverse_demo.php
require __DIR__ . '/wattleaddr.php';
// A point from your own server, such as a driver's last position.
$result = wa_reverse_geocode(-33.99168126, 150.80123469, 100, 3);
if (count($result['addresses']) === 0) {
exit("No address within 100 metres\n");
}
foreach ($result['addresses'] as $a) {
echo $a['formatted'], ' ', $a['distance_m'], " m\n";
}
echo 'billed: ', var_export($result['billed'], true), "\n";
Each address has a distance_m in metres, nearest first. Units often share their building’s coordinate, so the base address comes first on a tie. The coordinate is the G-NAF geocode, usually the property centroid. It is not the building’s door. Every call that returns an address is one lookup, and a session does not stop a repeat call from being billed. The parameters, billing and errors are in the reverse geocoding documentation. New to APIs? Read the step by step guide.
Check a form on your server
Here is a plain PHP handler, with no framework, that does it for a submitted form. It takes the submitted address, verifies it and returns the result as JSON. Your page reads the verdict, ok, ask and show fields. When show is set, show the person that address before you save it.
<?php
// check.php. Plain PHP, no framework. Run: WATTLEADDR_KEY=your_secret_key php -S localhost:8000 check.php
// The built-in server is for trying it out. In production, point your web server at this script.
require __DIR__ . '/wattleaddr.php';
header('Content-Type: application/json');
if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
http_response_code(405);
exit(json_encode(['ok' => false]));
}
$text = $_POST['address'] ?? '';
if (!is_string($text) || strlen(trim($text)) < 3) {
http_response_code(400);
exit(json_encode(['ok' => false, 'ask' => 'check']));
}
try {
echo json_encode(wa_check_address(trim($text)));
} catch (WattleAddrError $e) {
error_log('WattleAddr: ' . $e->getMessage());
// Do not send the API's message to the browser. Let the person carry on and try again.
http_response_code(502);
echo json_encode(['ok' => false, 'error' => 'try_again']);
}
Post a form to it with curl -d "address=35 Bergin Circuit Leppington NSW 2179" and the JSON comes back with the verdict. Plain PHP is only an example. The same wa_check_address function works in any framework. Put it behind the same login, CSRF check and rate limit as the rest of your form. Anyone who can reach it can use your lookups.
Errors and retries
Every error has the same shape: {"error":{"code","message","docs"}}. Switch on code, not on the wording of message. The client above does two things for you. It retries once on 429 rate_limited and 503 service_unavailable, after the number of seconds in the Retry-After header. Any other error stops with the code the API sent.
401 unauthorized: no key was sent. Check thatWATTLEADDR_KEYis set.401 invalid_key: a key was sent, but it is wrong or revoked. Copy it again from the console.402 quota_exceeded: this month’s lookups are used up and your plan does not run over, or your plan has reached its overage ceiling.429 rate_limitedand503 service_unavailable: wait forRetry-After, then retry. If the retry also fails, tell the person to try again.
<?php
// errors_demo.php. Run: WATTLEADDR_KEY=your_secret_key php errors_demo.php
require __DIR__ . '/wattleaddr.php';
try {
wa_autocomplete('1 martin place sydney', wa_new_session());
} catch (WattleAddrError $e) {
// Switch on the code, not on the wording of the message.
switch ($e->errorCode) {
case 'unauthorized':
echo "unauthorized: no key was sent. Set WATTLEADDR_KEY.\n";
break;
case 'invalid_key':
echo "invalid_key: the key is wrong or revoked. Copy it again from the console.\n";
break;
case 'quota_exceeded':
// quota_exceeded comes from retrieve and verify. Autocomplete keeps working.
echo "quota_exceeded: this month's lookups are used up. Check your plan in the console.\n";
break;
case 'rate_limited':
case 'service_unavailable':
echo $e->errorCode . ": still refused after one retry. Try again later.\n";
break;
default:
throw $e;
}
}
The full list of codes, with what each one means and what to do, is on the errors page.
Storing the result
Store the id, the formatted text and the components together. Ids can change between G-NAF releases, so keep the parts as well as the id. To refresh an address, search again. Do not replay an old id. Keep postcode as a string, because some postcodes start with a zero. There is a schema in how to store an Australian address. For prices, see pricing.