How to Verify US Addresses in C# / ASP.NET Core
Confirm an address is real and deliverable before you ship to it. Free API, a typed HttpClient service, an ASP.NET Core submit endpoint, and a clear rule for every deliverability outcome.
A user types an address into your checkout form. It looks fine. It even passes your validation attribute. 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 C# using sthan.io's address API. We use a typed HttpClient and wire it into an ASP.NET Core endpoint, but the service is plain C# - it works the same in MVC controllers, minimal APIs, Razor Pages, Blazor Server, or a background worker.
Quick summary: Register a typedHttpClientthat sends your API key as aBearertoken, callGET /v2/address-verification/usa/{address}, and readResult.deliverableStatusplus the standardizedResult.fullAddress. AcceptConfirmed, warn onUnknown, blockNotDeliverable. The free tier gives you 100 requests/month - no credit card required.
What you'll need: .NET 6 or later (.NET 8 recommended) 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": []
}
Id, Result, StatusCode, IsError, Errors) are PascalCase, while the fields inside Result are camelCase (fullAddress, deliverableStatus, matchCode). You don't have to fight this: deserialize with PropertyNameCaseInsensitive = true and System.Text.Json maps both shapes onto plain PascalCase C# records - FullAddress binds to fullAddress automatically. Without that flag the inner fields would come back null.
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), ornullwhen there is no unit. Only a postal-confirmed unit appears here.zip4andcounty- appended for you even when the input only had a 5-digit ZIP.matchTierandconfidence- how the match was reached. The example above isApproximate/0.6because speculative mode standardized the input rather than matching it verbatim - stillConfirmedas 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
- Sign up at sthan.io and subscribe to the free Address Verification tier
- Open your dashboard and create an API key
- 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
Add your base URL and API key to appsettings.json:
{
"SthanApi": {
"BaseUrl": "https://api.sthan.io",
"ApiKey": "sthan_live_xxxxxxxxxxxxxxxx"
}
}
dotnet user-secrets set "SthanApi:ApiKey" "sthan_live_..."). For production use environment variables or a vault. The settings binder reads from all of these automatically.
Bind the section to a strongly-typed settings class:
public class SthanApiSettings
{
public string BaseUrl { get; set; } = "https://api.sthan.io";
public string ApiKey { get; set; } = "";
}
Build the verification service
First, model the response. One generic envelope record covers every endpoint, and a VerifiedAddress record holds the fields inside Result. Keep the C# properties PascalCase - case-insensitive deserialization will bind them to the camelCase JSON:
public record SthanApiResponse<T>
{
public string Id { get; init; } = "";
public T? Result { get; init; }
public int StatusCode { get; init; }
public bool IsError { get; init; }
public List<string> Errors { get; init; } = new();
}
public record VerifiedAddress
{
public string FullAddress { get; init; } = "";
public string AddressLine1 { get; init; } = "";
public string AddressLine2 { get; init; } = "";
public string? UnitType { get; init; }
public string? UnitNumber { get; init; }
public string City { get; init; } = "";
public string StateCode { get; init; } = "";
public string County { get; init; } = "";
public string ZipCode { get; init; } = "";
public string Zip4 { get; init; } = "";
public string DpvConfirmation { get; init; } = "";
public string DeliverableStatus { get; init; } = "Unknown";
public double Confidence { get; init; }
public string MatchTier { get; init; } = "";
public string MatchMode { get; init; } = "";
}
Now the service. It takes an HttpClient from the framework, URL-encodes the address, calls the endpoint, and returns the typed VerifiedAddress:
using System.Text.Json;
public class AddressVerificationService
{
private readonly HttpClient _httpClient;
private static readonly JsonSerializerOptions JsonOptions = new()
{
PropertyNameCaseInsensitive = true // binds camelCase JSON to PascalCase records
};
public AddressVerificationService(HttpClient httpClient)
{
_httpClient = httpClient;
}
public async Task<VerifiedAddress> VerifyAsync(
string address, string mode = "speculative", CancellationToken ct = default)
{
var encoded = Uri.EscapeDataString(address.Trim());
var response = await _httpClient.GetAsync(
$"/v2/address-verification/usa/{encoded}?match={mode}", ct);
response.EnsureSuccessStatusCode();
await using var stream = await response.Content.ReadAsStreamAsync(ct);
var result = await JsonSerializer
.DeserializeAsync<SthanApiResponse<VerifiedAddress>>(stream, JsonOptions, ct);
if (result?.IsError == true)
throw new InvalidOperationException(
$"Verification error: {string.Join(", ", result.Errors)}");
return result?.Result
?? throw new InvalidOperationException("Empty verification result.");
}
}
Register it as a typed HttpClient in Program.cs. This is where the API key becomes a default Authorization header, so every call is already authenticated:
using System.Net.Http.Headers;
using Microsoft.Extensions.Options;
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddControllers();
builder.Services.Configure<SthanApiSettings>(
builder.Configuration.GetSection("SthanApi"));
builder.Services.AddHttpClient<AddressVerificationService>((sp, client) =>
{
var settings = sp.GetRequiredService<IOptions<SthanApiSettings>>().Value;
client.BaseAddress = new Uri(settings.BaseUrl);
client.DefaultRequestHeaders.Authorization =
new AuthenticationHeaderValue("Bearer", settings.ApiKey);
client.Timeout = TimeSpan.FromSeconds(10);
});
var app = builder.Build();
app.MapControllers();
app.Run();
IHttpClientFactory? Never new HttpClient() per request - each instance holds its own connection pool and can lead to socket exhaustion under load. AddHttpClient<T> registers a typed client, manages the handler lifetime, and injects a ready-to-use HttpClient into your service.
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:
| Mode | Behavior | Use 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:
deliverableStatus | Meaning | What 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. |
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 the per-component matchCode breakdown (add a MatchCode record to VerifiedAddress if you want it typed). If matchCode.street is Corrected, you know the verifier fixed a typo and should show the user the standardized address to confirm. If Confidence is below your threshold or 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 ASP.NET Core controller that verifies the submitted address and returns a decision the front end can act on:
using Microsoft.AspNetCore.Mvc;
[ApiController]
[Route("api/checkout")]
public class CheckoutController : ControllerBase
{
private readonly AddressVerificationService _service;
public CheckoutController(AddressVerificationService service)
{
_service = service;
}
public record AddressRequest(string Address);
[HttpPost("address")]
public async Task<IActionResult> SubmitAddress(
[FromBody] AddressRequest body, CancellationToken ct)
{
if (string.IsNullOrWhiteSpace(body.Address))
return BadRequest(new { ok = false, reason = "empty" });
VerifiedAddress result;
try
{
result = await _service.VerifyAsync(body.Address, ct: ct);
}
catch (Exception)
{
// Don't punish the user for our hiccup: accept, but mark unverified.
return Ok(new { ok = true, verified = false, standardized = body.Address });
}
if (result.DeliverableStatus == "NotDeliverable")
return UnprocessableEntity(new
{
ok = false,
reason = "not_deliverable",
message = "We couldn't confirm this address is deliverable. Please double-check it."
});
string? warning = result.DeliverableStatus switch
{
"ConfirmedPrimaryOnly" => "We confirmed the building but not the unit - check the apartment/suite.",
"Unknown" => "We couldn't fully confirm this address. Please make sure it's correct.",
_ => null
};
return Ok(new
{
ok = true,
verified = result.DeliverableStatus == "Confirmed",
standardized = result.FullAddress,
unit = new { type = result.UnitType, number = result.UnitNumber },
zip4 = result.Zip4,
warning
});
}
}
Prefer minimal APIs? The same logic drops straight into Program.cs:
app.MapPost("/api/checkout/address", async (
AddressRequest body,
AddressVerificationService service,
CancellationToken ct) =>
{
var result = await service.VerifyAsync(body.Address, ct: ct);
return result.DeliverableStatus == "NotDeliverable"
? Results.UnprocessableEntity(new { ok = false, reason = "not_deliverable" })
: Results.Ok(new { ok = true, standardized = result.FullAddress });
});
Either way, 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:
public record AuthTokenResult
{
[JsonPropertyName("access_token")]
public string AccessToken { get; init; } = "";
[JsonPropertyName("expiration")]
public DateTime Expiration { get; init; }
}
public async Task<string> GetTokenAsync(CancellationToken ct = default)
{
var request = new HttpRequestMessage(HttpMethod.Get, "/Auth/Token");
request.Headers.Add("profileName", _settings.ProfileName);
request.Headers.Add("profilePassword", _settings.ProfilePassword);
var response = await _httpClient.SendAsync(request, ct);
response.EnsureSuccessStatusCode();
var json = await response.Content.ReadAsStringAsync(ct);
var result = JsonSerializer
.Deserialize<SthanApiResponse<AuthTokenResult>>(json, JsonOptions);
return result!.Result!.AccessToken;
}
You would then set the Authorization header per request with the cached token instead of attaching a static key in Program.cs. Everything else - the endpoint, the envelope, the parsing - stays the same. Cache the token and refresh it shortly before expiry.
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.
using System.Net;
public async Task<VerifiedAddress?> SafeVerifyAsync(
string address, CancellationToken ct = default)
{
try
{
return await VerifyAsync(address, ct: ct);
}
catch (HttpRequestException ex)
when (ex.StatusCode == HttpStatusCode.TooManyRequests)
{
// 429: degrade gracefully - accept unverified rather than blocking checkout
return null;
}
}
For production resilience you can layer retry and circuit-breaker policies onto the typed client with Microsoft.Extensions.Http.Polly, but the try/catch above is enough to ship.
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 C# autocomplete walkthrough is here: Address Autocomplete in C#.
If you need to break an address into components, parse freeform input, or get latitude/longitude, the same envelope and the same typed-HttpClient pattern apply. The full C# walkthrough covering verification, parsing, and geocoding together is in Integrate Address APIs in C#.
Frequently Asked Questions
Register a typed HttpClient that sends your sthan.io API key as a Bearer token and call GET /v2/address-verification/usa/{address}. Read the standardized address from Result.fullAddress and the deliverability from Result.deliverableStatus. The envelope keys are PascalCase and the fields inside Result are camelCase, so deserialize with PropertyNameCaseInsensitive = true. The full working client is 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 ASP.NET Core 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. 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.