Call the API from your own backend. Your server sends the visitor's IP and user agent and gets back location, device, network and commerce data.
Your ACCOUNT_ID and full endpoint URL are shown in the API keys section of your dashboard. The account ID is not a secret. The API key is.
Headers
| Header | Required | Value |
|---|---|---|
Authorization | Yes | Bearer gv_YOUR_KEY. The key must belong to the same account as the ACCOUNT_ID in the hostname |
Content-Type | Recommended | application/json. The body is always parsed as JSON |
Request body
A JSON object with the visitor's IP and user agent. Both are optional. Leave one out and the fields that depend on it come back null.
{
"ip": "84.198.10.20",
"ua": "Mozilla/5.0 (Linux; Android 14; SM-S928B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/126.0.0.0 Mobile Safari/537.36"
}
| Field | Type | Meaning |
|---|---|---|
ip | string | Visitor IP, IPv4 or IPv6 |
ua | string | Visitor's User-Agent header |
Batch requests
Send a JSON array of up to 16 objects to look up several visitors in one call. The response is an array in the same order. Each item counts as one lookup toward your monthly quota. A batch with more than 16 items is rejected with 413, and nothing is counted.
[
{ "ip": "84.198.10.20", "ua": "Mozilla/5.0 ..." },
{ "ip": "2a02:c7c:1234::1", "ua": "Mozilla/5.0 ..." }
]
An array item that isn't an object gets an error in its slot, and the other items still succeed:
[
{ "location": { ... }, ... },
{ "error": "invalid item: expected an object" }
]
Response
200 OK with Content-Type: application/json. A single object for a single request, an array for a batch.
{
"location": {
"country": "Germany",
"country_code": "DE",
"continent": "Europe",
"eu": true,
"state": "Hesse",
"city": "Frankfurt am Main",
"languages": [{ "code": "de", "rtl": false }],
"privacy_regime": { "name": "GDPR", "consent": "OPT IN" }
},
"device": {
"type": "mobile",
"os": { "name": "Android", "version": "14" },
"browser": { "name": "Chrome", "version": "126" }
},
"asn": { "id": 3209, "name": "Vodafone GmbH", "threat_level": "normal" },
"commerce": {
"currency": { "code": "EUR", "symbol": "€", "fx_usd": 1.16, "fx_eur": 1.0 },
"buying_power": 0.79,
"sales_tax": {
"label": "VAT",
"rate": 19.0,
"rate_with_local_addons": { "min": 19.0, "max": 19.0 },
"threshold": 0.0
}
},
"lookup_latency_ms": 0.04
}
Response fields
Any field can be null when the data isn't known for that visitor. Without an IP, every location, asn and commerce field is null. Without a user agent, device is null.
| Field | Type | Meaning |
|---|---|---|
location.country | string | Country name, e.g. "Germany" |
location.country_code | string | ISO 3166-1 alpha-2 code, e.g. "DE" |
location.continent | string | Continent name |
location.eu | boolean | Whether the country is in the EU |
location.state | string | State, region or province |
location.city | string | City name |
location.languages | array | Languages spoken in the country, primary first. Each has code and rtl (right-to-left script) |
location.privacy_regime | object | The privacy law that applies (name, e.g. "GDPR") and consent: "OPT IN" or "OPT OUT" |
device.type | string | e.g. "desktop", "mobile", "tablet" |
device.os | object | name and version |
device.browser | object | name and version |
asn.id | integer | Autonomous system number of the visitor's network |
asn.name | string | Network operator |
asn.threat_level | string | "normal", or "elevated" for known hosting, datacenter or VPN networks |
commerce.currency | object | Local currency: code, symbol, and FX rates fx_usd / fx_eur (refreshed daily) |
commerce.buying_power | number | Purchasing power vs. a US baseline of 1.0. 0.79 means 21% less buys the same |
commerce.sales_tax.label | string | Tax name (VAT, GST, ...). null if no tax applies |
commerce.sales_tax.rate | number | Standard rate, in percent |
commerce.sales_tax.rate_with_local_addons | object | min/max rate once local or provincial add-ons are included, already summed |
commerce.sales_tax.threshold | number | Seller registration threshold, in local currency |
lookup_latency_ms | number | Server-side time the lookup took |
Examples
Every example below keeps one client or connection alive and reuses it across calls. Do the same in your code. Opening a new connection per request roughly triples latency and quickly runs into the connection rate limit below.
ACCOUNT_ID=YOUR_ACCOUNT_ID
API_KEY=gv_YOUR_KEY
curl https://$ACCOUNT_ID.api.getvisitor.info/ \
-H "Authorization: Bearer $API_KEY" \
-H "Content-Type: application/json" \
-d '{
"ip": "84.198.10.20",
"ua": "Mozilla/5.0 (Linux; Android 14; SM-S928B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/126.0.0.0 Mobile Safari/537.36"
}'# gem install net-http-persistent
require "net/http/persistent"
require "json"
ACCOUNT_ID = "YOUR_ACCOUNT_ID"
API_KEY = "gv_YOUR_KEY"
# Reuse this client across requests. Don't create a new one per call.
HTTP = Net::HTTP::Persistent.new(name: "getvisitor")
def lookup(ip:, ua:)
uri = URI("https://#{ACCOUNT_ID}.api.getvisitor.info/")
req = Net::HTTP::Post.new(uri)
req["Authorization"] = "Bearer #{API_KEY}"
req["Content-Type"] = "application/json"
req.body = { ip: ip, ua: ua }.to_json
JSON.parse(HTTP.request(uri, req).body)
end
# lookup(ip: "84.198.10.20", ua: request.user_agent)import requests
ACCOUNT_ID = "YOUR_ACCOUNT_ID"
API_KEY = "gv_YOUR_KEY"
# Reuse this session across requests. Don't create a new one per call.
session = requests.Session()
session.headers["Authorization"] = f"Bearer {API_KEY}"
def lookup(ip, ua):
response = session.post(f"https://{ACCOUNT_ID}.api.getvisitor.info/", json={"ip": ip, "ua": ua})
return response.json()
# lookup("84.198.10.20", request.headers["User-Agent"])// A traditional PHP-FPM request is its own short-lived process, so
// there's no long-running client object to reuse ACROSS requests the
// way the other examples here do. curl's own keep-alive still reuses
// the TCP/TLS connection for MULTIPLE calls made within the SAME
// request when you keep using the same curl handle, which is what this
// does. If you're on a long-running PHP runtime (Swoole, RoadRunner),
// hold $ch in a worker-scoped variable instead of opening it per request.
$accountId = "YOUR_ACCOUNT_ID";
$apiKey = "gv_YOUR_KEY";
function lookup($ch, $accountId, $apiKey, $ip, $ua) {
curl_setopt_array($ch, [
CURLOPT_URL => "https://{$accountId}.api.getvisitor.info/",
CURLOPT_POST => true,
CURLOPT_HTTPHEADER => [
"Authorization: Bearer {$apiKey}",
"Content-Type: application/json",
],
CURLOPT_POSTFIELDS => json_encode(["ip" => $ip, "ua" => $ua]),
CURLOPT_RETURNTRANSFER => true,
]);
return json_decode(curl_exec($ch), true);
}
$ch = curl_init();
// lookup($ch, $accountId, $apiKey, "84.198.10.20", $_SERVER["HTTP_USER_AGENT"]);
curl_close($ch);const ACCOUNT_ID = "YOUR_ACCOUNT_ID";
const API_KEY = "gv_YOUR_KEY";
// fetch() reuses the underlying connection across calls to the same
// origin automatically. No explicit client/session object needed here.
async function lookup(ip, ua) {
const response = await fetch(`https://${ACCOUNT_ID}.api.getvisitor.info/`, {
method: "POST",
headers: {
"Authorization": `Bearer ${API_KEY}`,
"Content-Type": "application/json",
},
body: JSON.stringify({ ip, ua }),
});
return response.json();
}
// lookup("84.198.10.20", req.headers["user-agent"]);package main
import (
"bytes"
"encoding/json"
"net/http"
)
const accountID = "YOUR_ACCOUNT_ID"
const apiKey = "gv_YOUR_KEY"
// http.DefaultClient reuses connections across calls automatically.
// Its Transport pools them by default, as long as you keep using the
// SAME client, not a fresh http.Client{} constructed per call.
func lookup(ip, ua string) (map[string]interface{}, error) {
payload, err := json.Marshal(map[string]string{"ip": ip, "ua": ua})
if err != nil {
return nil, err
}
req, err := http.NewRequest("POST", "https://"+accountID+".api.getvisitor.info/", bytes.NewBuffer(payload))
if err != nil {
return nil, err
}
req.Header.Set("Authorization", "Bearer "+apiKey)
req.Header.Set("Content-Type", "application/json")
resp, err := http.DefaultClient.Do(req)
if err != nil {
return nil, err
}
defer resp.Body.Close()
var result map[string]interface{}
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
return nil, err
}
return result, nil
}
// lookup("84.198.10.20", r.Header.Get("User-Agent"))static final String ACCOUNT_ID = "YOUR_ACCOUNT_ID";
static final String API_KEY = "gv_YOUR_KEY";
// HttpClient reuses connections across calls automatically, as long as
// you keep using the SAME client. Never HttpClient.newHttpClient() per call.
static final HttpClient client = HttpClient.newHttpClient();
static String lookup(String ip, String ua) throws Exception {
String body = String.format(
"""
{"ip": "%s", "ua": "%s"}""",
ip.replace("\"", "\\\""), ua.replace("\"", "\\\"")
);
HttpRequest request = HttpRequest.newBuilder()
.uri(URI.create("https://" + ACCOUNT_ID + ".api.getvisitor.info/"))
.header("Authorization", "Bearer " + API_KEY)
.header("Content-Type", "application/json")
.POST(HttpRequest.BodyPublishers.ofString(body))
.build();
HttpResponse<String> response = client.send(request, HttpResponse.BodyHandlers.ofString());
return response.body();
}
// lookup("84.198.10.20", request.getHeader("User-Agent"));const string AccountId = "YOUR_ACCOUNT_ID";
const string ApiKey = "gv_YOUR_KEY";
// Reuse ONE client for the life of your process. Never `using var client
// = new HttpClient()` per call, which disposes the underlying connection
// immediately and is a well-known source of socket exhaustion under load.
static readonly HttpClient client = new();
static async Task<JsonElement> Lookup(string ip, string ua)
{
if (!client.DefaultRequestHeaders.Contains("Authorization"))
client.DefaultRequestHeaders.Add("Authorization", "Bearer " + ApiKey);
var payload = new { ip, ua };
var response = await client.PostAsJsonAsync($"https://{AccountId}.api.getvisitor.info/", payload);
return await response.Content.ReadFromJsonAsync<JsonElement>();
}
// await Lookup("84.198.10.20", Request.Headers.UserAgent.ToString());require "http/client"
require "json"
ACCOUNT_ID = "YOUR_ACCOUNT_ID"
API_KEY = "gv_YOUR_KEY"
# Reuse this client across requests. Don't create a new one per call.
CLIENT = HTTP::Client.new("#{ACCOUNT_ID}.api.getvisitor.info", tls: true)
def lookup(ip : String, ua : String)
headers = HTTP::Headers{"Authorization" => "Bearer #{API_KEY}", "Content-Type" => "application/json"}
body = {ip: ip, ua: ua}.to_json
response = CLIENT.post("/", headers: headers, body: body)
JSON.parse(response.body)
end
# lookup("84.198.10.20", request.headers["User-Agent"])Limits
Paid limits scale with the number of units on your subscription.
| Limit | Free | Paid, per unit |
|---|---|---|
| Lookups per month | 6,000 | 5,000,000 |
| Requests per minute | 100 | 1,000 |
| New connections per minute | 10 | 100 |
| Concurrent connections | 2 | 8 |
| Max lookups per request | 16 (fixed) | 16 (fixed) |
| Request body size | 10 KB | 10 KB |
The request rate limit is shared with CORS / Web calls. CORS lookups have their own monthly quota.
Going over a rate limit returns 429. Traffic that stays well above the limit (about 20% over) is dropped without a response. Back off when you see a 429 and honor Retry-After.
Why there's a limit on new connections
Each new connection costs a TCP and a TLS handshake. That roughly triples your lookup latency, and costs us far more CPU than the lookup itself. With keep-alive, one connection carries many requests, so the new connections limit (100/min per unit) is set well below the request limit (1,000/min). The concurrent connections limit (8 per unit) counts every open connection, idle ones included.
- Create one HTTP client at startup and reuse it, as the examples do. In serverless functions, create it outside the handler.
- Use a batch request instead of parallel connections.
- Both limits grow with every unit. If you already reuse connections and still hit them, add units.
Going over returns 429 (bearer-connection-rate-exceeded or bearer-concurrent-connections-exceeded). Well past the limit, new connections are dropped during the handshake.
Errors
| Status | Body | Cause |
|---|---|---|
400 | {"error": "invalid JSON body"} | The body isn't valid JSON |
401 | invalid or missing API key | No Authorization header, an unknown or revoked key, or a key from a different account than the ACCOUNT_ID in the hostname |
403 | over quota: see https://getvisitor.info/docs/billing.html | Monthly lookup quota used up. Add units or wait for the next period. See Units & billing |
404 | Unknown ACCOUNT_ID in the hostname | |
413 | request too large | Request body over 10 KB |
413 | {"error": "too many lookups: max 16 per request"} | Batch array with more than 16 items |
404 | {"error": "not found"} | Path other than / |
429 | Rate limit hit. See the X-Rate-Limit-Reason header below |
Error bodies are plain text, except 400, 404 not found and 413 too many lookups, which are JSON.
429 reasons
Every 429 carries Retry-After: 60 and an X-Rate-Limit-Reason header:
| X-Rate-Limit-Reason | Meaning |
|---|---|
global-request-rate-exceeded | Too many requests per minute for your account |
bearer-connection-rate-exceeded | Too many new connections per minute. Reuse a keep-alive connection instead of opening one per request |
bearer-concurrent-connections-exceeded | Too many connections open at once. The server closes this connection |
A connection that keeps sending requests after repeated rejections (limit, quota, unknown account) is closed by the server.