Tutorial

How to Verify US Addresses in Go

Confirm an address is real and deliverable before you ship to it. Free API, a small net/http client, an http submit handler, and a clear rule for every deliverability outcome.

sthan.io Team
sthan.io Team
June 27, 2026 · 11 min read

A user types an address into your checkout form. It looks fine. It even passes your regex. But the apartment number is wrong, the street is misspelled, or the ZIP belongs to the next town over. You only find out when the package bounces back - and each failed delivery costs $15-20 to re-ship, plus a support ticket and a frustrated customer.

Address verification catches the problem at submit time. You send the raw address to an API, and it tells you whether the postal service can actually deliver there, hands back a clean standardized version with the ZIP+4 and county filled in, and splits out the confirmed unit number. You store good data instead of a guess.

This tutorial shows you how to verify US addresses in Go using sthan.io's address API. We use the standard library net/http and encoding/json and wire it into an http handler, but the client is plain Go - it drops into any router (chi, gin, Echo) or a CLI batch tool unchanged.

Quick summary: Send your API key as a Bearer token, call GET /v2/address-verification/usa/{address}, and read Result.deliverableStatus plus the standardized Result.fullAddress. Accept Confirmed, warn on Unknown, block NotDeliverable. The free tier gives you 100 requests/month - no credit card required.

What you'll need: Go 1.18+ and a free sthan.io account. No credit card, no approval queue. The free verification tier is 100 requests/month; paid plans start at $12/month if you need more. (Verification is a one-call-per-address confirmation, so the volumes are far lower than a real-time autocomplete - 100/month covers a small store's checkout traffic.)

Try it first

Verify any US address right here - no signup required:

Try it live

That's what you're building. Type a messy address - wrong casing, a misspelled street, a missing ZIP - and the API returns the standardized form along with a deliverability verdict you can act on.

What the API returns

Every response is wrapped in a standard envelope. For verification, the Result field is a single object describing the matched address. This is a real response for 1600 Pennsylvania Ave NW:

{
  "Id": "118a23c7-d836-46af-a17f-022a3754e36c",
  "Result": {
    "inputAddress": "1600 Pennsylvania Ave NW Washington DC 20500",
    "fullAddress": "1600 Pennsylvania Ave NW, Washington, DC 20500-0005",
    "addressLine1": "1600 Pennsylvania Ave NW",
    "addressLine2": "Washington, DC 20500-0005",
    "unitType": null,
    "unitNumber": null,
    "city": "Washington",
    "stateCode": "DC",
    "county": "District Of Columbia",
    "zipCode": "20500",
    "zip4": "0005",
    "dpvConfirmation": "Y",
    "deliverableStatus": "Confirmed",
    "confidence": 0.6,
    "matchTier": "Approximate",
    "matchMode": "Speculative",
    "matchCode": {
      "houseNumber": "Matched",
      "street": "Matched",
      "unit": "NotApplicable",
      "city": "Matched",
      "state": "Matched",
      "zipCode": "Matched",
      "zip4": "Inferred"
    },
    "lastVerifiedDate": "2026-06-16T22:40:51",
    "footnotes": ["recovered: standardized via correction, not an exact match"]
  },
  "ClientSessionId": null,
  "StatusCode": 200,
  "IsError": false,
  "Errors": []
}
Casing - map it with struct tags. The envelope keys (Id, Result, StatusCode, IsError, Errors) are PascalCase, while the fields inside Result are camelCase (fullAddress, deliverableStatus, matchCode). With encoding/json you bind both exactly using struct tags - json:"Result" on the envelope, json:"fullAddress" on the inner struct. Your Go field names stay exported (capitalized); the tag is what matches the wire format, so the mixed casing is handled for you.

The fields you'll use most often:

  • fullAddress - the postal-standardized address. Store this, not the user's raw input.
  • deliverableStatus - a plain-English summary of deliverability (covered below). Branch your checkout on this.
  • unitType / unitNumber - the confirmed apartment or suite as discrete fields (e.g. APT, 4B), or null when there is no unit. Only a postal-confirmed unit appears here.
  • zip4 and county - appended for you even when the input only had a 5-digit ZIP.
  • matchTier and confidence - how the match was reached. The example above is Approximate / 0.6 because speculative mode standardized the input rather than matching it verbatim - still Confirmed as deliverable.
  • matchCode - a per-component breakdown (Matched / Corrected / Inferred / Unmatched / NotApplicable) so you can see exactly which fields were trusted versus fixed. (The response also carries postal routing fields - carrierRoute, deliveryPoint, elot - omitted above for brevity.)

Get your API key

  1. Sign up at sthan.io and subscribe to the free Address Verification tier
  2. Open your dashboard and create an API key
  3. Copy the key - it looks like sthan_live_xxxxxxxxxxxxxxxx

