Java
The official Java client library for the VPNDetection API.
Getting Started
<dependency>
<groupId>io.vpndetection</groupId>
<artifactId>vpndetection</artifactId>
<version>6.10.0</version>
</dependency>implementation 'io.vpndetection:vpndetection:6.10.0'Requires Java 17 or newer. HTTP is the JDK's own java.net.http.HttpClient, so there is no third-party HTTP stack to reconcile with yours.
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.
import io.vpndetection.Result;
import io.vpndetection.VPNDetection;
VPNDetection client = VPNDetection.create();
Result result = client.lookup("45.83.91.1");
System.out.println(result.isVpn()); // trueBuild the client once and keep it. It owns a connection pool, a cache and a thread pool, and it is thread safe.
With 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:
VPNDetection client = VPNDetection.builder()
.apiKey(System.getenv("VPNDETECTION_API_KEY"))
.build();
Result result = client.lookup("45.83.91.1");
System.out.println(result.isVpn()); // true
System.out.println(result.location().get().getCountry()); // the ISO 3166 country code
System.out.println(result.asn().get().getName()); // who runs the announcing network
System.out.println(result.vpn().get().getProvider()); // "nordvpn", on a paid plan
System.out.println(result.isHosting()); // Optional[true], on a paid planYour own address
Result result = client.myIp();
System.out.println(result.ip()); // the address we saw this call come fromSame answer lookup 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
Entitlement acct = client.myEntitlement();
System.out.println(acct.getPlan().getKey()); // max
System.out.println(acct.getUsage().getRequests()); // 580
System.out.println(acct.getUsage().getWindowEnd()); // 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. getHardLimit() is null on an uncapped plan, which is not the same as zero.
myIpAsync() and myEntitlementAsync() return a CompletableFuture of the same thing.
Batch lookup
Look up as many addresses as you like in one call. 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 = client.lookupBatch(List.of("45.83.91.1", "8.8.8.8", "1.1.1.1"));
for (var entry : results.entrySet()) {
if (!entry.getValue().isSuccess()) {
System.err.println(entry.getKey() + ": " + entry.getValue().error().get().getMessage());
continue;
}
System.out.println(entry.getKey() + ": " + entry.getValue().orElseThrow().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 may take are configurable per call:
var results = client.lookupBatch(manyIps,
new BatchOptions().concurrency(4).retries(4).requestTimeout(Duration.ofSeconds(10)));There are CompletableFuture variants of both, for when you would rather not block:
client.lookupAsync("45.83.91.1").thenAccept(r -> System.out.println(r.isVpn()));
client.lookupBatchAsync(manyIps).thenAccept(results -> System.out.println(results.size()));Caching
Answers are cached by default, so repeat lookups of the same address are free:
VPNDetection client = VPNDetection.create();
Result result = client.lookup("45.83.91.1");
System.out.println(result.isVpn()); // true, API request
Result result2 = client.lookup("45.83.91.1");
System.out.println(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:
VPNDetection client = VPNDetection.builder()
.cacheSize(50_000)
.cacheTtl(Duration.ofHours(6))
.build();
VPNDetection clientNoCache = VPNDetection.builder().cacheEnabled(false).build();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:
Result result = client.lookup("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:
import static io.vpndetection.Bogon.isBogon;
isBogon("10.0.0.1"); // trueErrors
Failures throw a VPNDetectionException carrying a kind() and a retryable() flag. It is unchecked, so it travels through a stream or a future without being wrapped first:
import io.vpndetection.VPNDetectionException;
try {
client.lookup("1.1.1.1");
} catch (VPNDetectionException e) {
System.err.println(e.kind() + " " + e.retryable());
}kind() is one of BAD_REQUEST, UNAUTHORIZED, FORBIDDEN, RATE_LIMITED, QUOTA_EXCEEDED, SERVER_ERROR or NETWORK.
Note that RATE_LIMITED and QUOTA_EXCEEDED 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.
Timeouts
Each attempt at an API call may take 30 seconds, response body included, before it fails as a retryable NETWORK error. Change it for the client, or for a single call:
VPNDetection client = VPNDetection.builder().requestTimeout(Duration.ofSeconds(5)).build();
Result result = client.lookup("45.83.91.1", new LookupOptions().requestTimeout(Duration.ofSeconds(2)));The bound is per attempt, so a call that is retried can take longer in total. Database downloads are not bounded by it, since a large one can take minutes.
Database downloads
If your key carries the db.download scope, the licensed databases are available through client.database(). A license covers a database family, and the ids the transfers take come from its getVersions(). There are three ways to take one: to a file, as a time-limited link you transfer yourself, or as bytes.
var databases = client.database().list();
// Streamed straight to disk, so nothing bigger than a chunk is ever held in memory.
long written = client.database().download(
"vpn_ip_extended_v1", DatabaseFormat.MMDB, Path.of("vpn_ip_extended_v1.mmdb"));
String url = client.database().downloadUrl("vpn_ip_extended_v1", DatabaseFormat.MMDB);
byte[] raw = client.database().downloadBytes("cdn_ip_v1", DatabaseFormat.CSVGZ);downloadBytes 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 download 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:
VPNDetection client = VPNDetection.create();
DeviceAuthorization device = client.oauth().deviceAuthorization("your-client-id",
new DeviceAuthorizationOptions().scope("account.read apikeys.read apikeys.reveal"));
System.out.println("Open " + device.getVerificationUri() + " and enter " + device.getUserCode());
TokenResponse token = client.oauth().pollDeviceToken("your-client-id", device);
if (token.getApikey() == null) {
throw new IllegalStateException("no API key came back: none was picked, or it cannot be shown again");
}
VPNDetection keyed = VPNDetection.builder().apiKey(token.getApikey()).build();A denied sign-in throws OauthAccessDeniedException and a code that ran out OauthExpiredTokenException, and client IDs are issued on request from support@vpndetection.io. client.oauth().revoke("your-client-id", token.getRefreshToken()) signs the machine out again.
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:
VPNDetection client = VPNDetection.create();
String redirectUri = "http://127.0.0.1:8765/callback";
Pkce pkce = client.oauth().createPkce();
String 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.
TokenResponse token = client.oauth().exchangeAuthorizationCode("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.getApikey() 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 client_id it gets back as the client ID for authorizationUrl and exchangeAuthorizationCode:
VPNDetection client = VPNDetection.create();
ClientRegistration registration = client.oauth().register(List.of("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 rest are Optional, where empty means "not in your plan" rather than "checked, and no".
result.isHosting().orElse(false); // when you only want the flag
result.isHosting().isEmpty(); // not in your plan