Tutorial

How to Add Address Autocomplete in C# / ASP.NET Core

Add US address autocomplete to your ASP.NET Core app in minutes. Free API, a typed HttpClient service, a backend proxy, and a debounced front-end input included.

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

Your checkout form collects addresses as freeform text. Users mistype street names, skip apartment numbers, and guess at ZIP codes. That bad data flows into your database, and each failed delivery costs $15-20 to re-ship.

Address autocomplete fixes the problem at the source. Users type a few characters, pick the correct address from a dropdown, and you store a postal-formatted string with the unit number and ZIP+4 already attached - ready to print on a shipping label.

This tutorial shows you how to add US address autocomplete to a C# / ASP.NET Core app using sthan.io's address API. The pattern works the same whether you build with MVC controllers, Razor Pages, minimal APIs, or Blazor Server.

Quick summary: Register a typed HttpClient that sends your API key as a Bearer token, call GET /AutoComplete/USA/Address/{text}, and read the suggestions from the Result field of the response envelope. Expose that call through your own backend endpoint so the key never reaches the browser. The free tier gives you 100,000 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 tier gives you 100,000 requests/month - enough for roughly 20,000 address lookups, assuming about 5 keystrokes per lookup. Paid plans start at $7/month if you outgrow it.

Try it first

Type any partial US address - no signup required:

Try it live

That's what you're building. Type "123 main st" - lowercase, abbreviated, no city or state - and the API returns complete, postal-formatted addresses with apartment numbers, ZIP+4 codes, and proper casing.

What the API returns

The API wraps every response in a standard envelope. The address suggestions live in the Result field, which for autocomplete is a plain array of strings:

{
  "Id": "3f2504e0-4f89-11d3-9a0c-0305e82c3301",
  "Result": [
    "123 Main St APT 1, Andover, MA 01810-3816",
    "123 Main St APT 1, Delhi, NY 13753-1257",
    "123 Main St STE 1, Caldwell, ID 83605-5476",
    "123 Main St STE 1, Corinth, NY 12822-1010",
    "123 Main St STE 1, Delhi, NY 13753-1258"
  ],
  "ClientSessionId": null,
  "StatusCode": 200,
  "IsError": false,
  "Errors": []
}

Each suggestion includes the full street, the unit designation (APT, STE, UNIT), city, state code, and ZIP+4. The API handles abbreviations (St, Ave, Blvd) and directional prefixes (N, S, E, W) on the way in, and returns clean, standardized output. In C#, you deserialize this into a small generic envelope and read the Result property - which is what the rest of this guide does.

Get your API key

  1. Sign up at sthan.io and subscribe to the free Address Autocomplete 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 instead of a long-lived key, 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"
  }
}
Security tip: Don't commit a real key. For local development use .NET User Secrets (dotnet user-secrets set "SthanApi:ApiKey" "sthan_live_..."). For production use environment variables or a vault service. The settings binder reads from all of these sources 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 autocomplete service

First, define the response envelope as a generic record. Every sthan.io endpoint returns the same shape, so one type covers them all:

using System.Text.Json.Serialization;

public record SthanApiResponse<T>
{
    [JsonPropertyName("Id")]
    public string Id { get; init; } = "";

    [JsonPropertyName("Result")]
    public T? Result { get; init; }

    [JsonPropertyName("StatusCode")]
    public int StatusCode { get; init; }

    [JsonPropertyName("IsError")]
    public bool IsError { get; init; }

    [JsonPropertyName("Errors")]
    public List<string> Errors { get; init; } = new();
}

Now the service itself. It takes a HttpClient from the framework, URL-encodes the query, calls the endpoint, and returns the Result array:

using System.Text.Json;

public class AddressAutocompleteService
{
    private readonly HttpClient _httpClient;

    private static readonly JsonSerializerOptions JsonOptions = new()
    {
        PropertyNameCaseInsensitive = true
    };

    public AddressAutocompleteService(HttpClient httpClient)
    {
        _httpClient = httpClient;
    }

    public async Task<List<string>> AutocompleteAsync(
        string query, CancellationToken ct = default)
    {
        // Skip very short queries to avoid wasting calls
        if (string.IsNullOrWhiteSpace(query) || query.Trim().Length < 3)
            return new List<string>();

        var encoded = Uri.EscapeDataString(query.Trim());
        var response = await _httpClient.GetAsync(
            $"/AutoComplete/USA/Address/{encoded}", ct);
        response.EnsureSuccessStatusCode();

        await using var stream = await response.Content.ReadAsStreamAsync(ct);
        var result = await JsonSerializer
            .DeserializeAsync<SthanApiResponse<List<string>>>(
                stream, JsonOptions, ct);

        if (result?.IsError == true)
            throw new InvalidOperationException(
                $"Autocomplete error: {string.Join(", ", result.Errors)}");

        return result?.Result ?? new List<string>();
    }
}

Register the service as a typed HttpClient in Program.cs. This is where the API key is attached as a default Authorization header, so every call the service makes is already authenticated:

using System.Net.Http.Headers;
using Microsoft.Extensions.Options;

var builder = WebApplication.CreateBuilder(args);

builder.Services.AddControllers();

// Bind configuration
builder.Services.Configure<SthanApiSettings>(
    builder.Configuration.GetSection("SthanApi"));