You get the key immediately, with no approval queue. An API key is the simplest way to authenticate: you send it as a Bearer token on every request and there is no separate login step. (If you prefer a short-lived token, there is a JWT flow covered later.)

Configure the project

Nothing to install - net/http and encoding/json are in the standard library. Keep your key out of source control by reading it from the environment:

# net/http and encoding/json ship with Go - nothing to go get.
# Set the key in your shell (or load a .env with a library like godotenv):
export STHAN_API_KEY="sthan_live_xxxxxxxxxxxxxxxx"
Security tip: Never hard-code the key in a source file. Read it from the environment with os.Getenv in production and load a .env locally with a library like godotenv - and add .env to .gitignore.
package main

import (
    "net/http"
    "os"
    "time"
)

var (
    baseURL = "https://api.sthan.io"
    apiKey  = os.Getenv("STHAN_API_KEY") // empty if unset - check at startup
    client  = &http.Client{Timeout: 10 * time.Second}
)

Build the verification client

Define structs that mirror the response - the json struct tags carry the exact casing - then call the endpoint and decode the envelope. A single package-level http.Client pools connections across calls:

// Envelope is the standard response wrapper. Its keys are PascalCase.
type Envelope struct {
    ID      string          `json:"Id"`
    Result  VerifiedAddress `json:"Result"`
    IsError bool            `json:"IsError"`
    Errors  []string        `json:"Errors"`
}

// VerifiedAddress fields are camelCase on the wire - the json tags map them.
type VerifiedAddress struct {
    InputAddress      string    `json:"inputAddress"`
    FullAddress       string    `json:"fullAddress"`
    AddressLine1      string    `json:"addressLine1"`
    AddressLine2      string    `json:"addressLine2"`
    UnitType          string    `json:"unitType"`
    UnitNumber        string    `json:"unitNumber"`
    City              string    `json:"city"`
    StateCode         string    `json:"stateCode"`
    County            string    `json:"county"`
    ZipCode           string    `json:"zipCode"`
    Zip4              string    `json:"zip4"`
    DpvConfirmation   string    `json:"dpvConfirmation"`
    DeliverableStatus string    `json:"deliverableStatus"`
    Confidence        float64   `json:"confidence"`
    MatchTier         string    `json:"matchTier"`
    MatchMode         string    `json:"matchMode"`
    MatchCode         MatchCode `json:"matchCode"`
    Footnotes         []string  `json:"footnotes"`
}

type MatchCode struct {
    HouseNumber string `json:"houseNumber"`
    Street      string `json:"street"`
    Unit        string `json:"unit"`
    City        string `json:"city"`
    State       string `json:"state"`
    ZipCode     string `json:"zipCode"`
    Zip4        string `json:"zip4"`
}

// APIError carries a non-200 status so callers can react (e.g. retry on 429).
type APIError struct{ StatusCode int }

func (e *APIError) Error() string {
    return fmt.Sprintf("sthan.io returned HTTP %d", e.StatusCode)
}

func VerifyAddress(address, mode string) (*VerifiedAddress, error) {
    if mode == "" {
        mode = "speculative"
    }
    endpoint := fmt.Sprintf("%s/v2/address-verification/usa/%s?match=%s",
        baseURL, url.PathEscape(address), mode)

    req, err := http.NewRequest(http.MethodGet, endpoint, nil)
    if err != nil {
        return nil, err
    }
    req.Header.Set("Authorization", "Bearer "+apiKey)

    resp, err := client.Do(req)
    if err != nil {
        return nil, err
    }
    defer resp.Body.Close()

    if resp.StatusCode != http.StatusOK {
        return nil, &APIError{resp.StatusCode}
    }

    var env Envelope
    if err := json.NewDecoder(resp.Body).Decode(&env); err != nil {
        return nil, err
    }
    if env.IsError {
        return nil, fmt.Errorf("verification error: %v", env.Errors)
    }
    return &env.Result, nil
}

That's the whole integration. One call:

result, err := VerifyAddress("1600 pennsylvania ave nw washington dc 20500", "speculative")
if err != nil {
    log.Fatal(err)
}

fmt.Println(result.FullAddress)        // 1600 Pennsylvania Ave NW, Washington, DC 20500-0005
fmt.Println(result.DeliverableStatus)  // Confirmed
fmt.Println(result.Zip4)               // 0005
Reuse one http.Client. Each client keeps its own connection pool, so create one at package level and share it across verifications rather than allocating a client per call. The Timeout guards against a slow upstream hanging your request goroutine.

Choose a match mode

The match parameter controls how much typo tolerance the verifier applies. The same call supports four modes, from strictest to loosest:

