.NET
The official .NET client library for the VPNDetection API.
Getting Started
dotnet add package VPNDetectionTargets .NET 8 and newer.
Usage
No API key needed to start. Without a key, a lookup answers ip and is_vpn, up to 1000 a day per source address.
using VPNDetection;
using var client = new VpnDetectionClient();
var result = await client.LookupAsync("45.83.91.1");
Console.WriteLine(result.IsVpn); // TrueWith an API key
A free key adds Tor and relay detection, where the address is and the AS that announces it. Every paid plan answers every field, the same on each, and the plans differ only in how many lookups they include. Create a key in the console, then pass it in:
using var client = new VpnDetectionClient(new VpnDetectionClientOptions
{
ApiKey = Environment.GetEnvironmentVariable("VPNDETECTION_API_KEY"),
});
var result = await client.LookupAsync("45.83.91.1");
Console.WriteLine(result.IsVpn); // True
Console.WriteLine(result.Location?.Country); // the ISO 3166 country code
Console.WriteLine(result.Asn?.Name); // who runs the announcing network
Console.WriteLine(result.Vpn?.Provider); // nordvpn, on a paid plan
Console.WriteLine(result.IsHosting); // True, on a paid planYour own address
var result = await client.MyIpAsync();
Console.WriteLine(result.Ip); // the address we saw this call come fromSame answer LookupAsync would give for that address, and the same cost against your allowance. It is deliberately not cached: which address you are is the whole question, and a machine that moves between networks would otherwise be told where it used to be.
Your plan and usage
var acct = await client.MyEntitlementAsync();
Console.WriteLine(acct.Plan.Key); // max
Console.WriteLine(acct.Usage.Requests); // 580
Console.WriteLine(acct.Usage.WindowEnd); // when the allowance resetsUsage counts against the anniversary of your subscription, not the calendar month and not the billing period, and it is the same number a lookup is gated on. HardLimit is null on an uncapped plan, which is not the same as zero.
Batch lookup
Look up many addresses at once. Bogons and cached answers are handled locally, and everything else goes to the batch endpoint in chunks of up to 1000 addresses, in parallel:
var results = await client.LookupBatchAsync(new[] { "45.83.91.1", "8.8.8.8", "1.1.1.1" });
foreach (var (ip, answer) in results)
{
if (!answer.IsSuccess)
{
Console.Error.WriteLine($"{ip}: {answer.Error!.Message}");
continue;
}
Console.WriteLine($"{ip}: {answer.Result!.IsVpn}");
}Results are keyed by address, in the order you first listed each one, so duplicates in your list collapse into a single entry and one address failing never loses the rest: it carries its error as its value, with the status the API would have given that address on its own.
How many chunks are in flight at once, how many times a failed chunk is retried, and how long each attempt at a chunk may take, are configurable per call:
var results = await client.LookupBatchAsync(manyIps, new BatchOptions
{
Concurrency = 4,
Retries = 4,
RequestTimeout = TimeSpan.FromSeconds(10),
});Caching
Answers are cached by default, so repeat lookups of the same address are free:
using var client = new VpnDetectionClient();
var result = await client.LookupAsync("45.83.91.1");
Console.WriteLine(result.IsVpn); // True, API request
var result2 = await client.LookupAsync("45.83.91.1");
Console.WriteLine(result2.IsVpn); // True, no API request, result was cachedYou can change the default cache variables (max size, TTL, etc) on initialization, or even disable it:
using var client = new VpnDetectionClient(new VpnDetectionClientOptions
{
CacheSize = 50_000,
CacheTtl = TimeSpan.FromHours(6),
});
using var noCache = new VpnDetectionClient(new VpnDetectionClientOptions { CacheEnabled = false });The cache belongs to the client instance, never to the process, because two keys can be on different plans and so entitled to different fields.
Private and reserved addresses
Private, loopback, link-local, documentation and multicast addresses (and their IPv6 equivalents, including the 6to4 and Teredo ranges) can never be VPN or proxy infrastructure. The library answers them locally, so they cost no request and no quota:
var result = await client.LookupAsync("192.168.1.1");
result.IsBogon; // True, this answer was computed rather than served
result.IsVpn; // FalseThe check is available on the client, which is handy when your inputs are addresses anyway:
client.IsBogon("10.0.0.1"); // True
client.IsBogon("8.8.8.8"); // FalseIt is also available on its own, if you want it without a client:
VPNDetection.Bogon.IsBogon("10.0.0.1"); // TrueErrors
Failures throw a VpnDetectionException carrying a Kind and a Retryable flag:
try
{
await client.LookupAsync("1.1.1.1");
}
catch (VpnDetectionException e)
{
Console.Error.WriteLine($"{e.Kind} {e.Retryable} {e.StatusCode}");
}Kind is one of BadRequest, Unauthorized, Forbidden, RateLimited, QuotaExceeded, ServerError or Network.
Note that RateLimited and QuotaExceeded both arrive as HTTP 429 and are not the same thing. A rate limit is when the API faces extreme traffic bursts and so retrying later works; but a spent quota needs your allowance raised or the window to roll over. The library retries rate limits for you, but not if your quota is exceeded.
Each attempt is abandoned after 30 seconds by default (RequestTimeout on the options), which fails as a retryable Network error. One call can set its own, longer or shorter:
var result = await client.LookupAsync("45.83.91.1", new LookupOptions { RequestTimeout = TimeSpan.FromSeconds(5) });Database downloads
If your key carries the db.download scope, the licensed databases are available through client.Database. A license covers a database FAMILY, so the ids below come from its versions. DownloadAsync fetches one to a path, streaming it straight to disk so that nothing bigger than a chunk is ever held in memory; or take the bytes, or the time-limited link to run the transfer yourself:
var databases = await client.Database.ListAsync();
var id = databases[0].Versions[0].Id; // "vpn_ip_extended_v1"
var written = await client.Database.DownloadAsync(id, DatabaseFormat.Mmdb, $"{id}.mmdb");
var bytes = await client.Database.DownloadBytesAsync("cdn_ip_v1", DatabaseFormat.Csvgz);
var url = await client.Database.DownloadUrlAsync(id, DatabaseFormat.Mmdb);DownloadBytesAsync holds the whole file in memory, and the catalog runs from cdn_ip_v1 at 10 KB to resproxy_ip_90d_v1 at 1.79 GB, so use DownloadAsync for anything you have not measured.
Sign in with OAuth (device flow)
A program running on the person's own machine can let them sign in with a browser and pick one of their API keys, instead of asking them to paste it:
using var client = new VpnDetectionClient();
var device = await client.Oauth.DeviceAuthorizationAsync(
"your-client-id", new DeviceAuthorizationOptions { Scope = "account.read apikeys.read apikeys.reveal" });
Console.WriteLine($"Open {device.VerificationUri} and enter {device.UserCode}");
var token = await client.Oauth.PollDeviceTokenAsync("your-client-id", device);
if (token.Apikey is null)
{
throw new InvalidOperationException("no API key came back: none was picked, or it can't be shown again");
}
using var keyed = new VpnDetectionClient(new VpnDetectionClientOptions { ApiKey = token.Apikey });A denied sign-in throws OauthAccessDeniedException and a code that ran out OauthExpiredTokenException. Client IDs are issued on request from support@vpndetection.io, and client.Oauth.RevokeAsync("your-client-id", token.RefreshToken) signs the machine out again.
Dependency injection
Register one shared client. The answer cache lives in the client, so one built per request scope starts empty every time and pays for a lookup the last scope already made. In a web app, bound each lookup tightly too, so an unreachable API costs a request seconds rather than minutes:
services.AddSingleton(new VpnDetectionClient(new VpnDetectionClientOptions
{
ApiKey = builder.Configuration["VpnDetection:ApiKey"],
RequestTimeout = TimeSpan.FromSeconds(2),
Retries = 0,
}));The client also takes an HttpClient, so it can register as a typed client and pick up your handler pipeline, pooling and resilience policies:
services.AddSingleton(new VpnDetectionClientOptions { ApiKey = builder.Configuration["VpnDetection:ApiKey"] });
services.AddHttpClient<VpnDetectionClient>(http => http.Timeout = TimeSpan.FromSeconds(2))
.ConfigurePrimaryHttpMessageHandler(() => new HttpClientHandler { AllowAutoRedirect = false });A typed client is transient, so each scope gets a new VpnDetectionClient with a cache of its own. AllowAutoRedirect = false matters: the database download endpoint answers 302 with the link this library hands back, and .NET's default handler would follow it and fetch the whole database instead. A client that follows redirects is refused with a clear error rather than quietly downloading gigabytes.
A borrowed HttpClient keeps its own Timeout, 100 seconds unless you set one, so RequestTimeout on the options does not apply to it; a per-call RequestTimeout still does.
Sign in with OAuth (authorization code)
An app that can take a browser redirect signs the person in there instead, with a PKCE pair made for that one sign-in:
using var client = new VpnDetectionClient();
var redirectUri = "http://127.0.0.1:8765/callback";
var pkce = client.Oauth.CreatePkce();
var url = client.Oauth.AuthorizationUrl("your-client-id", redirectUri, pkce.Challenge,
new AuthorizationUrlOptions { Scope = "apikeys.use", State = "your-state" });
// Open url in the browser. Its redirect to redirectUri carries code and state.
var token = await client.Oauth.ExchangeAuthorizationCodeAsync("your-client-id", code, pkce.Verifier, redirectUri);Check that state came back as you sent it before you exchange code, which works once. The client ID can also be the https URL of a client metadata document your app serves, and such an app is never handed a key, so token.Apikey stays null.
Register a client
An app with no client ID, and no client metadata document to use as one, registers itself, then signs in with the ClientId it gets back as the client ID for AuthorizationUrl and ExchangeAuthorizationCodeAsync:
using var client = new VpnDetectionClient();
var registration = await client.Oauth.RegisterAsync(new[] { "http://127.0.0.1:8765/callback" },
new RegisterOptions { ClientName = "Your App" });Each redirect URI must be https on a public host name, or http on a loopback address, which then matches any port. Nothing is kept until a person approves the client, so an app with no client ID saved can simply register again.
Absent is not false
Without a key only Ip and IsVpn come back, and a free key adds Tor, relay, Location and Asn. The flags are bool?, where null means "not in your plan" rather than "checked, and no".
result.IsHosting ?? false // when you only want the flag
result.IsHosting is null // not in your plan