Geolocate IP Addresses
Turns public IP addresses into country, region, city, coordinates and timezone — up to 50 addresses in a single call. Use it to put a country column next to the addresses in your index request log, to map where your search traffic comes from, or to enrich your own application logs without running a geo-IP database yourself.
Endpoint
POST https://opensolr.com/solr_manager/api/geo_lookup
opensolr.com. It is part of the management API, not the AI API, so do not call it on api.opensolr.com — that host answers 404 with WRONG_API_HOST and tells you the correct URL. GET works exactly like POST; a JSON request body is accepted too, with the same field names.Parameters
| Parameter | Type | Status | Description |
|---|---|---|---|
email | string | Required | Your Opensolr registration email address |
api_key | string | Required | Your Opensolr API key (master key, or a scoped key that allows this endpoint) |
ips | array or string | Optional | The addresses to look up. A real JSON array in a JSON body, a JSON array as a string, or a plain string of addresses separated by commas, semicolons or whitespace — all three are accepted, so a pasted log column works as-is |
ip | string | Optional | A single address. Can be used on its own, or together with ips — it is appended to that list |
ips or ip is required. A request naming no usable address answers {"status": false, "msg": "ERROR_NO_IP_SUPPLIED"} together with the current max_ips value, so a client can discover the batch size without reading this page.How It Works
Every address you send is validated first, then resolved. Duplicates are collapsed, so sending the same address forty times costs one lookup. Results already known to Opensolr are served from cache: a resolved address is cached for a week, an address the locator has no data for is cached for an hour, and an upstream failure is cached for one minute only — a transient outage never masquerades as “this address has no geography”.
Addresses with no geography are refused before any lookup happens: loopback, RFC1918 private ranges, CGNAT, link-local, multicast, documentation and reserved ranges, and their IPv6 equivalents (unique-local, link-local, Teredo, NAT64, documentation). An IPv4-mapped IPv6 address such as ::ffff:8.8.8.8 is unwrapped to its IPv4 form and judged by the IPv4 rules. These come back as ERROR_NOT_A_PUBLIC_IP inside the results rather than failing your request.
Results are keyed by the address exactly as you sent it (reduced to the characters an address can contain), so you can look up what you asked for without re-normalising anything. The ip field inside each entry is the canonical form.
Absent facts are absent
On a resolved entry only found, ip and country_code are guaranteed. Every other field is omitted when the locator does not know it — never an empty string, never a zero standing in for “unknown”. Test with isset() and you never have to guess whether a value is real:
- Coordinates are omitted, not faked. A missing, non-numeric or out-of-range pair is dropped; so is exactly
0,0, which is a real place in the Gulf of Guinea and would put a false cluster on your map; and so is the locator's continental-US centroid when no city was resolved, which would otherwise spike every map in the middle of Kansas. Country and timezone are still returned in that case. timezoneis always a real IANA identifier (validated against the timezone database) or absent. Never an offset, never an invented string.
Versioned contract
The response shape is Opensolr's, not the locator's, and it carries contract_version. The provider behind the endpoint can be replaced without breaking your integration, and the field names on this page do not change when it is.
Limits
| Limit | Value | What happens |
|---|---|---|
| Addresses per call | 50 | Extras are dropped, the first 50 are resolved, and the response sets capped: true. submitted tells you how many usable addresses you actually sent |
| Candidates parsed per call | 1000 | Parsing stops there, so a pathological payload cannot become the attack |
| Length per address | 64 characters | Anything longer is truncated before validation |
| Rate limit cost | 1 per address | Your monthly API request counter is charged per address, not per request: a 50-address batch costs 50. The per-minute and per-hour limits count the request once |
ERROR_GEO_NO_DATA. The contract will not change when IPv6 support lands — the same entries simply start resolving.Response
The envelope:
| Key | Type | Description |
|---|---|---|
status | boolean | true when the request was understood. Per-address failures do not make this false |
contract_version | integer | Version of this response shape. Currently 1 |
count | integer | Number of entries in results |
submitted | integer | Usable addresses your request contained, before the cap was applied |
max_ips | integer | The server-side batch cap in force |
capped | boolean | true only when submitted exceeded max_ips and addresses were dropped |
results | object | One entry per address, keyed by the address as you sent it |
A resolved entry:
| Key | Type | Always present | Description |
|---|---|---|---|
found | boolean | Yes | true |
ip | string | Yes | The canonical form of the address |
country_code | string | Yes | Two-letter ISO 3166 country code, uppercase |
country_name | string | No | Country name |
region | string | No | Region or state name; falls back to the region code when only that is known |
city | string | No | City name |
latitude | float | No | Present only when the pair is plottable — see Absent facts are absent |
longitude | float | No | As above; the two always appear together or not at all |
timezone | string | No | IANA timezone identifier, for example Europe/Bucharest |
An unresolved entry carries found: false, the canonical ip when the address was at least valid, and a msg naming the reason.
Errors
Per-address problems are not request failures — they appear inside results, so one bad line in a log batch never voids the other forty-nine:
| Code | Where | Meaning |
|---|---|---|
ERROR_NOT_A_PUBLIC_IP | entry | Not a valid address, or an address with no geography (private, loopback, CGNAT, link-local, reserved, multicast, documentation) |
ERROR_GEO_NO_DATA | entry | A valid public address the locator has no data for. Also the current answer for IPv6 |
ERROR_GEO_LOOKUP_UNAVAILABLE | entry | The lookup itself failed upstream. Retry — this is cached for one minute only |
ERROR_NO_IP_SUPPLIED | request | Neither ips nor ip named a usable address. HTTP 200 with status: false |
ERROR_AUTHENTICATION_FAILED | request | HTTP 403. Email / API key mismatch |
ERROR_SCOPED_KEY_ENDPOINT_NOT_ALLOWED | request | HTTP 403. A scoped key that was not granted this endpoint |
ERROR_RATE_LIMIT_PER_MINUTE / _PER_HOUR | request | HTTP 429 with a Retry-After header. Back off and retry |
WRONG_API_HOST | request | HTTP 404. You called it on api.opensolr.com; the body names the correct URL |
Code Examples
cURL
# A single address curl -s "https://opensolr.com/solr_manager/api/geo_lookup?email=YOUR_EMAIL&api_key=YOUR_API_KEY&ip=8.8.8.8" # A batch, as a plain separated list curl -s -X POST "https://opensolr.com/solr_manager/api/geo_lookup" \ -d "email=YOUR_EMAIL" -d "api_key=YOUR_API_KEY" \ -d "ips=8.8.8.8, 1.1.1.1, 208.67.222.222" # A batch, as a JSON body curl -s -X POST "https://opensolr.com/solr_manager/api/geo_lookup" \ -H "Content-Type: application/json" \ -d '{"email":"YOUR_EMAIL","api_key":"YOUR_API_KEY","ips":["8.8.8.8","1.1.1.1"]}'
PHP
$payload = json_encode([ 'email' => 'YOUR_EMAIL', 'api_key' => 'YOUR_API_KEY', 'ips' => ['8.8.8.8', '1.1.1.1', '10.0.0.5'], ]); $ch = curl_init('https://opensolr.com/solr_manager/api/geo_lookup'); curl_setopt_array($ch, [ CURLOPT_RETURNTRANSFER => true, CURLOPT_POST => true, CURLOPT_HTTPHEADER => ['Content-Type: application/json'], CURLOPT_POSTFIELDS => $payload, ]); $out = json_decode(curl_exec($ch), true); curl_close($ch); if ($out['capped'] ?? false) { echo "Only the first {$out['max_ips']} of {$out['submitted']} addresses were resolved\n"; } foreach ($out['results'] as $sent => $entry) { if (empty($entry['found'])) { echo "{$sent}: {$entry['msg']}\n"; continue; } // Never assume city/coordinates are there — absent means unknown. $where = $entry['city'] ?? $entry['region'] ?? $entry['country_name'] ?? $entry['country_code']; $pin = isset($entry['latitude']) ? " ({$entry['latitude']}, {$entry['longitude']})" : ''; echo "{$sent}: {$entry['country_code']} — {$where}{$pin}\n"; }
Python
import requests r = requests.post( "https://opensolr.com/solr_manager/api/geo_lookup", json={ "email": "YOUR_EMAIL", "api_key": "YOUR_API_KEY", "ips": ["8.8.8.8", "1.1.1.1", "10.0.0.5"], }, timeout=60, ) data = r.json() for sent, entry in data["results"].items(): if not entry.get("found"): print(sent, entry["msg"]) continue # .get() everywhere: an absent key means the locator did not know it. print(sent, entry["country_code"], entry.get("city"), entry.get("timezone"))
Example Response
{ "status": true, "contract_version": 1, "count": 3, "submitted": 3, "max_ips": 50, "capped": false, "results": { "8.8.8.8": { "found": true, "ip": "8.8.8.8", "country_code": "US", "country_name": "United States", "region": "California", "city": "Mountain View", "latitude": 37.386, "longitude": -122.0838, "timezone": "America/Los_Angeles" }, "1.1.1.1": { "found": true, "ip": "1.1.1.1", "country_code": "AU", "country_name": "Australia", "timezone": "Australia/Sydney" }, "10.0.0.5": { "found": false, "msg": "ERROR_NOT_A_PUBLIC_IP" } } }
1.1.1.1 resolved to a country and a timezone and carries no city, region, latitude or longitude — because those were not known. That is the contract working as designed: your code branches on isset(), never on a sentinel value.Use Cases
- Add a country or city column to the addresses returned by the index request log and the traffic monitoring API, so you can see where your search traffic actually comes from
- Draw a map of query volume by country without shipping a geo-IP database with your application, and without a per-lookup bill
- Spot traffic that does not belong: a burst of queries from a country you do not serve usually means a leaked key, a scraper, or an integration nobody remembers deploying
- Decide which region to host an index in, from where your users really are
- Enrich your own application or web server logs — the endpoint does not care whether the addresses came from Opensolr
- Set a per-user timezone from a signup address, using a value that is guaranteed to be a real IANA identifier
Related Documentation
Need help with the Opensolr API? We are here to help.
Contact Support