Tutorial

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.

James Whitaker
October 16, 2026 · 9 min read · Last reviewed

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

StepWhat runsWhy
While typingAddress autocomplete, called after a short pause in typingFewer typos and missing ZIP codes before the address is ever submitted
On submitAddress verification, called from your serverChecks the final address, including the unit number, and returns the deliverability status
Before fulfilmentVerification 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.

ResultForm actionMessage to show
DPV Y, nothing changedAccept and save the standardized addressNone
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 answerKeep 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 reviewNone
Timeout or API errorAccept as typed, verify again before fulfilmentNone

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 formsCheckout and shipping
GoalA clean, standardized record: billing, tax, fraud checks, no duplicate customersA parcel that arrives on the first attempt
Unit numberOptional on a general account form; required if your own rules need a full addressRequired for D and S results
Record typeDecide whether you accept a PO box as a billing addressRecord type P means a PO box; check your carrier's PO box rules before you buy a label
What to storeThe standardized address and its ZIP+4, so the same customer matches next timeThe 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 sentHTTPDPVdeliverableStatusCorrectedTimeHandler reply
1355 Markett St Ste 900, San Francisco, CA 94103200YConfirmedstreet0.9 sconfirm
1355 Market St, San Francisco, CA 94103200noneUnknownnone1.6 saccept, review
1355 Market St Ste 9999, San Francisco, CA 94103200SConfirmedPrimaryOnlynone42.5 saccept, verifyLater (askUnit with no time limit)
1600 Pennsylvania Ave NW, Washington, DC 20500200YConfirmednone1.6 saccept
99999 Market St, San Francisco, CA 94103404noneUnknownnone54.9 saccept, 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

Ask the customer to fix it first, and let them continue if they confirm. Postal records are not always current, so a hard block can lose real orders. Flag confirmed-undeliverable orders for review before shipping.
From your server. A verification API key in browser code can be copied and used by anyone. The browser sends the address to your backend, your backend calls the API and returns only the fields the form needs.
Set a short timeout, accept the address as typed when it expires, and verify it again in the background before fulfilment. Never let a verification outage stop a customer from paying.
A DPV code of D means the building is confirmed but the unit number is missing; S means the unit number given was not confirmed. In both cases show the standardized building address and ask for the apartment or suite number instead of rejecting the address.
They do different jobs. Autocomplete reduces typing and spelling errors while the customer types; verification checks the final address, including the unit number, when the form is submitted. You can use both; verification alone is enough if your form can show a correction prompt.

Sources

Read October 16, 2026:

Add verification to your checkout

3,000 free verifications and 100,000 free autocomplete requests a month, no credit card.

James Whitaker
Written by James Whitaker

James Whitaker writes about PHP and Ruby integrations and address checks for ecommerce checkout flows.

More from James Whitaker