C#.NETASP.NET CoreGeolocationTutorial

C# .NET IP Geolocation: ASP.NET Core, HttpClient & Minimal API Examples

ApogeoAPI6 min read

Why use a REST API for IP geolocation in .NET?

The two common .NET approaches are embedding a MaxMind GeoLite2 database file or calling a REST API. A REST API wins for most SaaS apps: no 70 MB binary to ship, no weekly update job, and no GeoIP2.DatabaseReader to manage in memory. You call an endpoint and get JSON back.

Response model (typed C#)

// GeoResponse.cs
using System.Text.Json.Serialization;

public class GeoResponse
{
    [JsonPropertyName("ip")]
    public string Ip { get; set; } = string.Empty;

    [JsonPropertyName("country_code")]
    public string CountryCode { get; set; } = "US";

    [JsonPropertyName("country_name")]
    public string CountryName { get; set; } = string.Empty;

    [JsonPropertyName("city")]
    public string? City { get; set; }

    [JsonPropertyName("latitude")]
    public double? Latitude { get; set; }

    [JsonPropertyName("longitude")]
    public double? Longitude { get; set; }

    [JsonPropertyName("currency_code")]
    public string? CurrencyCode { get; set; }

    [JsonPropertyName("timezone")]
    public string? Timezone { get; set; }
}

Service class with HttpClient and DI

// GeoService.cs
using System.Net.Http.Headers;
using System.Net.Http.Json;
using Microsoft.Extensions.Caching.Memory;

public interface IGeoService
{
    Task<GeoResponse> GetAsync(string ipAddress, CancellationToken ct = default);
    Task<GeoResponse> GetCallerAsync(HttpContext httpContext, CancellationToken ct = default);
}

public class GeoService : IGeoService
{
    private readonly HttpClient _http;
    private readonly IMemoryCache _cache;
    private static readonly TimeSpan CacheTtl = TimeSpan.FromHours(1);

    public GeoService(HttpClient http, IMemoryCache cache)
    {
        _http = http;
        _cache = cache;
    }

    public async Task<GeoResponse> GetAsync(string ip, CancellationToken ct = default)
    {
        // Cache by /24 subnet to reduce quota usage
        var subnet = string.Join('.', ip.Split('.')[..3]);
        if (_cache.TryGetValue(subnet, out GeoResponse? cached)) return cached!;

        var response = await _http.GetFromJsonAsync<GeoResponse>(
            $"v1/ip/{Uri.EscapeDataString(ip)}", ct)
            ?? new GeoResponse { Ip = ip };

        _cache.Set(subnet, response, CacheTtl);
        return response;
    }

    public async Task<GeoResponse> GetCallerAsync(HttpContext ctx, CancellationToken ct = default)
    {
        var ip = ctx.Connection.RemoteIpAddress?.ToString() ?? "0.0.0.0";
        // Respect reverse-proxy header
        if (ctx.Request.Headers.TryGetValue("X-Forwarded-For", out var xff))
            ip = xff.ToString().Split(',')[0].Trim();
        return await GetAsync(ip, ct);
    }
}

Registering with DI in Program.cs

// Program.cs
builder.Services.AddMemoryCache();

builder.Services.AddHttpClient<IGeoService, GeoService>(client =>
{
    client.BaseAddress = new Uri("https://api.apogeoapi.com/");
    client.DefaultRequestHeaders.Authorization =
        new AuthenticationHeaderValue("Bearer", builder.Configuration["ApogeoAPI:ApiKey"]);
    client.Timeout = TimeSpan.FromSeconds(3);
})
.SetHandlerLifetime(TimeSpan.FromMinutes(5)); // avoid socket exhaustion

Using in a controller

// PricingController.cs
[ApiController]
[Route("api/[controller]")]
public class PricingController : ControllerBase
{
    private readonly IGeoService _geo;
    public PricingController(IGeoService geo) => _geo = geo;

    [HttpGet]
    public async Task<IActionResult> GetPricing(CancellationToken ct)
    {
        var geo = await _geo.GetCallerAsync(HttpContext, ct);

        var price = geo.CountryCode switch
        {
            "US" => new { Currency = "USD", Amount = 29, Symbol = "$" },
            "GB" => new { Currency = "GBP", Amount = 25, Symbol = "£" },
            "DE" or "FR" or "ES" or "IT" => new { Currency = "EUR", Amount = 27, Symbol = "€" },
            "AU" => new { Currency = "AUD", Amount = 45, Symbol = "A$" },
            _ => new { Currency = "USD", Amount = 29, Symbol = "$" },
        };

        return Ok(new { geo.CountryCode, geo.CountryName, price.Currency, price.Amount, price.Symbol });
    }
}

Minimal API version (.NET 6+)

app.MapGet("/api/geo", async (IGeoService geo, HttpContext ctx, CancellationToken ct) =>
{
    var result = await geo.GetCallerAsync(ctx, ct);
    return Results.Ok(new
    {
        country = result.CountryCode,
        city    = result.City,
        lat     = result.Latitude,
        lng     = result.Longitude,
        tz      = result.Timezone,
    });
})
.WithName("GetCallerGeo")
.WithOpenApi();

Middleware — block or redirect by country

// GeoBlockMiddleware.cs
public class GeoBlockMiddleware
{
    private static readonly HashSet<string> BlockedCountries = new() { "CN", "RU", "KP", "IR" };
    private readonly RequestDelegate _next;
    private readonly IGeoService _geo;

    public GeoBlockMiddleware(RequestDelegate next, IGeoService geo)
    {
        _next = next;
        _geo = geo;
    }

    public async Task InvokeAsync(HttpContext ctx)
    {
        var geo = await _geo.GetCallerAsync(ctx);
        if (BlockedCountries.Contains(geo.CountryCode))
        {
            ctx.Response.StatusCode = 451; // Unavailable For Legal Reasons
            await ctx.Response.WriteAsync("Service not available in your region.");
            return;
        }
        await _next(ctx);
    }
}

// Register in Program.cs:
// app.UseMiddleware<GeoBlockMiddleware>();

appsettings.json

{
  "ApogeoAPI": {
    "ApiKey": "your-api-key-here"
  }
}

In production, set ApogeoAPI__ApiKey as an environment variable (double underscore = nested key in .NET config).

Resources

Try ApogeoAPI free

1,000 requests/month forever. 14-day full-access trial. No credit card.

Get your free API key