How to Validate Addresses at Signup and Checkout
Where to check, what to do with each answer, and how to keep a slow API from costing you an order.
Validate in two places: suggest addresses while the customer types, and verify the final address on your server when they submit. Then act on the deliverability status: accept a confirmed address, ask for a unit number when it is missing, show a "did you mean" when a field was corrected, and ask the customer to fix an address that is not confirmed. Set a short timeout and accept the address as typed if the check does not answer in time.
Where to check
| Step | What runs | Why |
|---|---|---|
| While typing | Address autocomplete, called after a short pause in typing | Fewer typos and missing ZIP codes before the address is ever submitted |
| On submit | Address verification, called from your server | Checks the final address, including the unit number, and returns the deliverability status |
| Before fulfilment | Verification again for any order accepted without a result (timeout, override) | Catches problems before the parcel ships, without stopping the payment |
What to do with each answer
The one-letter DPV codes in the table (DPV, delivery point validation, says whether postal records confirm the address and its unit) are common US postal codes, and Google's Address Validation API and Smarty's US Street API both define them this way. Other vendors may report them differently, so map each vendor's response to your form's actions; what an address verification API returns covers every field. Google's API also gives a separate recommendation in its possibleNextAction field, which Google marks as a Preview feature: ACCEPT, CONFIRM, CONFIRM_ADD_SUBPREMISES (consider adding a unit number) and FIX. It is Google's own suggestion, not a translation of the DPV code.
| Result | Form action | Message to show |
|---|---|---|
| DPV Y, nothing changed | Accept and save the standardized address | None |
| DPV Y, a field was corrected (ZIP, city, spelling) | Show both versions, preselect the corrected one | "We updated your address to match postal records. Use this one?" |
| DPV Y, but a unit or box number the customer typed is missing or different in the answer | Keep the typed address, ask the customer to confirm, flag for review | "We could not confirm # 380. Use the address as you typed it?" |
| DPV D (unit missing) | Keep the address, ask for the unit | "This building has apartments or suites. Add your unit number." |
| DPV S (unit not confirmed) | Ask to check the unit, allow continuing | "We could not confirm unit 4B. Check it, or continue if it is right." |
| DPV N (not confirmed) | Ask to fix; allow continuing after confirmation; flag the order | "We could not find this address. Check the street number and ZIP code." |
| No status (empty) | Accept, flag for review | None |
| Timeout or API error | Accept as typed, verify again before fulfilment | None |
Do not hard-block on N. Postal records are not always current: Smarty's US Street API documentation says the postal "no-stat" list, which marks addresses as temporarily undeliverable, is "often several months behind". A block can turn a data gap into a lost order. A confirmation step plus a review flag keeps both the order and the warning.
Signup and checkout are not the same check
| Signup and account forms | Checkout and shipping | |
|---|---|---|
| Goal | A clean, standardized record: billing, tax, fraud checks, no duplicate customers | A parcel that arrives on the first attempt |
| Unit number | Optional on a general account form; required if your own rules need a full address | Required for D and S results |
| Record type | Decide whether you accept a PO box as a billing address | Record type P means a PO box; check your carrier's PO box rules before you buy a label |
| What to store | The standardized address and its ZIP+4, so the same customer matches next time | The standardized address on the order, the typed one in the order notes |
Server-side check with a timeout
The browser posts the address to your backend; your backend calls the verification API with the key, gives the whole call one 4-second budget, and returns only what the form needs: an action (accept, confirm, askUnit or fix), a review flag for your order queue, unitDropped when a unit the customer typed did not come back, the standardized address, the record type and the list of corrected fields. The 4-second budget, and accepting an address while it waits for a later check, are our design choices for a checkout, not rules of the API; pick the limit that suits your form. Each tab sets the budget in its own client's way, with a comment saying how, because some timeouts limit each wait rather than the whole call: the Requests documentation says its timeout "is not a time limit on the entire response download". The cURL tab shows the raw call and a real response we got on October 16, 2026; the other tabs are the same handler in ten languages, using the sthan.io API.
# The call your server makes (the API key never goes to the browser)
curl --max-time 4 \
"https://api.sthan.io/v2/address-verification/usa/1355%20Markett%20St%20Ste%20900%2C%20San%20Francisco%2C%20CA%2094103" \
-H "Authorization: Bearer $STHAN_API_KEY"
# Response, trimmed to the fields the form uses ("Markett" was a typo):
# {
# "Result": {
# "fullAddress": "1355 Market St STE 900, San Francisco, CA 94103-1337",
# "recordType": "H",
# "dpvConfirmation": "Y",
# "deliverableStatus": "Confirmed",
# "matchCode": { "houseNumber": "Matched", "street": "Corrected", "unit": "Matched",
# "city": "Matched", "state": "Matched", "zipCode": "Matched", "zip4": "Inferred" }
# }
# }
# street is "Corrected", so the form asks "did you mean" before saving.
// Node 18 or later, Express. POST /api/check-address { "address": "..." }
const express = require('express');
const app = express();
app.use(express.json());
const UNIT_WORDS = ['apt', 'apartment', 'ste', 'suite', 'unit', 'rm', 'room', 'bldg', 'box', 'pmb'];
const verifyLater = { action: 'accept', verifyLater: true };
// The unit or box number the customer typed ("Apt 4B", "# 380", "#380"), or null
function typedUnit(address) {
const t = address.replace(/,/g, ' ').split(/\s+/).filter(Boolean);
for (let i = 0; i < t.length; i++) {
const bare = t[i].replace(/^#+/, '');
if (t[i].startsWith('#') && bare) return bare;
if (t[i] !== '#' && !UNIT_WORDS.includes(t[i].toLowerCase().replace(/\.+$/, ''))) continue;
let j = i + 1;
if (t[j] === '#') j++;
if (j < t.length) return t[j].replace(/^#+/, '');
}
return null;
}
app.post('/api/check-address', async (req, res) => {
const address = String(req.body?.address ?? '');
const url = 'https://api.sthan.io/v2/address-verification/usa/' + encodeURIComponent(address);
try {
// One 4-second budget: the signal also stops a slow body in r.json()
const r = await fetch(url, {
headers: { Authorization: `Bearer ${process.env.STHAN_API_KEY}` },
signal: AbortSignal.timeout(4000)
});
if (r.status === 404) return res.json({ action: 'fix' }); // no match found
if (!r.ok) return res.json(verifyLater); // 429, 503, 500
const v = (await r.json())?.Result;
if (!v || typeof v !== 'object') return res.json(verifyLater); // no Result in the answer
const mc = v.matchCode ?? {};
const corrected = Object.keys(mc).filter(k => mc[k] === 'Corrected');
const typed = typedUnit(address);
const unitDropped = typed !== null && (mc.unit === 'Unmatched' ||
String(v.unitNumber ?? '').toLowerCase() !== typed.toLowerCase());
let action = 'accept', review = false;
switch (v.deliverableStatus) {
case 'Confirmed':
if (unitDropped) { action = 'confirm'; review = true; } // keep what they typed
else if (corrected.length) action = 'confirm';
break;
case 'ConfirmedPrimaryOnly': action = 'askUnit'; break;
case 'NotDeliverable': action = 'fix'; review = true; break;
default: review = true; // Unknown or empty
}
res.json({ action, review, corrected, unitDropped, standardized: v.fullAddress, recordType: v.recordType });
} catch {
res.json(verifyLater); // past the budget, network error or unreadable body
}
});
app.listen(3000);
# Flask + requests. POST /api/check-address {"address": "..."}
import os
from concurrent.futures import ThreadPoolExecutor, TimeoutError as PastBudget
from urllib.parse import quote
import requests
from flask import Flask, jsonify, request
app = Flask(__name__)
API = "https://api.sthan.io/v2/address-verification/usa/"
UNIT_WORDS = {"apt", "apartment", "ste", "suite", "unit", "rm", "room", "bldg", "box", "pmb"}
pool = ThreadPoolExecutor(max_workers=16)
def typed_unit(address):
"""The unit or box number the customer typed ("Apt 4B", "# 380", "#380"), or None."""
t = address.replace(",", " ").split()
for i, w in enumerate(t):
if w.startswith("#") and w.lstrip("#"):
return w.lstrip("#")
if w != "#" and w.lower().rstrip(".") not in UNIT_WORDS:
continue
j = i + 1
if j < len(t) and t[j] == "#":
j += 1
if j < len(t):
return t[j].lstrip("#")
return None
def call_api(address):
r = requests.get(API + quote(address, safe=""), timeout=4,
headers={"Authorization": f"Bearer {os.environ['STHAN_API_KEY']}"})
return r.status_code, (r.json() if r.status_code == 200 else None)
@app.post("/api/check-address")
def check_address():
address = str((request.get_json(silent=True) or {}).get("address", ""))
verify_later = {"action": "accept", "verifyLater": True}
try:
# requests' timeout limits each wait, not the whole call, so the pool gives the call one 4-second budget
status, body = pool.submit(call_api, address).result(timeout=4)
except (PastBudget, requests.RequestException, ValueError): # past the budget, network error, unreadable body
return jsonify(verify_later)
if status == 404:
return jsonify(action="fix") # no match found
v = body.get("Result") if isinstance(body, dict) else None
if status != 200 or not isinstance(v, dict): # 429, 503, 500, or no Result
return jsonify(verify_later)
mc = v.get("matchCode") if isinstance(v.get("matchCode"), dict) else {}
corrected = [field for field, s in mc.items() if s == "Corrected"]
typed = typed_unit(address)
unit_dropped = typed is not None and (mc.get("unit") == "Unmatched"
or str(v.get("unitNumber") or "").lower() != typed.lower())
status_name = v.get("deliverableStatus")
if status_name == "Confirmed" and unit_dropped:
action, review = "confirm", True # keep what they typed
elif status_name == "Confirmed":
action, review = ("confirm" if corrected else "accept"), False
elif status_name == "ConfirmedPrimaryOnly":
action, review = "askUnit", False
elif status_name == "NotDeliverable":
action, review = "fix", True
else: # Unknown or empty
action, review = "accept", True
return jsonify(action=action, review=review, corrected=corrected, unitDropped=unit_dropped,
standardized=v.get("fullAddress"), recordType=v.get("recordType"))
// ASP.NET Core minimal API (.NET 8). POST /api/check-address { "address": "..." }
using System.Net;
using System.Net.Http.Headers;
using System.Text.Json;
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddHttpClient("sthan", c =>
{
c.BaseAddress = new Uri("https://api.sthan.io/");
c.Timeout = TimeSpan.FromSeconds(4); // one budget: GetAsync reads the whole body before it returns
c.DefaultRequestHeaders.Authorization =
new AuthenticationHeaderValue("Bearer", Environment.GetEnvironmentVariable("STHAN_API_KEY"));
});
var app = builder.Build();
string[] unitWords = { "apt", "apartment", "ste", "suite", "unit", "rm", "room", "bldg", "box", "pmb" };
var verifyLater = new { action = "accept", verifyLater = true };
// The unit or box number the customer typed ("Apt 4B", "# 380", "#380"), or null
string TypedUnit(string address)
{
var t = address.Replace(',', ' ').Split((char[])null, StringSplitOptions.RemoveEmptyEntries);
for (var i = 0; i < t.Length; i++)
{
var bare = t[i].TrimStart('#');
if (t[i].StartsWith('#') && bare.Length > 0) return bare;
if (t[i] != "#" && !unitWords.Contains(t[i].ToLowerInvariant().TrimEnd('.'))) continue;
var j = i + 1;
if (j < t.Length && t[j] == "#") j++;
if (j < t.Length) return t[j].TrimStart('#');
}
return null;
}
string Str(JsonElement e, string name) =>
e.ValueKind == JsonValueKind.Object && e.TryGetProperty(name, out var p) && p.ValueKind == JsonValueKind.String
? p.GetString() : null;
app.MapPost("/api/check-address", async (CheckRequest req, IHttpClientFactory http) =>
{
var address = req.Address ?? "";
try
{
using var r = await http.CreateClient("sthan")
.GetAsync("v2/address-verification/usa/" + Uri.EscapeDataString(address));
if (r.StatusCode == HttpStatusCode.NotFound) return Results.Json(new { action = "fix" }); // no match found
if (!r.IsSuccessStatusCode) return Results.Json(verifyLater); // 429, 503, 500
using var doc = JsonDocument.Parse(await r.Content.ReadAsStringAsync());
if (doc.RootElement.ValueKind != JsonValueKind.Object ||
!doc.RootElement.TryGetProperty("Result", out var v) || v.ValueKind != JsonValueKind.Object)
return Results.Json(verifyLater); // no Result in the answer
var mc = v.TryGetProperty("matchCode", out var m) ? m : default;
var corrected = mc.ValueKind == JsonValueKind.Object
? mc.EnumerateObject()
.Where(p => p.Value.ValueKind == JsonValueKind.String && p.Value.GetString() == "Corrected")
.Select(p => p.Name).ToList()
: new List<string>();
var typed = TypedUnit(address);
var unitDropped = typed != null && (Str(mc, "unit") == "Unmatched" ||
!string.Equals(Str(v, "unitNumber"), typed, StringComparison.OrdinalIgnoreCase));
var (action, review) = Str(v, "deliverableStatus") switch
{
"Confirmed" when unitDropped => ("confirm", true), // keep what they typed
"Confirmed" => (corrected.Count > 0 ? "confirm" : "accept", false),
"ConfirmedPrimaryOnly" => ("askUnit", false),
"NotDeliverable" => ("fix", true),
_ => ("accept", true) // Unknown or empty
};
return Results.Json(new
{
action, review, corrected, unitDropped,
standardized = Str(v, "fullAddress"),
recordType = Str(v, "recordType")
});
}
catch (Exception e) when (e is HttpRequestException or TaskCanceledException or JsonException)
{
return Results.Json(verifyLater); // past the budget, network error or unreadable body
}
});
app.Run();
record CheckRequest(string Address);
// Spring Boot + java.net.http (Java 17 or later). POST /api/check-address { "address": "..." }
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import java.net.URI;
import java.net.URLEncoder;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.nio.charset.StandardCharsets;
import java.time.Duration;
import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.TimeoutException;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RestController;
@RestController
public class CheckAddressController {
private static final HttpClient HTTP = HttpClient.newBuilder().connectTimeout(Duration.ofSeconds(4)).build();
private static final ObjectMapper JSON = new ObjectMapper();
private static final Set<String> UNIT_WORDS =
Set.of("apt", "apartment", "ste", "suite", "unit", "rm", "room", "bldg", "box", "pmb");
private static final Map<String, Object> VERIFY_LATER = Map.of("action", "accept", "verifyLater", true);
public record CheckRequest(String address) {}
/** The unit or box number the customer typed ("Apt 4B", "# 380", "#380"), or null. */
static String typedUnit(String address) {
String[] t = address.replace(',', ' ').trim().split("\\s+");
for (int i = 0; i < t.length; i++) {
String bare = t[i].replaceFirst("^#+", "");
if (t[i].startsWith("#") && !bare.isEmpty()) return bare;
if (!t[i].equals("#") && !UNIT_WORDS.contains(t[i].toLowerCase().replaceFirst("\\.+$", ""))) continue;
int j = i + 1;
if (j < t.length && t[j].equals("#")) j++;
if (j < t.length) return t[j].replaceFirst("^#+", "");
}
return null;
}
private static String text(JsonNode n, String field) {
return n.path(field).isTextual() ? n.path(field).asText() : null;
}
@PostMapping("/api/check-address")
public Map<String, Object> check(@RequestBody CheckRequest req) {
String address = req.address() == null ? "" : req.address();
String encoded = URLEncoder.encode(address, StandardCharsets.UTF_8).replace("+", "%20");
HttpRequest call = HttpRequest.newBuilder(URI.create("https://api.sthan.io/v2/address-verification/usa/" + encoded))
.header("Authorization", "Bearer " + System.getenv("STHAN_API_KEY"))
.build();
CompletableFuture<HttpResponse<String>> pending = HTTP.sendAsync(call, HttpResponse.BodyHandlers.ofString());
try {
// get() waits for the whole body, so this is one 4-second budget for the call
HttpResponse<String> r = pending.get(4, TimeUnit.SECONDS);
if (r.statusCode() == 404) return Map.of("action", "fix"); // no match found
if (r.statusCode() != 200) return VERIFY_LATER; // 429, 503, 500
JsonNode v = JSON.readTree(r.body()).path("Result");
if (!v.isObject()) return VERIFY_LATER; // no Result in the answer
JsonNode mc = v.path("matchCode");
List<String> corrected = new ArrayList<>();
mc.fields().forEachRemaining(f -> {
if ("Corrected".equals(f.getValue().asText())) corrected.add(f.getKey());
});
String typed = typedUnit(address);
boolean unitDropped = typed != null && ("Unmatched".equals(mc.path("unit").asText())
|| !typed.equalsIgnoreCase(v.path("unitNumber").asText("")));
String action = "accept";
boolean review = false;
switch (v.path("deliverableStatus").asText("")) {
case "Confirmed" -> {
if (unitDropped) { action = "confirm"; review = true; } // keep what they typed
else if (!corrected.isEmpty()) action = "confirm";
}
case "ConfirmedPrimaryOnly" -> action = "askUnit";
case "NotDeliverable" -> { action = "fix"; review = true; }
default -> review = true; // Unknown or empty
}
Map<String, Object> out = new LinkedHashMap<>();
out.put("action", action);
out.put("review", review);
out.put("corrected", corrected);
out.put("unitDropped", unitDropped);
out.put("standardized", text(v, "fullAddress"));
out.put("recordType", text(v, "recordType"));
return out;
} catch (TimeoutException e) {
pending.cancel(true);
return VERIFY_LATER; // past the budget
} catch (Exception e) { // network error or unreadable body
if (e instanceof InterruptedException) Thread.currentThread().interrupt();
return VERIFY_LATER;
}
}
}
<?php
// Laravel, routes/api.php (served under /api). POST /api/check-address {"address": "..."}
// config/services.php: 'sthan' => ['key' => env('STHAN_API_KEY')], and STHAN_API_KEY=your-key in .env
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Route;
Route::post('/check-address', function (Request $request) {
$address = (string) $request->input('address', '');
$verifyLater = ['action' => 'accept', 'verifyLater' => true];
// The unit or box number the customer typed ("Apt 4B", "# 380", "#380"), or null
$typedUnit = function (string $address): ?string {
$t = preg_split('/\s+/', trim(str_replace(',', ' ', $address)), -1, PREG_SPLIT_NO_EMPTY);
$words = ['apt', 'apartment', 'ste', 'suite', 'unit', 'rm', 'room', 'bldg', 'box', 'pmb'];
foreach ($t as $i => $w) {
if (str_starts_with($w, '#') && ltrim($w, '#') !== '') return ltrim($w, '#');
if ($w !== '#' && ! in_array(rtrim(strtolower($w), '.'), $words, true)) continue;
$j = $i + 1;
if (($t[$j] ?? null) === '#') $j++;
if (isset($t[$j])) return ltrim($t[$j], '#');
}
return null;
};
try {
$r = Http::withToken(config('services.sthan.key'))
->timeout(4) // one budget: cURL's total time, connect to last byte
->get('https://api.sthan.io/v2/address-verification/usa/' . rawurlencode($address));
} catch (\Exception $e) { // past the budget or network error
return $verifyLater;
}
if ($r->status() === 404) {
return ['action' => 'fix']; // no match found
}
$v = $r->successful() ? $r->json('Result') : null; // null for an unreadable body
if (! is_array($v)) {
return $verifyLater; // 429, 503, 500, or no Result
}
$mc = is_array($v['matchCode'] ?? null) ? $v['matchCode'] : [];
$corrected = array_keys(array_filter($mc, fn ($s) => $s === 'Corrected'));
$typed = $typedUnit($address);
$unitDropped = $typed !== null && (($mc['unit'] ?? null) === 'Unmatched'
|| strcasecmp((string) ($v['unitNumber'] ?? ''), $typed) !== 0);
[$action, $review] = match ($v['deliverableStatus'] ?? null) {
'Confirmed' => $unitDropped ? ['confirm', true] // keep what they typed
: [$corrected ? 'confirm' : 'accept', false],
'ConfirmedPrimaryOnly' => ['askUnit', false],
'NotDeliverable' => ['fix', true],
default => ['accept', true], // Unknown or empty
};
return [
'action' => $action, 'review' => $review, 'corrected' => $corrected, 'unitDropped' => $unitDropped,
'standardized' => $v['fullAddress'] ?? null, 'recordType' => $v['recordType'] ?? null,
];
});
# Rails. config/routes.rb: post "/api/check-address", to: "check_address#create"
require "net/http"
require "json"
require "timeout"
class CheckAddressController < ActionController::API
API = "https://api.sthan.io/v2/address-verification/usa/"
UNIT_WORDS = %w[apt apartment ste suite unit rm room bldg box pmb].freeze
VERIFY_LATER = { action: "accept", verifyLater: true }.freeze
def create
address = params[:address].to_s
uri = URI(API + ERB::Util.url_encode(address))
req = Net::HTTP::Get.new(uri)
req["Authorization"] = "Bearer #{ENV.fetch("STHAN_API_KEY")}"
# One 4-second budget for connect, headers and body; max_retries: 0 stops Net::HTTP retrying the GET
r = Timeout.timeout(4) do
Net::HTTP.start(uri.host, uri.port, use_ssl: uri.scheme == "https",
open_timeout: 4, read_timeout: 4, max_retries: 0) { |http| http.request(req) }
end
return render(json: { action: "fix" }) if r.code == "404" # no match found
return render(json: VERIFY_LATER) unless r.code == "200" # 429, 503, 500
body = JSON.parse(r.body)
v = body.is_a?(Hash) ? body["Result"] : nil
return render(json: VERIFY_LATER) unless v.is_a?(Hash) # no Result in the answer
mc = v["matchCode"].is_a?(Hash) ? v["matchCode"] : {}
corrected = mc.select { |_field, s| s == "Corrected" }.keys
typed = typed_unit(address)
unit_dropped = !typed.nil? && (mc["unit"] == "Unmatched" || !v["unitNumber"].to_s.casecmp?(typed))
action, review =
case v["deliverableStatus"]
when "Confirmed"
if unit_dropped then ["confirm", true] # keep what they typed
else [corrected.empty? ? "accept" : "confirm", false]
end
when "ConfirmedPrimaryOnly" then ["askUnit", false]
when "NotDeliverable" then ["fix", true]
else ["accept", true] # Unknown or empty
end
render json: { action: action, review: review, corrected: corrected, unitDropped: unit_dropped,
standardized: v["fullAddress"], recordType: v["recordType"] }
rescue Timeout::Error, SocketError, SystemCallError, IOError, OpenSSL::SSL::SSLError,
Net::HTTPBadResponse, JSON::ParserError
render json: VERIFY_LATER # past the budget, network error or unreadable body
end
private
# The unit or box number the customer typed ("Apt 4B", "# 380", "#380"), or nil
def typed_unit(address)
t = address.tr(",", " ").split
t.each_with_index do |w, i|
bare = w.sub(/\A#+/, "")
return bare if w.start_with?("#") && !bare.empty?
next unless w == "#" || UNIT_WORDS.include?(w.downcase.sub(/\.+\z/, ""))
j = i + 1
j += 1 if t[j] == "#"
return t[j].sub(/\A#+/, "") if j < t.size
end
nil
end
end
// Go 1.22 or later, standard library only. POST /api/check-address {"address": "..."}
package main
import (
"encoding/json"
"net/http"
"net/url"
"os"
"strings"
"time"
)
// Client.Timeout covers connect, headers and reading the body: one 4-second budget
var client = &http.Client{Timeout: 4 * time.Second}
var unitWords = map[string]bool{"apt": true, "apartment": true, "ste": true, "suite": true, "unit": true,
"rm": true, "room": true, "bldg": true, "box": true, "pmb": true}
// typedUnit returns the unit or box number the customer typed ("Apt 4B", "# 380", "#380").
func typedUnit(address string) (string, bool) {
t := strings.Fields(strings.ReplaceAll(address, ",", " "))
for i, w := range t {
bare := strings.TrimLeft(w, "#")
if strings.HasPrefix(w, "#") && bare != "" {
return bare, true
}
if w != "#" && !unitWords[strings.TrimRight(strings.ToLower(w), ".")] {
continue
}
j := i + 1
if j < len(t) && t[j] == "#" {
j++
}
if j < len(t) {
return strings.TrimLeft(t[j], "#"), true
}
}
return "", false
}
func checkAddress(w http.ResponseWriter, r *http.Request) {
var in struct {
Address string `json:"address"`
}
if err := json.NewDecoder(r.Body).Decode(&in); err != nil {
http.Error(w, "bad request", http.StatusBadRequest)
return
}
reply := func(body map[string]any) {
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(body)
}
verifyLater := map[string]any{"action": "accept", "verifyLater": true}
req, _ := http.NewRequest(http.MethodGet,
"https://api.sthan.io/v2/address-verification/usa/"+url.PathEscape(in.Address), nil)
req.Header.Set("Authorization", "Bearer "+os.Getenv("STHAN_API_KEY"))
resp, err := client.Do(req)
if err != nil { // past the budget or network error
reply(verifyLater)
return
}
defer resp.Body.Close()
if resp.StatusCode == http.StatusNotFound { // no match found
reply(map[string]any{"action": "fix"})
return
}
if resp.StatusCode != http.StatusOK { // 429, 503, 500
reply(verifyLater)
return
}
var body struct {
Result *struct {
FullAddress string `json:"fullAddress"`
RecordType string `json:"recordType"`
UnitNumber string `json:"unitNumber"`
DeliverableStatus string `json:"deliverableStatus"`
MatchCode map[string]string `json:"matchCode"`
}
}
if err := json.NewDecoder(resp.Body).Decode(&body); err != nil || body.Result == nil {
reply(verifyLater) // past the budget, unreadable body or no Result
return
}
v := body.Result
corrected := []string{}
for field, s := range v.MatchCode {
if s == "Corrected" {
corrected = append(corrected, field)
}
}
typed, hasUnit := typedUnit(in.Address)
unitDropped := hasUnit && (v.MatchCode["unit"] == "Unmatched" || !strings.EqualFold(v.UnitNumber, typed))
action, review := "accept", false
switch v.DeliverableStatus {
case "Confirmed":
if unitDropped {
action, review = "confirm", true // keep what they typed
} else if len(corrected) > 0 {
action = "confirm"
}
case "ConfirmedPrimaryOnly":
action = "askUnit"
case "NotDeliverable":
action, review = "fix", true
default: // Unknown or empty
review = true
}
reply(map[string]any{"action": action, "review": review, "corrected": corrected, "unitDropped": unitDropped,
"standardized": v.FullAddress, "recordType": v.RecordType})
}
func main() {
http.HandleFunc("POST /api/check-address", checkAddress)
http.ListenAndServe(":8080", nil)
}
// axum + reqwest. POST /api/check-address {"address": "..."}
// Cargo.toml: axum = "0.7", reqwest = { version = "0.12", features = ["json"] }, serde_json = "1",
// tokio = { version = "1", features = ["full"] }, urlencoding = "2"
use axum::{routing::post, Json, Router};
use serde_json::{json, Value};
use std::time::Duration;
const UNIT_WORDS: [&str; 10] = ["apt", "apartment", "ste", "suite", "unit", "rm", "room", "bldg", "box", "pmb"];
/// The unit or box number the customer typed ("Apt 4B", "# 380", "#380"), or None
fn typed_unit(address: &str) -> Option<String> {
let cleaned = address.replace(',', " ");
let t: Vec<&str> = cleaned.split_whitespace().collect();
for (i, w) in t.iter().enumerate() {
let bare = w.trim_start_matches('#');
if w.starts_with('#') && !bare.is_empty() {
return Some(bare.to_string());
}
let word = w.to_lowercase();
if *w != "#" && !UNIT_WORDS.contains(&word.trim_end_matches('.')) {
continue;
}
let mut j = i + 1;
if t.get(j) == Some(&"#") {
j += 1;
}
if let Some(n) = t.get(j) {
return Some(n.trim_start_matches('#').to_string());
}
}
None
}
async fn check_address(Json(body): Json<Value>) -> Json<Value> {
let address = body["address"].as_str().unwrap_or_default();
let url = format!("https://api.sthan.io/v2/address-verification/usa/{}", urlencoding::encode(address));
let api_key = std::env::var("STHAN_API_KEY").unwrap_or_default();
let verify_later = json!({ "action": "accept", "verifyLater": true });
let resp = match reqwest::Client::new()
.get(url)
.bearer_auth(api_key)
.timeout(Duration::from_secs(4)) // one budget, from connecting until the body has finished
.send()
.await
{
Ok(r) => r,
Err(_) => return Json(verify_later), // past the budget or network error
};
if resp.status() == 404 {
return Json(json!({ "action": "fix" })); // no match found
}
if !resp.status().is_success() {
return Json(verify_later); // 429, 503, 500
}
let v = match resp.json::<Value>().await {
Ok(j) if j["Result"].is_object() => j["Result"].clone(),
_ => return Json(verify_later), // past the budget, unreadable body or no Result
};
let mc = &v["matchCode"];
let corrected: Vec<String> = mc
.as_object()
.map(|m| m.iter().filter(|(_, s)| s.as_str() == Some("Corrected")).map(|(k, _)| k.clone()).collect())
.unwrap_or_default();
let unit_dropped = typed_unit(address).is_some_and(|u| {
mc["unit"] == "Unmatched" || !v["unitNumber"].as_str().unwrap_or("").eq_ignore_ascii_case(&u)
});
let (action, review) = match v["deliverableStatus"].as_str() {
Some("Confirmed") if unit_dropped => ("confirm", true), // keep what they typed
Some("Confirmed") => (if corrected.is_empty() { "accept" } else { "confirm" }, false),
Some("ConfirmedPrimaryOnly") => ("askUnit", false),
Some("NotDeliverable") => ("fix", true),
_ => ("accept", true), // Unknown or empty
};
Json(json!({ "action": action, "review": review, "corrected": corrected, "unitDropped": unit_dropped,
"standardized": v["fullAddress"], "recordType": v["recordType"] }))
}
#[tokio::main]
async fn main() {
let app = Router::new().route("/api/check-address", post(check_address));
let listener = tokio::net::TcpListener::bind("0.0.0.0:3000").await.unwrap();
axum::serve(listener, app).await.unwrap();
}
// Ktor 2.3. POST /api/check-address {"address": "..."}
// build.gradle.kts: io.ktor:ktor-server-netty, io.ktor:ktor-server-content-negotiation,
// io.ktor:ktor-serialization-jackson (all 2.3.12)
import com.fasterxml.jackson.databind.ObjectMapper
import io.ktor.serialization.jackson.*
import io.ktor.server.application.*
import io.ktor.server.engine.*
import io.ktor.server.netty.*
import io.ktor.server.plugins.contentnegotiation.*
import io.ktor.server.request.*
import io.ktor.server.response.*
import io.ktor.server.routing.*
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import java.net.URI
import java.net.URLEncoder
import java.net.http.HttpClient
import java.net.http.HttpRequest
import java.net.http.HttpResponse
import java.time.Duration
import java.util.concurrent.TimeUnit
val http: HttpClient = HttpClient.newBuilder().connectTimeout(Duration.ofSeconds(4)).build()
val mapper = ObjectMapper()
val UNIT_WORDS = setOf("apt", "apartment", "ste", "suite", "unit", "rm", "room", "bldg", "box", "pmb")
val VERIFY_LATER = mapOf("action" to "accept", "verifyLater" to true)
data class CheckRequest(val address: String = "")
// The unit or box number the customer typed ("Apt 4B", "# 380", "#380"), or null
fun typedUnit(address: String): String? {
val t = address.replace(',', ' ').split(Regex("\\s+")).filter { it.isNotEmpty() }
for ((i, w) in t.withIndex()) {
val bare = w.trimStart('#')
if (w.startsWith("#") && bare.isNotEmpty()) return bare
if (w != "#" && w.lowercase().trimEnd('.') !in UNIT_WORDS) continue
var j = i + 1
if (t.getOrNull(j) == "#") j++
t.getOrNull(j)?.let { return it.trimStart('#') }
}
return null
}
fun checkAddress(address: String): Map<String, Any?> {
val encoded = URLEncoder.encode(address, Charsets.UTF_8).replace("+", "%20")
val request = HttpRequest.newBuilder(URI.create("https://api.sthan.io/v2/address-verification/usa/$encoded"))
.header("Authorization", "Bearer " + System.getenv("STHAN_API_KEY"))
.build()
val pending = http.sendAsync(request, HttpResponse.BodyHandlers.ofString())
val response = try {
pending.get(4, TimeUnit.SECONDS) // waits for the whole body: one 4-second budget
} catch (e: Exception) {
pending.cancel(true)
return VERIFY_LATER // past the budget or network error
}
if (response.statusCode() == 404) return mapOf("action" to "fix") // no match found
if (response.statusCode() != 200) return VERIFY_LATER // 429, 503, 500
val v = try {
mapper.readTree(response.body()).path("Result")
} catch (e: Exception) {
return VERIFY_LATER // unreadable body
}
if (!v.isObject) return VERIFY_LATER // no Result in the answer
val mc = v.path("matchCode")
val corrected = mc.fields().asSequence()
.filter { it.value.asText() == "Corrected" }.map { it.key }.toList()
val typed = typedUnit(address)
val unitDropped = typed != null && (mc.path("unit").asText() == "Unmatched" ||
!typed.equals(v.path("unitNumber").asText(""), ignoreCase = true))
val (action, review) = when (v.path("deliverableStatus").asText()) {
"Confirmed" -> if (unitDropped) "confirm" to true // keep what they typed
else (if (corrected.isEmpty()) "accept" else "confirm") to false
"ConfirmedPrimaryOnly" -> "askUnit" to false
"NotDeliverable" -> "fix" to true
else -> "accept" to true // Unknown or empty
}
return mapOf("action" to action, "review" to review, "corrected" to corrected, "unitDropped" to unitDropped,
"standardized" to v.path("fullAddress").textValue(), "recordType" to v.path("recordType").textValue())
}
fun main() {
embeddedServer(Netty, port = 8080) {
install(ContentNegotiation) { jackson() }
routing {
post("/api/check-address") {
val body = call.receive<CheckRequest>()
call.respond(withContext(Dispatchers.IO) { checkAddress(body.address) })
}
}
}.start(wait = true)
}
# Plug + Bandit + Req. Run as a script: elixir server.exs
# POST /api/check-address {"address": "..."}
Mix.install([{:bandit, "~> 1.5"}, {:plug, "~> 1.16"}, {:req, "~> 0.5"}, {:jason, "~> 1.4"}])
defmodule CheckAddress do
use Plug.Router
plug :match
plug Plug.Parsers, parsers: [:json], json_decoder: Jason
plug :dispatch
@unit_words ~w(apt apartment ste suite unit rm room bldg box pmb)
@verify_later %{action: "accept", verifyLater: true}
post "/api/check-address" do
reply = check(to_string(conn.body_params["address"] || ""))
conn |> put_resp_content_type("application/json") |> send_resp(200, Jason.encode!(reply))
end
defp check(address) do
url = "https://api.sthan.io/v2/address-verification/usa/" <> URI.encode(address, &URI.char_unreserved?/1)
# receive_timeout alone does not cover connecting, so the task gives the whole call one 4-second budget;
# retry: false because Req retries some failures by default
task =
Task.async(fn ->
try do
Req.get(url, auth: {:bearer, System.get_env("STHAN_API_KEY")}, retry: false)
rescue
e -> {:error, e} # unreadable body
end
end)
case Task.yield(task, 4000) || Task.shutdown(task, :brutal_kill) do
{:ok, {:ok, %Req.Response{status: 404}}} -> %{action: "fix"} # no match found
{:ok, {:ok, %Req.Response{status: 200, body: %{"Result" => v}}}} when is_map(v) -> decide(address, v)
_ -> @verify_later # past the budget, network error, 429, 503, 500, unreadable body or no Result
end
end
defp decide(address, v) do
mc = if is_map(v["matchCode"]), do: v["matchCode"], else: %{}
corrected = for {field, "Corrected"} <- mc, do: field
typed = typed_unit(address)
unit_dropped =
typed != nil and
(mc["unit"] == "Unmatched" or String.downcase(to_string(v["unitNumber"])) != String.downcase(typed))
{action, review} =
case v["deliverableStatus"] do
"Confirmed" when unit_dropped -> {"confirm", true} # keep what they typed
"Confirmed" -> {if(corrected == [], do: "accept", else: "confirm"), false}
"ConfirmedPrimaryOnly" -> {"askUnit", false}
"NotDeliverable" -> {"fix", true}
_ -> {"accept", true} # Unknown or empty
end
%{action: action, review: review, corrected: corrected, unitDropped: unit_dropped,
standardized: v["fullAddress"], recordType: v["recordType"]}
end
# The unit or box number the customer typed ("Apt 4B", "# 380", "#380"), or nil
defp typed_unit(address) do
t = address |> String.replace(",", " ") |> String.split()
t
|> Enum.with_index()
|> Enum.find_value(fn {w, i} ->
bare = String.trim_leading(w, "#")
cond do
String.starts_with?(w, "#") and bare != "" ->
bare
w == "#" or String.trim_trailing(String.downcase(w), ".") in @unit_words ->
j = if Enum.at(t, i + 1) == "#", do: i + 2, else: i + 1
if n = Enum.at(t, j), do: String.trim_leading(n, "#")
true ->
nil
end
end)
end
end
{:ok, _} = Bandit.start_link(plug: CheckAddress, port: 4000)
Process.sleep(:infinity)
The API key stays in an environment variable on the server. matchCode gives each field one of five values: Matched, Corrected, Inferred (missing from the input and filled in), Unmatched or NotApplicable. The form shows the "did you mean" prompt when a field the customer typed was Corrected, or when a unit or box number the customer typed did not come back. A Confirmed status describes the postal record that matched, so the handlers check the unit themselves: on October 16, 2026, "2443 Fillmore St # 380, San Francisco, CA 94115" came back Confirmed as "2443 Fillmore St, San Francisco, CA 94115-1814", with matchCode.unit Unmatched and no unit number. A unit word in the typed address (Apt, Ste, Unit, Box and a few more) or a # counts as a typed unit; if the answer's unitNumber is empty or different, or matchCode.unit is Unmatched, the handler returns confirm with review and unitDropped, so the form keeps what the customer typed. A ZIP+4 that the API added on its own comes back as Inferred and does not need a prompt. A 404 means the API found no match; it does not prove the place does not exist. Any other error, an answer that cannot be read or has no Result, and a call past the budget all accept the address and mark it to verify again before fulfilment.
Where sthan.io fits. sthan.io covers both steps: address autocomplete (100,000 free requests a month) for the typing step and verification (3,000 free a month, up to 100 a day, then from $12 for 10,000) for the submit step. Responses include the standardized address, ZIP+4, record type, a per-field match code and a deliverability status. sthan.io is not CASS-certified.
What the live API returned on October 16, 2026
We sent five addresses to the sthan.io verification API on October 16, 2026: four for an office building with suites in San Francisco, and the White House. The last column is what the handlers above send back to the form. We replayed every saved answer to all ten handlers on a test server: rows 3 and 5 give askUnit and fix when they arrive in time, and a test answer held back past 4 seconds gives accept, verifyLater, which is what those two live calls would get.
| Address sent | HTTP | DPV | deliverableStatus | Corrected | Time | Handler reply |
|---|---|---|---|---|---|---|
| 1355 Markett St Ste 900, San Francisco, CA 94103 | 200 | Y | Confirmed | street | 0.9 s | confirm |
| 1355 Market St, San Francisco, CA 94103 | 200 | none | Unknown | none | 1.6 s | accept, review |
| 1355 Market St Ste 9999, San Francisco, CA 94103 | 200 | S | ConfirmedPrimaryOnly | none | 42.5 s | accept, verifyLater (askUnit with no time limit) |
| 1600 Pennsylvania Ave NW, Washington, DC 20500 | 200 | Y | Confirmed | none | 1.6 s | accept |
| 99999 Market St, San Francisco, CA 94103 | 404 | none | Unknown | none | 54.9 s | accept, verifyLater (fix with no time limit) |
Three answers came back in under 2 seconds. The building without a suite came back with no DPV code and the status Unknown, so the handler accepts it and flags it for review (the "No status" row above). The suite that postal records do not confirm and the made-up house number with no match each took more than 40 seconds, so the 4-second limit let those orders through and put them in the verify-again queue. That queue is what catches them before the parcel ships. sthan.io reports both D and S as ConfirmedPrimaryOnly, so the handler asks for the unit in both cases; read dpvConfirmation if you want the different S message from the table above.
Frequently Asked Questions
Sources
Read October 16, 2026:
- Google, Address Validation API reference, method validateAddress (UspsData.dpvConfirmation and PossibleNextAction)
- Smarty, US Street API documentation (dpv_match_code, dpv_no_stat and record_type)
- sthan.io, US Address Verification product page
- sthan.io, API documentation and rate-limit table
- sthan.io, OpenAPI description (matchCode and deliverableStatus values)
- sthan.io, US pricing (Address Autocomplete and Address Verification plans)
- Requests documentation, Timeouts
- Ruby Net::HTTP documentation, max_retries=
- Req documentation, retry and timeout options
- sthan.io verification API: live calls on October 16, 2026 (five are in the table above, plus the 2443 Fillmore St answer)
Add verification to your checkout
3,000 free verifications and 100,000 free autocomplete requests a month, no credit card.