ModeBehaviorUse when
strict Only confirmed-deliverable matches. Returns NotDeliverable on a miss rather than guessing. You refuse to ship to anything less than a confirmed address.
balanced Exact plus typo-corrected matches. Returns the best candidate, marking deliverability Unknown when it can't be confirmed. Typical checkout - tolerant of small mistakes, still expects a real address.
fuzzy Wider recovery; the deliverability gate relaxes to "not explicitly undeliverable." Higher recall, more risk of a loose match. Cleaning messy legacy data where some match beats none.
speculative Loosest recovery, with extra tolerance for heavily misspelled street names. Any best-effort match is flagged matchTier = "Speculative". Maximum recovery / agent tooling. This is the default.

If you omit match, the endpoint defaults to speculative for the widest recovery. Whichever mode you pick, the location-defining parts of the address - house number, ordinal, directional, state, and the street's core name - are never substituted. A looser mode only widens tolerance for misspellings of the same street, never a jump to a different one.

Interpret the result

deliverableStatus is the one field most integrations branch on. It collapses the raw postal DPV code into four plain values:

deliverableStatusMeaningWhat to do
Confirmed The building and any unit were confirmed. Safe to ship. Accept. Store fullAddress.
ConfirmedPrimaryOnly The building was confirmed but the apartment/suite was missing or invalid. Accept with a nudge to re-check the unit.
Unknown Deliverability could not be confirmed. The address may exist but isn't vouched for. Soft warning - let the user proceed, flag for review.
NotDeliverable The address was explicitly rejected. Block. Ask the user to correct it.
The one mistake to avoid: Unknown is not the same as NotDeliverable. A blank DPV code means "we couldn't confirm," not "this is a bad address." If you hard-block on Unknown, you'll reject perfectly real addresses (new construction, recently added units). Block only on NotDeliverable; treat Unknown and ConfirmedPrimaryOnly as warnings.

For richer logic, pair the status with matchCode. If result.MatchCode.Street is "Corrected", you know the verifier fixed a typo and should show the user the standardized address to confirm. If result.Confidence is below your threshold or result.MatchTier is "Speculative", treat the result as a suggestion rather than a fact.

Verify at form submit

Verification belongs on the server, at the moment the user submits - not in the browser. The API does not enable CORS for browser requests, and your API key must never reach client-side JavaScript. Here is an http handler that verifies the submitted address and returns a decision the front end can act on:

func submitAddress(w http.ResponseWriter, r *http.Request) {
    var body struct {
        Address string `json:"address"`
    }
    if err := json.NewDecoder(r.Body).Decode(&body); err != nil || body.Address == "" {
        writeJSON(w, http.StatusBadRequest, map[string]any{"ok": false, "reason": "empty"})
        return
    }

    result, err := VerifyAddress(body.Address, "speculative")
    if err != nil {
        // Don't punish the user for our hiccup: accept, but mark unverified.
        writeJSON(w, http.StatusOK, map[string]any{
            "ok": true, "verified": false, "standardized": body.Address,
        })
        return
    }

    if result.DeliverableStatus == "NotDeliverable" {
        writeJSON(w, http.StatusUnprocessableEntity, map[string]any{
            "ok":      false,
            "reason":  "not_deliverable",
            "message": "We couldn't confirm this address is deliverable. Please double-check it.",
        })
        return
    }

    var warning string
    switch result.DeliverableStatus {
    case "ConfirmedPrimaryOnly":
        warning = "We confirmed the building but not the unit - check the apartment/suite."
    case "Unknown":
        warning = "We couldn't fully confirm this address. Please make sure it's correct."
    }

    writeJSON(w, http.StatusOK, map[string]any{
        "ok":           true,
        "verified":     result.DeliverableStatus == "Confirmed",
        "standardized": result.FullAddress,
        "unit":         map[string]string{"type": result.UnitType, "number": result.UnitNumber},
        "zip4":         result.Zip4,
        "warning":      warning,
    })
}

func writeJSON(w http.ResponseWriter, status int, v any) {
    w.Header().Set("Content-Type", "application/json")
    w.WriteHeader(status)
    json.NewEncoder(w).Encode(v)
}

The front end posts the raw address to this handler and gets back a clean, standardized string plus a yes/warn/no decision. Store standardized - the postal-formatted fullAddress - rather than the user's original text, and you've turned a guess into shippable data.

Alternative: JWT authentication

An API key is the simplest option and is all most apps need. If your security policy prefers short-lived credentials, the platform also supports a 2-step JWT flow. You call GET /Auth/Token once with your profileName and profilePassword headers, receive a token valid for up to 60 minutes, then send that token as the Bearer value on subsequent calls:

type tokenEnvelope struct {
    Result struct {
        AccessToken string `json:"access_token"`
    } `json:"Result"`
}

