Authentication
Pass your key on every request, either as Authorization: Bearer bt_... or as an x-api-key header. Keys are scoped; the Geo API needs geo:read. A missing key returns 401, a key without the scope returns 403.
curl "https://api.bolrach.com/v1/geo" \
-H "Authorization: Bearer bt_your_key_here"Calling /v1/geo with no ip parameter locates the caller, which doubles as the fastest way to test a fresh key.
Endpoints
Locate one address, or the caller when ip is omitted.
| Parameter | In | Type | Description |
|---|---|---|---|
ip | query | string, optional | IPv4 or IPv6 address. Omit to locate your own public address. |
curl "https://api.bolrach.com/v1/geo?ip=8.8.8.8" \
-H "Authorization: Bearer bt_your_key_here"Response
{
"ip": "8.8.8.8",
"country": "US",
"region": "California",
"city": "Mountain View",
"lat": 37.422,
"lon": -122.085,
"timezone": null,
"asn": 15169,
"network": "Google LLC",
"network_type": "hosting",
"tor": false,
"source": "dbip",
"attribution": "IP geolocation by DB-IP (https://db-ip.com), refined by Bolrach Geo"
}The same lookup with the address in the path. IPv6 works unescaped.
curl "https://api.bolrach.com/v1/geo/2001:4860:4860::8888" \
-H "Authorization: Bearer bt_your_key_here"Resolve 1-100 addresses in one call. Results come back in request order, and the whole batch counts as one request against your limit.
| Field | Type | Description |
|---|---|---|
ips | string[], required | 1-100 IPv4/IPv6 addresses. |
curl -X POST "https://api.bolrach.com/v1/geo/batch" \
-H "Authorization: Bearer bt_your_key_here" \
-H "Content-Type: application/json" \
-d '{"ips": ["8.8.8.8", "1.1.1.1"]}'Response
{
"count": 2,
"results": [
{ "ip": "8.8.8.8", "country": "US", "city": "Mountain View", "asn": 15169, "network": "Google LLC", "network_type": "hosting", "tor": false, "source": "dbip", ... },
{ "ip": "2620:101:c040::1", "country": "CA", "city": "Edmonton", "timezone": "America/Edmonton", "asn": 3359, "network": "University of Alberta", "network_type": null, "tor": false, "source": "observed", ... }
]
}Result fields
Every field below works for IPv4 and IPv6. Private and unrouted ranges come back with every place and network field set to null.
| Field | What it tells you |
|---|---|
country, region, city, lat, lon | Where the address is, to city level. Coordinates are the city centre, not a street address. |
timezone | IANA zone such as Africa/Lagos. Filled when source is observed. |
asn, network | The network the address belongs to and who runs it, for example 15169 and Google LLC. |
network_type | tor, vpn, hosting or mobile. A sign-in from hosting is usually a script or a proxy, not a person at home. Null for ordinary connections. |
tor | The address is on today's Tor exit list. |
source | observed when Cloudflare's edge placed this network block for real Bolrach traffic in the last 90 days (the more accurate answer), dbip when the place comes from DB-IP Lite. |
Using the network signals
The place tells you where a request came from. The network fields tell you what kind of connection it used, and for sign-in checks that often matters more. A new sign-in from a hosting company is usually a script or a proxy rather than a person at home, and a Tor exit hides the real location on purpose.
The usual pattern is to ask for a second factor when one of these shows up, not to refuse the sign-in outright. People use VPNs and office proxies for ordinary reasons.
const res = await fetch(`https://api.bolrach.com/v1/geo?ip=${signInIp}`, {
headers: { Authorization: `Bearer ${process.env.BOLRACH_API_KEY}` },
});
const geo = await res.json();
// Ask for a second factor instead of blocking: people use VPNs and office proxies for good reasons.
const stepUp =
geo.tor ||
geo.network_type === "hosting" ||
(user.lastCountry && geo.country && geo.country !== user.lastCountry);
await sendSignInAlert(user, {
where: [geo.city, geo.region, geo.country].filter(Boolean).join(", "),
network: geo.network,
});AUTHVIO's own new-device sign-in email is built from exactly these fields: the place, the country, the address, the network owner and a warning line for Tor, VPN and hosting networks.
How the place is chosen
Cloudflare places every visitor that reaches a Bolrach site. Bolrach Geo keeps what Cloudflare reported for each network block (a /24 for IPv4, a /48 for IPv6) for 90 days, learned every 10 minutes from real traffic, and answers from that when it has it. Those results carry source: "observed" and usually a timezone. Every other address is answered from DB-IP Lite with source: "dbip". Only the block and its place are kept, never the address of any single visitor.
Accuracy is city level. It is good enough to say "Lagos" or "Toronto", and mobile networks can place a phone in the carrier's hub city rather than where the person is standing. Never use it as a street address.
Rate limits and quotas
Limits apply per key. Every authenticated response carries X-RateLimit-Limit, X-RateLimit-Remaining and X-RateLimit-Reset (seconds until the window resets); a 429 adds Retry-After.
| Plan | Requests / minute | Requests / day |
|---|---|---|
| Free | 60 | 5,000 |
| Growth | 300 | 50,000 |
| Scale | 1,200 | 500,000 |
Errors
Every error, on every service, uses one envelope:
{
"error": {
"code": "rate_limited",
"message": "You hit the rate limit for your plan (free). Retry in 13s.",
"status": 429
}
}| Status | Code | Meaning |
|---|---|---|
| 400 | invalid_ip, bad_request, batch_too_large | The input is not usable: malformed address, wrong body shape, or more than 100 batch items. |
| 401 | missing_key, invalid_key | No key was sent, or the key is unknown or revoked. |
| 403 | missing_scope | The key is valid but lacks geo:read. |
| 429 | rate_limited | Per-minute rate, daily quota, or platform backstop exceeded. Honor Retry-After. |
| 502 | upstream_error | The geo backend did not answer. Safe to retry with backoff. |
Data and attribution
Lookups run entirely on Bolrach-hosted data. City and network-owner data come from DB-IP Lite (CC-BY 4.0), reloaded on the 4th of each month. The Tor exit list is reloaded daily. When Cloudflare's edge has placed a network block for real Bolrach traffic, that answer is kept for 90 days and wins over the database. Your queries never touch a third-party lookup service.
attribution string; keep it visible wherever you display city-level results.Changelog
| Date | Change |
|---|---|
| 2026-09-24 | Places from Cloudflare-observed network blocks (source: "observed"). Added timezone, asn, network, network_type, tor and source. City-level results for IPv6. Existing fields are unchanged. |
| 2026-07-14 | v1 launch: GET /v1/geo, GET /v1/geo/{ip}, POST /v1/geo/batch with keys, scopes, rate limits and usage metering. |