// Register the typed HttpClient with IHttpClientFactory
builder.Services.AddHttpClient<AddressAutocompleteService>((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();
Why IHttpClientFactory? In a web app, 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 underlying handler lifetime, and injects a ready-to-use HttpClient into your service constructor.

Expose a backend proxy endpoint

The browser should call your server, and your server calls sthan.io. There are two reasons for this. First, the API does not enable CORS for browser requests, so a direct call from the page would be blocked. Second, and more important, putting your API key in client-side JavaScript would expose it to anyone who opens the network tab. The proxy keeps the key on the server.

Controller version

using Microsoft.AspNetCore.Mvc;

[ApiController]
[Route("api/address")]
public class AddressController : ControllerBase
{
    private readonly AddressAutocompleteService _service;

    public AddressController(AddressAutocompleteService service)
    {
        _service = service;
    }

    [HttpGet("autocomplete")]
    public async Task<IActionResult> Autocomplete(
        [FromQuery] string query, CancellationToken ct)
    {
        var suggestions = await _service.AutocompleteAsync(query, ct);
        return Ok(suggestions);
    }
}

Minimal API version

If you prefer minimal APIs, drop this into Program.cs instead of the controller:

app.MapGet("/api/address/autocomplete", async (
    string query,
    AddressAutocompleteService service,
    CancellationToken ct) =>
{
    var suggestions = await service.AutocompleteAsync(query, ct);
    return Results.Ok(suggestions);
});

Either way, your front end now has a clean URL to call: /api/address/autocomplete?query=123 main st returns a JSON array of addresses, and the API key never leaves the server.

Wire up the front-end input

The last piece is a debounced input that calls your proxy. Debouncing matters: without it, "123 main st" fires eleven requests, one per keystroke. With a 250ms debounce, it fires one request after the user pauses. Add this markup to a Razor view:

<input type="text" id="address" autocomplete="off"
       placeholder="Start typing your address..." />
<ul id="suggestions"></ul>
const input = document.getElementById("address");
const list = document.getElementById("suggestions");
let timer;

input.addEventListener("input", () => {
    clearTimeout(timer);
    const query = input.value.trim();

    if (query.length < 3) {
        list.innerHTML = "";
        return;
    }

    // Wait 250ms after the last keystroke before calling the server
    timer = setTimeout(async () => {
        const res = await fetch(
            `/api/address/autocomplete?query=${encodeURIComponent(query)}`);
        const items = await res.json();
        list.innerHTML = items
            .map(a => `<li>${a}</li>`)
            .join("");
    }, 250);
});

The browser only ever talks to /api/address/autocomplete on your own domain. No key, no CORS, no third-party script. From here you can style the list, add keyboard navigation, and fill the form fields when a user clicks a suggestion.

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. Cache it and refresh on expiry:

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)
{
    // Reuse the cached token until it is about to expire
    if (_token is not null && DateTime.UtcNow < _expiration.AddSeconds(-30))
        return _token;

    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);

    _token = result!.Result!.AccessToken;
    _expiration = result.Result.Expiration;
    return _token;
}

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.

Handle errors

Two status codes are worth handling explicitly so a hiccup never crashes your form:

  • 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 return what the user has typed so far rather than throwing.
public async Task<List<string>> SafeAutocompleteAsync(
    string query, CancellationToken ct = default)
{
    try
    {
        return await AutocompleteAsync(query, ct);
    }
    catch (HttpRequestException ex)
        when (ex.StatusCode == HttpStatusCode.TooManyRequests)
    {
        // Rate limited — degrade gracefully, don't crash the form
        return new List<string>();
    }
}

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: confirm the address is deliverable

Autocomplete gets the user to a clean, well-formed address fast. It does not, on its own, confirm that mail or a package will actually arrive there - a suggestion can be correctly formatted yet point at a unit that no longer accepts delivery.

The natural next step is to run the chosen address through the Address Verification API at the moment the user submits the form. It returns a Delivery Point Validation (DPV) result and a deliverable status, standardizes the address to standard postal format, and appends ZIP+4 and county. The call is the same pattern you already built - one GET, the same envelope:

var encoded = Uri.EscapeDataString(selectedAddress);
var response = await _httpClient.GetAsync(
    $"/v2/address-verification/usa/speculative/{encoded}", ct);
// Read result.Result.DeliverableStatus and result.Result.DpvConfirmation

Address Verification has its own free tier of 100 requests/month, with paid plans from $12/month. Pairing autocomplete (volume, real-time, as the user types) with verification (one confirming call at submit) keeps your costs low and your delivery data clean. For the full C# verification, parsing, and geocoding walkthrough, see Integrate Address APIs in C#.

Frequently Asked Questions

Register a typed HttpClient that sends your sthan.io API key as a Bearer token, call GET /AutoComplete/USA/Address/{text}, and read the suggestions from the Result field of the response envelope. Expose that call through your own backend endpoint so the front end never sees the key. The full working example is in the sections above.

The free tier includes 100,000 requests per month with no credit card required - roughly 20,000 address lookups assuming about 5 keystrokes per lookup. Paid plans start at $7/month. There is no trial period; the free tier is permanent. See pricing for higher-volume plans.

Call it from your ASP.NET Core backend, 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. Add a small proxy endpoint: the browser calls your server, your server calls sthan.io with the key. The controller and minimal API examples above show both styles.

The simplest method is an API key sent as a Bearer token: Authorization: Bearer sthan_{environment}_{key}. Create the key in your dashboard and store it in appsettings.json or .NET User Secrets. A 2-step JWT flow is also available - call GET /Auth/Token with profileName and profilePassword headers to get a token valid for up to 60 minutes.

Every response is wrapped in a standard envelope with Id, Result, ClientSessionId, StatusCode, IsError, and Errors fields. For autocomplete, Result holds an array of postal-formatted address strings - each with the unit designation, city, state code, and ZIP+4. Deserialize into SthanApiResponse<List<string>> and read the Result property.

Typically under 100ms, which is suitable for real-time typeahead. Pair the calls with client-side debouncing of 200-300ms so you send one request per pause rather than one per keystroke.

Confirm every address before you ship

You have autocomplete wired up. Add one verification call at submit to confirm deliverability with DPV - 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