func GetToken() (string, error) {
    req, _ := http.NewRequest(http.MethodGet, baseURL+"/Auth/Token", nil)
    req.Header.Set("profileName", os.Getenv("STHAN_PROFILE_NAME"))
    req.Header.Set("profilePassword", os.Getenv("STHAN_PROFILE_PASSWORD"))

    resp, err := client.Do(req)
    if err != nil {
        return "", err
    }
    defer resp.Body.Close()

    var env tokenEnvelope
    if err := json.NewDecoder(resp.Body).Decode(&env); err != nil {
        return "", err
    }
    return env.Result.AccessToken, nil
}

// Then set the header per request with the cached token:
//   req.Header.Set("Authorization", "Bearer "+token)

Everything else - the endpoint, the envelope, the parsing - stays exactly the same. Cache the token and refresh it shortly before the 60-minute expiry rather than fetching one per request.

Handle errors

Two status codes are worth handling explicitly so a hiccup never blocks a sale:

  • 401 - The key or token was rejected. Check the value and, on the JWT flow, refresh and retry once.
  • 429 - Rate limit reached. Back off and retry, or accept the address unverified rather than failing the checkout.

Because VerifyAddress returns a typed *APIError on a non-200, you can branch on the status with errors.As and retry only on 429:

func VerifyWithRetry(address, mode string, retries int) (*VerifiedAddress, error) {
    var apiErr *APIError
    for attempt := 0; ; attempt++ {
        result, err := VerifyAddress(address, mode)
        if err == nil {
            return result, nil
        }
        // Retry only on 429 (rate limit); fail fast on everything else (incl. 401).
        if errors.As(err, &apiErr) && apiErr.StatusCode == http.StatusTooManyRequests && attempt < retries {
            time.Sleep(time.Duration(1<<attempt) * time.Second) // 1s, then 2s
            continue
        }
        return nil, err
    }
}

The exponential back-off (1s, then 2s) is enough for transient limits. For heavier batch jobs, add a circuit breaker so one bad minute doesn't stall the whole queue.

What's next: fix addresses before they're submitted

Verification confirms an address at submit. You can stop bad addresses even earlier by helping users enter the right one in the first place: Address Autocomplete suggests complete, postal-formatted addresses as the user types, so most submissions are already clean before verification runs. Autocomplete has its own free tier of 100,000 requests/month - pairing the two (autocomplete as they type, one verification call at submit) keeps both your data and your costs in good shape.

The same Envelope and VerifiedAddress structs plus the net/http pattern apply to the parser and geocoding endpoints - swap the path and the inner struct. Prefer a different stack? The same flow in Python is covered in Verify US Addresses in Python.

Frequently Asked Questions

Send your sthan.io API key as a Bearer token and call GET /v2/address-verification/usa/{address} with net/http. Decode the JSON into structs whose tags map the PascalCase envelope (json:"Result") and the camelCase inner fields (json:"fullAddress", json:"deliverableStatus"). The full working client and http handler are in the sections above.

The free tier includes 100 verification requests per month with no credit card required. Paid plans start at $12/month. There is no trial period; the free tier is permanent. See pricing for higher-volume plans.

Confirmed means the building and any unit were confirmed - safe to ship. ConfirmedPrimaryOnly means the building was confirmed but the apartment or suite was not. NotDeliverable means the address was explicitly rejected - block it. Unknown means deliverability couldn't be confirmed; it is not the same as NotDeliverable, so treat it as a soft warning rather than a hard block.

Use strict if you refuse to ship to anything less than a confirmed deliverable address, balanced for typo-tolerant checkout matching, and speculative (the default) for the widest recovery on heavily mistyped input. Best-effort matches are clearly labelled matchTier = "Speculative", and location-changing components stay sacred in every mode.

Call it from your Go backend at form submit, not the browser. The API does not enable CORS for browser requests, and putting your API key in client-side JavaScript would expose it to anyone viewing the page source. Verify server-side, then return a clean decision to the page.

Yes. The response includes unitType and unitNumber as discrete fields (for example APT and 4B), or null when there is no unit, in addition to the inline addressLine1. Decode them with json:"unitType" and json:"unitNumber". Only the postal-confirmed unit is surfaced - an unconfirmed unit typed by the caller is never echoed back as verified.

Catch bad addresses before they cost you a re-ship

Add one verification call at submit to confirm deliverability, standardize the address, and append ZIP+4 - free tier of 100 requests/month, paid from $12/month, no credit card to start.

sthan.io Team
Written by sthan.io Team

The sthan.io engineering team builds and maintains address verification, parsing, geocoding, and autocomplete APIs. With deep expertise in postal addressing standards and spatial data systems, we help businesses improve address data quality and reduce failed deliveries. Questions? Reach us at [email protected].

Learn more about us