Addresses arrive in two ways. Either your form has separate fields for street, city, state and ZIP, and people still paste the whole thing into the first one, or you import addresses from somewhere else, a CSV from a trade show or a legacy CRM, where they were always one string. Both end in the same place: 350 5th Ave Apt 21B, New York, NY 10118 and 350 Fifth Avenue #21B New York 10118 are the same apartment, and nothing in your database knows it.
This lesson builds the three steps that fix that: parse the string into parts, check the parts against reference data, and assemble a normalised key. It uses Street Address Parser and Zip Codes Lookup, both 2 credits a call, with Cities Lookup for the cases where all you have is a city name.
One limit up front, because it shapes everything else: these tools parse and cross-check. They do not certify that a mailbox exists at the address. That needs postal-authority data such as USPS address certification, and it is a different, more expensive product. What you get here is cheap structure, which is what dedupe and most shipping forms actually need.
01What the parser returns
Start with a clean US address:
curl "https://api.apiverve.com/v1/streetaddressparser?address=1600+Amphitheatre+Parkway%2C+Mountain+View%2C+CA+94043" \
-H "x-api-key: YOUR_API_KEY"streetAddress is the street NAME (Amphitheatre); the number and the type are separate fields.
The documented fields sit under data.parsed: streetNumber, streetAddress, streetType, cityName, stateName and zipCode. Two of them are named in ways that cause bugs. streetAddress is the street name alone, Amphitheatre, not the full street line. stateName is the two-letter abbreviation, CA, not California.
The parser does real normalisation work on the street type and directionals. Parkway, parkway and Pkwy. all come back as Pkwy; street comes back St; Terrace comes back Ter; and the word north in 123 north main street comes back as streetPrefix: "N". A ZIP+4 such as 94043-1351 is cut to zipCode: "94043". It does not change the case of names, though: springfield il stays springfield and il. You will uppercase those yourself.
Unit numbers come back in fields that the published definition does not list yet, so code defensively around them. Observed in real responses:
{
"sec_unit_type": "Apt",
"sec_unit_num": "21B",
"streetNumber": "350",
"streetType": "Ave",
"streetAddress": "5th",
"cityName": "New York",
"stateName": "NY",
"zipCode": "10118"
}
#2 comes back as sec_unit_type: "#" and sec_unit_num: "2"; suite 400 as suite and 400. Parts that are not present are omitted from parsed, not set to null. Parse just 1600 amphitheatre parkway and you get three keys and no city at all. Read every field with a default.
The parser returns streetNumber 1600, streetAddress Amphitheatre, streetType Pkwy. What should you print on the first line of a shipping label?
02Where parsing goes wrong
A parser is a set of rules about what usually comes where. Feed it something unusual and it does not refuse; it returns a confident wrong answer. Two cases you will meet in real data, plus PO boxes, which parse cleanly but still need their own path.
No commas. 1600 amphitheatre parkway mountain view ca 94043-1351 comes back with streetType: "Mtn", streetAddress: "amphitheatre parkway" and cityName: "view". Without a comma, the parser read Mountain as a street type abbreviation. Add the commas, 1600 amphitheatre parkway, mountain view, ca 94043-1351, and every field is right. If your form has separate city, state and ZIP fields, the fix is simple: parse only the street line and take the rest from the fields the person filled in. Only parse the whole string when the whole string is all you have.
PO boxes. PO Box 1234, Austin, TX 78701-1234 comes back as sec_unit_type: "PO Box", sec_unit_num: "1234", cityName: "Austin", stateName: "TX" and zipCode: "78701", with no street fields. P.O. Box, POB and Post Office Box read the same way. Many carriers cannot deliver to PO boxes, so detect sec_unit_type matching PO Box and handle those addresses on their own path.
Non-US addresses. The parser is built for US addresses. 10 Downing Street, London SW1A 2AA returns a plausible street and cityName: "London", but no state and no postcode, and nothing tells you the format was foreign. If you ship internationally, branch on the country field before parsing and send only US addresses through this step.
The defense against all three is the same: never accept a parse on its own word. Check that the parts are the shape they should be.
const STATES = new Set(('AL AK AZ AR CA CO CT DE DC FL GA HI ID IL IN IA KS KY LA ME MD MA MI MN MS MO MT ' +
'NE NV NH NJ NM NY NC ND OH OK OR PA RI SC SD TN TX UT VT VA WA WV WI WY PR').split(' '));
export function parseProblems(p) {
const problems = [];
const state = (p.stateName ?? '').toUpperCase();
if (/^p\.?\s*o\.?\s*box$/i.test(p.sec_unit_type ?? '')) problems.push('po_box');
if (!p.streetNumber) problems.push('no_street_number');
if (!STATES.has(state)) problems.push('bad_state');
if (!/^\d{5}$/.test(p.zipCode ?? '')) problems.push('bad_zip');
if (STATES.has((p.cityName ?? '').toUpperCase())) problems.push('city_is_state');
return problems;
}import re
STATES = set(("AL AK AZ AR CA CO CT DE DC FL GA HI ID IL IN IA KS KY LA ME MD MA MI MN MS MO MT "
"NE NV NH NJ NM NY NC ND OH OK OR PA RI SC SD TN TX UT VT VA WA WV WI WY PR").split())
def parse_problems(p: dict) -> list:
problems = []
state = (p.get("stateName") or "").upper()
if re.fullmatch(r"p\.?\s*o\.?\s*box", p.get("sec_unit_type") or "", re.I):
problems.append("po_box")
if not p.get("streetNumber"):
problems.append("no_street_number")
if state not in STATES:
problems.append("bad_state")
if not re.fullmatch(r"\d{5}", p.get("zipCode") or ""):
problems.append("bad_zip")
if (p.get("cityName") or "").upper() in STATES:
problems.append("city_is_state")
return problemsThe PO box example trips only po_box and no_street_number, which is the signal to route it. The no-comma example passes this check, which is why the next step exists: view is a plausible city name until you compare it with the ZIP.
03Cross-check against the ZIP code
A five-digit ZIP is the most reliable part of a US address. People rarely mistype their own ZIP, and it pins down the state and usually the city. Zip Codes Lookup takes zip and returns city, state (the full name) and state_abbr. Latitude and longitude are premium.
curl "https://api.apiverve.com/v1/zipcodes?zip=94043" \
-H "x-api-key: YOUR_API_KEY"city Mountain View, state_abbr CA. Compare these with the parsed cityName and stateName.
Compare state_abbr with the parsed stateName exactly; a mismatch is a strong signal that either the ZIP or the state is wrong. Compare the city loosely. The dataset spells 64082 as Lees Summit, while residents write Lee's Summit, and one ZIP can serve a city plus neighbourhoods with their own names. Strip punctuation and case before comparing, and treat a city mismatch with a matching state as review, not reject. In the no-comma example, view against Mountain View fails even the loose comparison, which is how you catch it.
A 404 with zip not found does not mean the ZIP is fake. 10118 is the real ZIP of the Empire State Building, a single-building ZIP, and it is not in the dataset. Unique ZIPs for large buildings, institutions and PO box sections exist all over the country. Treat a 404 as "unknown, could not cross-check" and fall back to the parsed values. Rejecting on it would turn away everyone who works in a building big enough to have its own ZIP.
Cache lookups by ZIP indefinitely, or at least for weeks. There are only about 42,000 ZIPs, they rarely change, and an import of 100,000 customer addresses will hit the same few thousand repeatedly.
04When all you have is a city name
Some inputs give you a city and nothing else: a "where are you based" field, a conference badge scan. Cities Lookup searches a global city list by name and returns matches with country and population.
curl "https://api.apiverve.com/v1/citieslookup?city=Springfield" \
-H "x-api-key: YOUR_API_KEY"The top match is the largest Springfield, in Missouri, not the Illinois state capital.
That note is the lesson. Ask for five Springfields and you get Springfield (population 166,810), Springfield Gardens, West Springfield, an Australian Springfield Lakes and North Springfield. The response carries country and countryName but no state, and the most populous match is not necessarily the one your user meant. Use Cities Lookup to answer "is this a real place, and in which country", which it does well. Do not use its top result to fill in a state for a shipping address. If you need the state, ask for it. The custom limit and the coordinates are paid-plan features; on other plans, expect the single best match.
A ZIP lookup for 10118 returns 404 zip not found, but the parsed address otherwise looks complete. What should your import do?
05Build a normalised address key
With parsed, checked parts you can assemble a key that two spellings of the same address share. Uppercase everything, drop punctuation, collapse spaces, and use the parser's abbreviations, which are already consistent.
const clean = (s) => (s ?? '').toUpperCase().replace(/[^A-Z0-9 ]/g, '').replace(/\s+/g, ' ').trim();
export function addressKey(p) {
const unit = p.sec_unit_num ? `#${clean(p.sec_unit_num)}` : '';
return [p.streetNumber, p.streetPrefix, p.streetAddress, p.streetType, unit, p.zipCode]
.map((part) => (part === unit ? part : clean(part)))
.filter(Boolean)
.join(' ');
}
export async function parseAddress(address) {
const res = await fetch(`https://api.apiverve.com/v1/streetaddressparser?${new URLSearchParams({ address })}`, {
headers: { 'x-api-key': process.env.APIVERVE_API_KEY },
signal: AbortSignal.timeout(3000),
});
const body = await res.json();
if (!res.ok || body.status !== 'ok') throw new Error(body.error || `HTTP ${res.status}`);
return body.data.parsed;
}import os
import re
import requests
def clean(s) -> str:
s = re.sub(r"[^A-Z0-9 ]", "", (s or "").upper())
return re.sub(r"\s+", " ", s).strip()
def address_key(p: dict) -> str:
unit = f"#{clean(p.get('sec_unit_num'))}" if p.get("sec_unit_num") else ""
parts = [clean(p.get("streetNumber")), clean(p.get("streetPrefix")), clean(p.get("streetAddress")),
clean(p.get("streetType")), unit, clean(p.get("zipCode"))]
return " ".join(x for x in parts if x)
def parse_address(address: str) -> dict:
r = requests.get("https://api.apiverve.com/v1/streetaddressparser",
params={"address": address},
headers={"x-api-key": os.environ["APIVERVE_API_KEY"]}, timeout=3)
r.raise_for_status()
return r.json()["data"]["parsed"]1600 Amphitheatre Parkway, Mountain View, CA 94043 and 1600 amphitheatre parkway, mountain view, ca 94043-1351 both produce 1600 AMPHITHEATRE PKWY 94043. The key leaves out city and state on purpose: the ZIP already implies them, and city spellings are the noisiest part of any address. The unit is kept, because two flats in the same building are two customers.
Where the key will not help: 5th against Fifth, and street names that people abbreviate in their own ways (Saint and St, Mount and Mt). The parser normalises street types, not words inside street names. If those matter in your data, add a small replacement table for numbered and saint names before hashing, and accept that some duplicates will need a human.
For a backlog import, run the parser over every row once, store the parsed parts and the key next to the original string, and keep the original. You will want it the first time someone asks why two addresses were merged. Batch requests let you parse up to 200 addresses per call; see batch requests.
06Next
You now have structured, cross-checked addresses and a dedupe key. The last lesson handles the fields that look easiest and cause the most embarrassment: names and usernames. Continue to Names, gibberish and junk input.