VPNDetectionVPNDetection

PHP

The official PHP client library for the VPNDetection API.

See on GitHub

Getting Started

composer require vpndetection/vpndetection

Requires PHP 8.2 or newer. Everything is typed, and the tier-gated fields are nullable so an absent answer never reads as a false one.

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.

use VPNDetection\Client;

$client = new Client();

$result = $client->lookup('45.83.91.1');
echo $result->isVpn ? 'yes' : 'no';   // yes

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:

use VPNDetection\Client;
use VPNDetection\Options;

$client = new Client(new Options(apiKey: getenv('VPNDETECTION_API_KEY')));

$result = $client->lookup('45.83.91.1');
$result->isVpn;               // true
$result->location->country;   // the ISO 3166 country code
$result->asn->name;           // who runs the announcing network
$result->vpn->provider;       // 'nordvpn', on a paid plan
$result->isHosting;           // true, on a paid plan

Your own address

$result = $client->myIp();
echo $result->ip;   // the address we saw this call come from

Same 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

$acct = $client->myEntitlement();
echo $acct->plan->key;          // max
echo $acct->usage->requests;    // 580
echo $acct->usage->windowEnd;   // when the allowance resets

Usage 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:

use VPNDetection\VPNDetectionException;

$results = $client->lookupBatch(['45.83.91.1', '8.8.8.8', '1.1.1.1']);

foreach ($results as $ip => $result) {
    if ($result instanceof VPNDetectionException) {
        echo "{$ip}: {$result->getMessage()}\n";
        continue;
    }
    echo "{$ip}: ", $result->isVpn ? 'vpn' : 'clean', "\n";
}

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.

There's no limit on how many addresses you pass. 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:

$results = $client->lookupBatch($manyIps, ['concurrency' => 4, 'retries' => 4, 'timeout' => 10]);

Caching

Answers are cached by default, so repeat lookups of the same address are free:

$client = new Client();

$result = $client->lookup('45.83.91.1');
$result->isVpn;    // true, API request

$result2 = $client->lookup('45.83.91.1');
$result2->isVpn;   // true, no API request, result was cached

You can change the default cache variables (max size, TTL in seconds, etc) on initialization, or even disable it:

$client = new Client(new Options(cacheMaxSize: 50_000, cacheTtlSeconds: 6 * 60 * 60));
$clientNoCache = new Client(new Options(cache: false));

The cache lives on the client instance, so how much it buys you depends on how long that instance lives. A worker, a queue consumer or a long-running server gets the full benefit. Under PHP-FPM or mod_php the client is built for every request, so give it a PSR-16 cache that outlives the request, such as your framework's:

$client = new Client(new Options(apiKey: $key, cacheStore: $cache));

Entries are keyed by a hash of your API key, the base URL and the address, so two keys never share an answer, and they live cacheTtlSeconds. The store's own eviction takes the place of cacheMaxSize, and a store that fails is a miss, never a failed lookup. vpndetection/symfony and vpndetection/laravel wire the framework's cache for you.

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 = $client->lookup('192.168.1.1');
$result->isBogon;   // true, this answer was computed rather than served
$result->isVpn;     // false

The 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');    // false

It is also callable on its own, if you want it without a client:

use VPNDetection\Bogon;

Bogon::isBogon('10.0.0.1');   // true

Errors

Failures throw a VPNDetectionException carrying a kind and a isRetryable() flag:

use VPNDetection\VPNDetectionException;

try {
    $client->lookup('1.1.1.1');
} catch (VPNDetectionException $err) {
    echo $err->kind->value, ' ', $err->isRetryable() ? 'retryable' : 'final', "\n";
}

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 and retries

Each attempt gives up after 30 seconds, and a transient failure is retried twice. Both can be changed for the client, and for a single call:

$client = new Client(new Options(timeout: 10, retries: 4));

$result = $client->lookup('45.83.91.1', ['timeout' => 2.5, 'retries' => 0]);

The timeout is in seconds and applies to each attempt, so a retried call can take longer in total. An attempt that runs out of time fails as a retryable network error. A database download isn't bound by it, since a large file takes as long as it takes.

Database downloads

If your key carries the db.download scope, the licensed databases are available through $client->database. download fetches one to a path, streaming it straight to disk so that nothing bigger than a chunk is ever held in memory:

$databases = $client->database->list();

$written = $client->database->download('vpn_ip_extended_v1', 'mmdb', '/srv/data/vpn_ip_extended_v1.mmdb');
echo "{$written} bytes";

Or take the time-limited link and run the transfer yourself, or take a small database as bytes:

$url = $client->database->downloadUrl('vpn_ip_extended_v1', 'mmdb');
$bytes = $client->database->downloadBytes('cdn_ip_v1', '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: past your memory_limit this is a fatal error, not merely a slow one.

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:

$client = new Client();

$device = $client->oauth->deviceAuthorization('your-client-id', [
    'scope' => 'account.read apikeys.read apikeys.reveal',
]);
echo "Open {$device->verificationUri} and enter {$device->userCode}\n";

$token = $client->oauth->pollDeviceToken('your-client-id', $device);
if ($token->apikey === null) {
    throw new RuntimeException("no API key came back: none was picked, or it can't be shown again");
}
$keyed = new Client(new Options(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->revoke('your-client-id', $token->refreshToken) 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:

$client = new Client();
$redirectUri = 'http://127.0.0.1:8765/callback';
$pkce = $client->oauth->createPkce();

$url = $client->oauth->authorizationUrl('your-client-id', $redirectUri, $pkce->challenge, [
    'scope' => 'apikeys.use', 'state' => 'your-state',
]);
// Open $url in the browser. Its redirect to $redirectUri carries code and state.
$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->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 exchangeAuthorizationCode:

$client = new Client();
$registration = $client->oauth->register(['http://127.0.0.1:8765/callback'], ['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 is_vpn come back, and a free key adds Tor, relay, location and asn. A field your key does not include is null, which is not the same answer as false: null means "not in your plan", false means "checked, and no".

$result->isHosting ?? false;   // when you only want the flag
$result->isHosting === null;   // not in your plan

On this page