Overview
The IPFly .NET SDK wraps the IPFly API in a single dependency-free file. It's fully async (Task-based, with CancellationToken support throughout), uses only System.Net.Http and System.Text.Json from the BCL, and handles caching, retries, rate limiting, and in-flight de-duplication so your integration code can stay focused on what to do with the response.
TTL + LRU caching
In-memory by default — already shared across requests in a typical long-lived .NET process — or a JSON file for persistence across restarts.
Automatic retries
Exponential backoff with jitter on network errors and 5xx responses. 4xx fails fast, no wasted retries.
In-flight de-duplication
Two callers awaiting the same IP at once share one HTTP request via ConcurrentDictionary<string, Lazy<Task<T>>>.
Bounded batch lookups
BatchLookupAsync uses a SemaphoreSlim; a bad IP never fails the whole batch.
DI-friendly
Accepts an externally managed HttpClient — use IHttpClientFactory in ASP.NET Core.
Zero dependencies
One file, .NET 6+. No NuGet package required. MIT licensed.
Where This SDK Belongs
This client is meant to run server-side — an ASP.NET Core API, a worker service, a console app, an Azure Function. It is not meant to be shipped inside a client-side app (Blazor WebAssembly, MAUI, WPF) where your API token could be extracted from the compiled app.
If you need geolocation results in a client-side app, put this SDK behind your own backend endpoint and call that endpoint from the client (see the minimal API proxy example below), or use the IPFly JavaScript SDK with a domain-restricted token for prototyping.
Installation
No NuGet package is published — copy IPFlyClient.cs into your project.
# Drop IPFlyClient.cs directly into your source tree, e.g.: cp IPFlyClient.cs Services/IPFlyClient.cs
curl -O https://ipfly.world/libs/dotnet-sdk/IPFlyClient.cs # or wget https://ipfly.world/libs/dotnet-sdk/IPFlyClient.cs
using IPFly;
var client = new IPFlyClient("YOUR_TOKEN");Requirements
.NET 6.0 or later (uses nullable reference types, Environment.ProcessId, pattern-matching or combinators). No NuGet packages required.
Quick Start
Create a client once, then call LookupAsync wherever you need geolocation data. Lookups return a JsonElement — the standard BCL type for dynamic JSON, so there's no extra model type to learn.
using System;
using IPFly;
var client = new IPFlyClient("YOUR_TOKEN", new IPFlyClientOptions
{
Include = "security", // optional: request the security/ASN field set on every call
});
// Look up a specific IP
var data = await client.LookupAsync("8.8.8.8");
Console.WriteLine($"{data.GetProperty("city").GetString()}, " +
$"{data.GetProperty("country_name").GetString()}, " +
$"VPN: {data.GetProperty("security").GetProperty("is_vpn").GetBoolean()}");
// Look up the caller's own IP (omit the ip argument)
var me = await client.LookupSelfAsync();
Console.WriteLine($"You appear to be in {me.GetProperty("city").GetString()}");
// Handle errors explicitly
try
{
await client.LookupAsync("not-an-ip");
}
catch (IPFlyException err)
{
Console.WriteLine($"{err.Code}: {err.Message}");
}
client.Dispose(); // only needed if you didn't supply your own HttpClientClient Options
Passed as the second constructor argument: new IPFlyClient(token, new IPFlyClientOptions { ... }).
| Property | Type | Default | Description |
|---|---|---|---|
BaseUrl | string | ipfly.world/api | Override to point at your own backend proxy. |
Include | string? | null | Default include value sent with every request (e.g. "security"). Overridable per call. |
Timeout | TimeSpan | 8s | Per-request timeout. |
Retries | int | 2 | Retry attempts for network errors and 5xx. 4xx is never retried. |
RetryDelay | TimeSpan | 300ms | Base delay for exponential backoff; jitter is added automatically. |
RateLimit | double | 0 | Max requests/second this client will issue. 0 = unlimited. |
Concurrency | int | 6 | Default max parallel requests for BatchLookupAsync. |
Cache | CacheOptions | see below | Response cache settings — see Cache Options. |
HttpClient | HttpClient? | null | Supply your own (e.g. from IHttpClientFactory) — recommended in ASP.NET Core. If omitted, the SDK creates and owns one. |
Debug | bool | false | Write internal lifecycle events to Console.Error (tokens are redacted). |
OnRequest | Action<string?, string>? | null | Fired right before a request is sent: (ip, url). |
OnResponse | Action<string?, JsonElement, bool>? | null | Fired after a successful lookup: (ip, data, fromCache). |
OnError | Action<string?, IPFlyException>? | null | Fired when a lookup ultimately fails: (ip, error). |
OnCacheHit | Action<string?, JsonElement>? | null | Fired when a cached response is served instead of a network call: (ip, data). |
Cache Options
| Property | Type | Default | Description |
|---|---|---|---|
Enabled | bool | true | Set false to disable caching entirely. |
Ttl | TimeSpan | 5 minutes | How long a cached response stays valid. |
MaxSize | int | 500 | Max cached entries before least-recently-used entries are evicted. |
Storage | CacheStorage | Memory | Memory or File — see below. |
Path | string | temp dir | File path used when Storage is File. |
.NET-specific note: unlike PHP (where each web request is usually a fresh process), a typical ASP.NET Core app runs as one long-lived process — so the default Memory storage already behaves like a shared cache across requests. For multi-instance deployments behind a load balancer, see Extending the Cache.
LookupAsync() & LookupSelfAsync()
Task<JsonElement> LookupAsync(string? ip = null, LookupOptions? options = null, CancellationToken ct = default) looks up a single IP. Pass null (or omit) to geolocate the caller — or use the LookupSelfAsync(...) shorthand.
await client.LookupAsync("1.1.1.1", new LookupOptions
{
Include = "security", // overrides the client-level Include for this call only
SkipCache = true, // force a fresh network request, bypassing the cache
Timeout = TimeSpan.FromSeconds(3), // override the client's default timeout for this call
});Returns a Task<JsonElement> (see Response Shape). Throws IPFlyException on failure (see Error Handling).
BatchLookupAsync()
Task<IReadOnlyList<BatchResult>> BatchLookupAsync(IEnumerable<string> ips, int? concurrency = null, LookupOptions? options = null, CancellationToken ct = default) looks up many IPs with bounded concurrency via SemaphoreSlim. This call never throws for an individual failure — it returns results in the same order as ips.
var results = await client.BatchLookupAsync(new[] { "8.8.8.8", "1.1.1.1", "not-an-ip" });
foreach (var r in results)
{
if (r.Ok)
Console.WriteLine($"{r.Ip} -> {r.Data!.Value.GetProperty("city").GetString()}");
else
Console.WriteLine($"{r.Ip} failed: {r.Error!.Code}");
}BatchResult is a small class: Ip (string), Ok (bool), Data (JsonElement?), Error (IPFlyException?).
Cache Controls
| Method | Returns | Description |
|---|---|---|
ClearCache() | void | Empties the response cache immediately. |
CacheStats() | tuple | Returns (Enabled, Size, TtlSeconds, MaxSize) — useful for logging or a health-check endpoint. |
Dispose() | void | Disposes the internally created HttpClient — a no-op if you supplied your own. |
IsValidIp()
A static helper that validates an IPv4 or IPv6 string (via IPAddress.TryParse) without making a request.
IPFlyClient.IsValidIp("8.8.8.8"); // true
IPFlyClient.IsValidIp("2001:4860:4860::8888"); // true
IPFlyClient.IsValidIp("not-an-ip"); // falseResponse Shape
Successful lookups return a JsonElement with the same structure the IPFly API returns, unchanged — see the full field reference in the API docs. Shape depends on your plan and the Include value requested.
var data = await client.LookupAsync("8.8.8.8");
string? city = data.GetProperty("city").GetString();
string? country = data.GetProperty("country_name").GetString();
bool isVpn = data.GetProperty("security").GetProperty("is_vpn").GetBoolean();
string? asnName = data.GetProperty("asn").GetProperty("name").GetString();JsonElement.GetProperty throws if the property is missing. Use TryGetProperty for optional fields (e.g. state_prov, zipcode), which are plan- and location-dependent.
Error Handling
All failures throw IPFlyException, an Exception subclass with Message, Status, Code, and Cause.
| Code | Meaning |
|---|---|
| MISSING_TOKEN | No token was supplied when creating the client. |
| INVALID_IP | The IP string failed local validation before any request was sent. |
| TIMEOUT | The request exceeded the configured timeout. |
| NETWORK_ERROR | The request failed before a response was received (DNS, connection refused, etc). |
| HTTP_ERROR | The API responded with a non-2xx status — check Status (401 = bad token, 403 = plan/permission issue, 404 = private/bogon IP, 429/5xx = retry-worthy). |
| BAD_RESPONSE | The server responded but the body wasn't valid JSON. |
try
{
await client.LookupAsync("8.8.8.8");
}
catch (IPFlyException err)
{
if (err.Code == "TIMEOUT")
{
// retry later, log, fall back to a default
}
else if (err.Status == 401)
{
// token is invalid — surface a config error, don't retry
}
logger.LogWarning(err, "IPFly lookup failed: {Message}", err.Message);
}Performance Notes
Use IHttpClientFactory
Pass your own HttpClient via IPFlyClientOptions.HttpClient in ASP.NET Core rather than letting the SDK create its own — standard .NET guidance to avoid socket exhaustion.
De-duplication
If two requests call LookupAsync("1.2.3.4") concurrently, only one HTTP request goes out; both await the same underlying Task.
Rate limiting
A courtesy limiter on the client side that smooths bursts so you don't blow through your plan's requests/second in a tight loop.
Retries
Only apply to transient failures (network errors, timeouts, 5xx). A 401/403/404 fails fast since retrying won't fix a bad token or a private IP.
Extending the Cache
For a multi-instance deployment behind a load balancer, implement ICacheStore over whatever distributed cache you already use (Redis, IDistributedCache, etc.), serializing CacheEntry with System.Text.Json the same way FileCacheStore does internally.
public sealed class RedisCacheStore : IPFly.ICacheStore
{
public IPFly.CacheEntry? Get(string key) => throw new NotImplementedException();
public void Set(string key, IPFly.CacheEntry entry) => throw new NotImplementedException();
public void Delete(string key) => throw new NotImplementedException();
public IReadOnlyList<string> Keys() => throw new NotImplementedException();
public int Size() => throw new NotImplementedException();
public void Clear() => throw new NotImplementedException();
}CacheManager is internal, so wire a custom store in by adapting the ICacheStore interface in your own composition, or fork the file if you want the client's constructor to accept an ICacheStore directly — it's a small change.
Examples
Two common integration patterns: an ASP.NET Core minimal API proxy, and bulk-enriching a list of IPs.
using IPFly;
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddHttpClient();
builder.Services.AddSingleton(sp => new IPFlyClient(
builder.Configuration["IPFly:Token"]!,
new IPFlyClientOptions
{
HttpClient = sp.GetRequiredService<IHttpClientFactory>().CreateClient("ipfly"),
Cache = new CacheOptions { Ttl = TimeSpan.FromMinutes(10) },
}));
var app = builder.Build();
app.MapGet("/api/geo", async (HttpContext ctx, IPFlyClient client) =>
{
var ip = ctx.Request.Query["ip"].FirstOrDefault() ?? ctx.Connection.RemoteIpAddress?.ToString();
try
{
var data = await client.LookupAsync(ip, new LookupOptions { Include = "security" });
return Results.Json(data);
}
catch (IPFlyException err)
{
return Results.Json(new { error = err.Code, message = err.Message }, statusCode: err.Status ?? 502);
}
});
app.Run();using IPFly;
using var client = new IPFlyClient(Environment.GetEnvironmentVariable("IPFLY_TOKEN")!);
var ips = await File.ReadAllLinesAsync("visitors.txt");
var results = await client.BatchLookupAsync(ips, concurrency: 8);
foreach (var r in results)
{
Console.WriteLine(r.Ok
? $"{r.Ip} -> {r.Data!.Value.GetProperty("country_name").GetString()}"
: $"{r.Ip} -> error: {r.Error!.Code}");
}In the minimal API example, your frontend calls /api/geo on your own server — the IPFly token stays in this process and is never sent to the client.
Requirements
.NET 6.0 or later (uses nullable reference types, Environment.ProcessId, and pattern-matching or combinators). No NuGet packages required — just System.Net.Http and System.Text.Json from the BCL.
License
MIT — use it, fork it, ship it.