Normalise shipping addresses at checkout

About 15 minutes. Updated 11 September 2026.

Take the address a customer typed, resolve it against G-NAF on your server, store the id, and hand your carrier the canonical form. Node, with the fallback for an address that does not resolve, unit numbers handled, and the same answer every time an order is looked at again.

A shipping address typed at checkout is the most expensive text a shop stores. A missing unit number is a failed delivery; a suburb spelled two ways is two customers in the CRM; a street type left off is a carrier surcharge. Normalising means storing what G-NAF says the address is, not what was typed, and doing it once, at the moment the customer can still be asked.

This runs on the server with a secret key from your Keys tab. For the autocomplete in the form itself, which stops most of these problems before they are typed, see the autocomplete guide; this guide is the net underneath it, for the orders that come in by any other route.

01Resolve, then fall back

/v1/addresses/resolve takes the whole address as one line and answers matched with the address when it is real. When it says matched: false, /v1/addresses/similar finds the nearest real ones, which is the typo case. Three outcomes, one function.

normalise-address.tstypescript
// normalise-address.ts  (Node 18+, no dependencies)
const API = "https://api.locio.com.au";
const KEY = process.env.LOCIO_KEY!;          // a secret key, server side only

export type Address = {
  gnaf_pid: string;
  formatted: string;
  lat: number;
  lng: number;
  mesh_block?: string;
  components: Record<string, string>;
};

export type Normalised =
  | { status: "resolved"; address: Address }
  | { status: "ambiguous"; candidates: Address[] }
  | { status: "unmatched" };

async function get<T>(path: string, params: Record<string, string>): Promise<T> {
  const url = new URL(API + path);
  for (const [k, v] of Object.entries(params)) url.searchParams.set(k, v);
  const res = await fetch(url, { headers: { Authorization: `Bearer ${KEY}` } });
  if (!res.ok) {
    // 402 is the plan's units spent, 429 is too fast; both are refusals, both free.
    const problem = await res.json().catch(() => ({}));
    throw new Error(problem.detail || problem.title || `locio ${res.status}`);
  }
  return (await res.json()).data as T;
}

/**
 * The whole address as one line, however the form collected it. Joining the
 * fields with commas is fine; the API works out where the street ends and
 * the suburb begins.
 */
export async function normalise(typed: string): Promise<Normalised> {
  const { matched, address } = await get<{ matched: boolean; address: Address | null }>(
    "/v1/addresses/resolve",
    { q: typed },
  );
  if (matched && address) return { status: "resolved", address };

  // Not a real address as written: usually a typo. Three units, so only now.
  const candidates = await get<Address[]>("/v1/addresses/similar", { q: typed, limit: "3" });
  if (candidates.length === 0) return { status: "unmatched" };
  return { status: "ambiguous", candidates };
}

02At checkout

Resolved: store the id and the canonical text. Ambiguous: show the candidates and let the customer pick, which is a resolve of exactly what they picked. Unmatched: do not block the sale, keep what was typed, and flag the order for a person, because a new estate can be ahead of the register.

checkout.tstypescript
// checkout.ts: the order handler
import { normalise } from "./normalise-address";

export async function placeOrder(form: { name: string; address: string; unit?: string }) {
  // A unit typed in its own field goes in front, the way an envelope reads:
  // "2/32 Marine Parade" resolves to the unit, "32 Marine Parade" to the building.
  const typed = form.unit ? `${form.unit}/${form.address}` : form.address;
  const result = await normalise(typed);

  switch (result.status) {
    case "resolved":
      return saveOrder({
        // Store the id and the canonical text. The text is what the label
        // shows; the id is what lets you read the address back in a year
        // without knowing how it was typed.
        gnaf_pid: result.address.gnaf_pid,
        shipping_address: result.address.formatted,
        shipping_lat: result.address.lat,
        shipping_lng: result.address.lng,
        address_as_typed: form.address,
      });

    case "ambiguous":
      // Show the candidates and let the customer pick; then place the order
      // with the pick's formatted text, which resolves exactly.
      return { needsConfirmation: result.candidates.map((c) => c.formatted) };

    case "unmatched":
      // Do not block the sale over an address G-NAF does not know: new
      // estates and some rural lots lag the register. Keep what was typed,
      // flag it for a person, and ship.
      return saveOrder({ gnaf_pid: null, shipping_address: form.address, address_as_typed: form.address, review: true });
  }
}

03The carrier's label

A courier or postal API wants the address in parts. They come back already split and named the way G-NAF names them, unit, level, number, street name, street type, suburb, state and postcode, so the consignment is assembled rather than parsed.

consignment.tstypescript
// The carrier wants the parts, not the line. They are already split,
// named the way G-NAF names them, so nothing is parsed back apart.
const c = result.address.components;
const consignment = {
  line1: [c.flat_type && c.flat_number ? `${c.flat_type} ${c.flat_number}` : "", `${c.number_first} ${c.street_name} ${c.street_type}`]
    .filter(Boolean)
    .join(", "),
  suburb: c.locality_name,
  state: c.state,
  postcode: c.postcode,
  lat: result.address.lat,   // for the driver's routing, when the carrier takes it
  lng: result.address.lng,
};

04Reading it back

The id is the point of storing it. The same id returns the same address every time, whoever typed it and however, so a reprint, a return or a report a year later reads it from one call and never from the text.

later.tstypescript
// A year on: the address the order shipped to, from the id alone.
const address = await get<Address>(`/v1/addresses/${order.gnaf_pid}`, {});

What it costs

One unit per order for the resolve, three more for the few that need a fuzzy match. The free tier's 10,000 units a month covers a few thousand orders; a shop doing a hundred thousand orders a month is on Growth. Refused calls are free.

Related