Documentation
One API key, one balance. Pick LITE for geo + ASN, MAX for threat signals. LITE includes 100,000 free lookups per rolling 30 days; past that you are billed per query — no plans, no subscriptions.
Copy a self-contained Markdown spec — paste into Claude, ChatGPT, Cursor, or Copilot and ask it to build the client.
Quick Start
- Sign up — no credit card. Your account starts with $5 of free credit, good for 60 days.
- Open your dashboard and create an API key.
- Make a request — LITE works from the first minute:
# LITE — fast geo + ASN
curl -H "Authorization: Bearer LOOKIP_KEY" \
https://api.lookip.io/v1/lookup/lite/8.8.8.8MAX from the API needs topped-up credit — the signup grant is LITE-only. Try MAX free on the site, or top up to call it:
# MAX — threat signals + city-level geo
curl -H "Authorization: Bearer LOOKIP_KEY" \
https://api.lookip.io/v1/lookup/max/8.8.8.8The JSON shape is stable per kind — write your client once and it keeps working as your balance goes up and down.
Authentication
Send your key in the Authorization header. It is the only accepted scheme.
Authorization: Bearer LOOKIP_KEY
# Retired: ?token=LOOKIP_KEY in the URL now returns unauthorized 401.
# A key in a query string reaches access logs, browser history and Referer.We store a SHA-256 hash of your key, never the key itself. It is shown once, when you create or regenerate it, and cannot be retrieved afterwards — by you or by us. Lost a key? Regenerate it; the old one stops working immediately.
Keys are server-side only. Never embed them in client-side JavaScript, mobile apps, or public repositories. Rotate keys from the dashboard if a leak is suspected — revocation is instant.
Browsers refuse this for you: the Authorization header is not in our cross-origin allow list, so a preflight from another origin fails before your key leaves the page. It is a guard rail, not a control — CORS binds compliant browsers and nothing else, so it does not protect a key already shipped in a bundle, or one used from curl, a server, a native app, or an extension. Treat a leaked key as leaked and rotate it. The public ASN endpoints and the OpenAPI document need no key and stay browser-readable.
LITE vs MAX
Both kinds draw from the same credit balance at different rates. Purchased credit draws on both; the signup grant is LITE-only. There is nothing to enable — pick the one that answers your question.
Geo + ASN, served from a local dataset. Sub-5ms p95. Use it when you only need country and network ownership.
- Country, continent, countryCode
- ASN, organization, domain
- IPv4 + IPv6
Adds city-level geo, reverse DNS, threat signals, hosting flags, and mobile carrier data.
- City, region, postal, lat/long, timezone, DMA, geoname
- VPN / proxy / Tor / relay / residential-proxy detection
- Residential-proxy provider named, when attributable
- Anonymous, anycast, hosting, mobile, satellite, residential-proxy flags
- Mobile carrier (MCC / MNC)
- Hostname (reverse DNS)
The kind is picked by the URL path, not a header or body field — so the same key can mix both in the same second.
Pricing & Credit
Billing is prepaid credit, debited when a request is accepted and reversed automatically if we then fail to serve it. Amounts are stored in micro-USD (1 USD = 1,000,000 µUSD) so a single query is charged exactly, with no rounding in our favour.
| Kind | Per query | Per 1,000 queries |
|---|---|---|
| LITE | 150 µUSD | $0.15 |
| MAX | 1300 µUSD | $1.30 |
- Free on signup. $5 of credit, valid for 60 days — roughly 33,333 LITE queries. The signup grant is spendable on LITE only; topped-up credit covers MAX too.
- Top-ups start at $10 and carry a use-by window that grows with the amount: $10–24 lasts 30 days, $25–49 60 days, $50–99 90 days, $100–249 180 days, $250+ a full year. Credit is always spent oldest-expiry-first.
- Private-range lookups are free, on the single-lookup endpoints and inside a batch alike — RFC 1918 and loopback addresses, and anything that isn't a valid IP, short-circuit before any debit. A batch is charged for the distinct addresses we actually resolve, not for the size of the array. Other special-use ranges (CGNAT, TEST-NET, multicast) are only identified upstream and are billed.
- The ASN endpoints are free and need no API key at all.
- Run out and every metered call returns
402 insufficient_credituntil you top up. Nothing is served, so nothing is charged.
Full details on the pricing page.
Endpoints
Lookup endpoints live under /v1. Production base URL: https://api.lookip.io.
| Method | Path | Kind | Description |
|---|---|---|---|
GET | /v1/lookup/lite/:ip | LITE | Fast geo + ASN, served from our local dataset. |
POST | /v1/lookup/lite | LITE | Same as GET but accepts an optional context body. |
GET | /v1/lookup/max/:ip | MAX | City, threats, hostname, carrier. |
POST | /v1/lookup/max | MAX | Same as GET but accepts an optional context body. |
GET | /v1/lookup/me | MAX | Enrich the caller’s own IP. |
POST | /v1/batch/lite | LITE | Up to 100 IPs in one call. |
POST | /v1/batch/max | MAX | Up to 100 IPs in one call. |
GET | /v1/asn/:asn | Free | ASN record by number. Accepts 15169 or AS15169. No key required. |
GET | /v1/asn | Free | Search ASNs by q, country, limit. No key required. |
GET | /health | Free | Liveness probe. Returns { ok, ts }. |
GET | /openapi.json | Free | This API as an OpenAPI 3.1 document. No key required. |
Older aliases (/v1/lookup/:ip, /v1/lookup/full/:ip, /v1/batch) still resolve to MAX for existing integrations. Write new clients against the explicit lite / max paths.
Batch lookups
A batch call is debited up front for the distinct addresses we will actually resolve — malformed entries and bogons are classified out before the wallet is touched, and a repeated address is charged once. If the balance cannot cover the whole call it is rejected with 402 and nothing is served — a batch is never half-billed.
Entries that then fail are reversed before the response is sent, so a batch where 3 of 10 addresses return an error costs 7 lookups. The X-Credit-Debit-MicroUsd header on the response is the charge after any reversal — the net amount you paid for that call.
# Batch against MAX (use /v1/batch/lite for LITE). Up to 100 IPs per call.
curl -X POST https://api.lookip.io/v1/batch/max \
-H "Authorization: Bearer LOOKIP_KEY" \
-H "Content-Type: application/json" \
-d '{ "ips": ["8.8.8.8", "1.1.1.1", "2606:4700:4700::1111"] }'The response is an object keyed by IP, not an array. A per-IP failure does not fail the call:
{
"8.8.8.8": { "ip": "8.8.8.8", "location": { }, "network": { }, "lastUpdated": "…" },
"10.0.0.1": { "ip": "10.0.0.1", "bogon": true },
"not-an-ip": { "error": "invalid_ip" },
"1.2.3.4": { "error": "upstream_error" }
}ASN directory
Public, unauthenticated, and free. q matches name, domain, or number; country takes an ISO-3166 alpha-2 code; limit is 1–100 (default 50). The search form returns { items: [...] }.
# Public — no API key, no credit debited.
curl https://api.lookip.io/v1/asn/15169
curl "https://api.lookip.io/v1/asn?q=google&country=US&limit=50"prefixCount — IPv4 /24 blocks attributed to this AS by a sampled sweep of routable IPv4 space: one address per /24 is probed, and the blocks that answer are counted. It is a size proxy — sampled and lossy — and it is not a count of announced BGP prefixes. On large networks the two differ by an order of magnitude, so do not read it as a route count.
Response Shape
The JSON is stable per kind. LITE returns location + network; MAX adds threats, flags, mobile, and richer location/network fields. Every field except ip is optional — treat missing and null identically.
{
"ip": "8.8.8.8",
"location": {
"country": "United States",
"countryCode": "US",
"continent": "North America",
"continentCode": "NA"
},
"network": {
"asn": "AS15169",
"organization": "Google LLC",
"domain": "google.com"
},
"lastUpdated": "2026-08-14T09:45:16.269Z",
"datasetUpdatedAt": "2026-08-13T13:37:50.000Z"
}{
"ip": "8.8.8.8",
"hostname": "dns.google",
"location": {
"city": "Mountain View",
"region": "California",
"regionCode": "CA",
"country": "United States",
"countryCode": "US",
"continent": "North America",
"continentCode": "NA",
"latitude": 37.4056,
"longitude": -122.0775,
"timezone": "America/Los_Angeles",
"postalCode": "94043",
"dmaCode": "807",
"geonameId": "5375480",
"accuracyRadiusKm": 50,
"geoUpdatedAt": "2026-01-04"
},
"network": {
"asn": "AS15169",
"organization": "Google LLC",
"domain": "google.com",
"type": "hosting",
"asnUpdatedAt": "2021-05-01"
},
"threats": {
"service": "BestProxy",
"lastSeen": "2026-05-10",
"recentActivityPct": 85,
"isProxy": false,
"isRelay": false,
"isTor": false,
"isVpn": false,
"isResidentialProxy": false
},
"flags": {
"isAnonymous": false,
"isAnycast": true,
"isHosting": true,
"isMobile": false,
"isSatellite": false,
"isResidentialProxy": false
},
"lastUpdated": "2026-08-14T09:45:16.269Z"
}The service, lastSeen and recentActivityPct keys are shown filled in so you can see their shape. They appear only when the address is a detected anonymizer — 8.8.8.8 itself returns threats with the booleans and nothing else.
A missing boolean means we hold no data on it — never that the answer is no. We never turn “no signal” into a false, because your code cannot tell that apart from a clean address. Anything we work out ourselves rather than read from privacy data is named in derivedFields, an array of dotted paths such as ["threats.isVpn"]. The key is absent when every value came straight from the data, which is the usual case, so the rule is exact: a field present and not listed there came from upstream, not from us. Most listed fields are inferred from the network’s registered name and are best-effort; flags.isAnonymous is the one exception, deduced from booleans we did receive, so it is as good as its inputs.
{
"ip": "10.0.0.1",
"bogon": true
}Reserved and non-routable addresses short-circuit with bogon: true and no other fields — the private ranges, loopback, link-local, CGNAT (100.64/10), the TEST-NET blocks, multicast, 240/4, and their IPv6 equivalents. Branch on it before reading location or network. These cost nothing, on the single-lookup endpoints and inside a batch alike — a batch is debited only for the addresses it actually resolves.
Credit Headers
Every response that debited credit carries exactly three headers, so your client can track spend without a second round-trip:
X-Lookup-Kind: MAX
X-Credit-Debit-MicroUsd: 1300
X-Credit-Balance-MicroUsd: 4998700X-Lookup-Kind—LITEorMAX.X-Credit-Debit-MicroUsd— what this call cost, in µUSD.X-Credit-Balance-MicroUsd— what is left after the debit, in µUSD.
Both amounts are decimal strings holding integers that can exceed 253 — parse them with BigInt, int64, or a decimal type. Never with a float.
Bogon responses and every error response omit all three, because nothing was debited.
Optional Context
Both POST /v1/lookup/lite and POST /v1/lookup/max accept an optional context object alongside the IP. These fields let you tag a request with surrounding session information so you can slice your usage logs by user, campaign, or workflow step inside the dashboard.
Context never leaves Lookip. It is stored only against your account's request log and is never forwarded to any third party.
curl -X POST https://api.lookip.io/v1/lookup/max \
-H "Authorization: Bearer LOOKIP_KEY" \
-H "Content-Type: application/json" \
-d '{
"ip": "8.8.8.8",
"context": {
"userAgent": "Mozilla/5.0 …",
"email": "[email protected]",
"username": "alice",
"note": "checkout #4821",
"tags": ["signup", "trial"]
}
}'| Field | Limit | Purpose |
|---|---|---|
userAgent | 2000 chars | Originating UA string of the end user. |
email | 320 chars | End-user email for log correlation. |
username | 120 chars | Your internal username / handle. |
firstName / lastName | 120 chars | Personal name fields, if you collect them. |
phone | 40 chars | E.164 phone number. |
address, city, region, country, postal | 500 / 120 / 120 / 2 / 20 | Claimed billing/shipping address — useful for fraud checks against IP geo. |
note | — | Free-form short string (e.g. order id, ticket). |
tags | string[] | Array of labels for filtering the dashboard. |
extra | scalars only | Open object for anything else. Values must be string / number / boolean / null. |
Oversized or wrongly-typed context is rejected with 400 invalid_request — validate lengths before sending.
Errors
Errors are returned as JSON with an error.code and a human-readable error.message. The HTTP status reflects the category.
| Code | HTTP | Retry | Meaning |
|---|---|---|---|
invalid_request | 400 | No | Malformed body, invalid context, bad batch array, or an unparseable ASN. |
invalid_ip | 400 | No | The supplied IP is not a valid IPv4 or IPv6 address. |
unauthorized | 401 | No | Missing, invalid, or revoked API key. |
insufficient_credit | 402 | No | Balance too low to serve the call. Nothing was debited and nothing was served — top up and retry. |
insufficient_credit_for_max | 402 | No | The balance cannot fund a MAX lookup: the signup grant is spendable on LITE only. Top up to use MAX — nothing was debited and nothing was served. |
not_found | 404 | No | On the ASN endpoints, no record for that ASN. On a MAX lookup, our upstream provider holds no record for the address — the charge is reversed. |
rate_limited | 429 | Yes | Per-key cap of 50 requests/second exceeded, or our upstream provider rate-limited a MAX lookup. No credit spent either way. |
lite_db_unavailable | 503 | Yes | LITE dataset still loading (cold start, under 30s). No credit spent. |
service_busy | 503 | Yes | Nothing was charged. Our database was briefly unable to start a transaction — the wallet debit is that transaction, so it never happened. The response carries Retry-After; the same request a moment later succeeds. |
auth_failed | 502 | Yes | Not your key. A MAX lookup could not authenticate to our upstream data provider — our credential, not yours. Keep your key, back off and retry. Nothing is charged. |
upstream_error | 502 | Yes | A MAX enrichment failed. Transient — back off and retry. The status mirrors the upstream response when it sent one, so any 4xx or 5xx can carry this code. The charge for the failed lookup is reversed automatically. |
internal_error | 500 | Yes | Unhandled server fault on our side. |
insufficient_credit is the only error that carries a body beyond the standard shape — it tells you exactly how short you are:
{
"error": {
"code": "insufficient_credit",
"message": "Your credit balance is too low to serve this request."
},
"credit": {
"balanceMicroUsd": "420",
"needMicroUsd": "1300",
"balanceCents": 0,
"needCents": 0,
"kind": "MAX"
}
}The *MicroUsd fields are exact strings; the *Cents fields are a rounded convenience view and must not be used for reconciliation.
Rate Limits
- 50 requests/second per API key, shared across every authenticated endpoint and both kinds. Over the cap returns
rate_limitedand spends no credit. - 100 requests/second per calling address, across all of
/v1including the public ASN endpoints. It applies before your key is checked, so it also covers requests that carry no key. It is deliberately above the per-key cap and is never what stops one key from reaching 50/second. Samerate_limitedresponse, same 429, no credit spent. - Fixed one-second window. A burst that straddles a second boundary can be rejected even below the average rate — retry with jittered backoff, never a tight loop.
- Batch is the throughput lever. One batch call counts as a single request against the rate limit while debiting credit for each distinct resolvable IP inside it.
- LITE includes a recurring free allowance. 100,000 LITE lookups per rolling 30 days, consulted before your balance; a batch the remaining allowance cannot cover falls through to credit in full, never half-free. Past the allowance, and for every MAX query, your only volume ceiling is your credit balance.
Keyless CLI
lookip.io — not api.lookip.io — answers command-line clients with flat JSON instead of the marketing page. No key, no credit, metered by a per-day allowance on the calling address. Never send your API key here.
# No key. The caller's own address, or any address.
curl https://lookip.io
curl https://lookip.io/1.1.1.1The body is the flat curl shape, not the nested one above:
{
"ip": "1.1.1.1",
"hostname": "one.one.one.one",
"city": "Sydney",
"region": "New South Wales",
"country": "AU",
"loc": "-33.8688,151.2093",
"postal": "2000",
"timezone": "Australia/Sydney",
"org": "AS13335 Cloudflare, Inc.",
"tier": "max"
}It can answer with less than you asked for — once the day's allowance is spent the record falls back to country and network only. Two fields say so, and a script must read one of them: tier (max or lite, always present) and degraded, present only when something was withheld.
{
"ip": "1.1.1.1",
"country": "AU",
"org": "AS13335 Cloudflare, Inc.",
"tier": "lite",
"degraded": {
"reason": "daily_limit_reached",
"message": "Daily lookup allowance spent. …",
"resetAt": "2026-08-15T00:00:00.000Z"
}
}Branch on degraded.reason, never on degraded.message. Branch on degraded rather than on tier alone: a private range is a complete lite answer and must not be retried.
daily_limit_reached— 200. The reduced record is real, so it keeps a success status.resetAtsays when the allowance refills; retrying before then returns the same answer.lite_db_unavailable— 503 withRetry-After. The local dataset is not mounted on that server and the body carries the address and nothing else. Our fault, and worth retrying in seconds.unmetered— 200. We could not identify the caller, so nothing was spent. Reduced record, worth retrying shortly.
The same signal rides on the response headers, for a client that would rather not parse the body:
X-Lookip-Tier: lite
X-Lookip-Degraded: daily_limit_reached
X-Lookip-Daily-Limit: 50
X-Lookip-Daily-Remaining: 0
X-Lookip-Daily-Reset: 2026-08-15T00:00:00.000ZX-Lookip-Degraded is absent on a complete answer, and the three daily-counter headers are absent when there was nobody to meter — 0 of 0 would read as “you spent it all”.
SDKs & Examples
The API is plain HTTP + JSON, so any client works. Here are minimal examples:
# MAX — threat signals + city-level geo
curl -H "Authorization: Bearer LOOKIP_KEY" \
https://api.lookip.io/v1/lookup/max/8.8.8.8// Use /lite when you only need geo + ASN, /max for threat signals.
const r = await fetch("https://api.lookip.io/v1/lookup/max/8.8.8.8", {
headers: { Authorization: `Bearer ${process.env.LOOKIP_KEY}` },
});
if (r.status === 402) throw new Error("Out of credit — top up in the dashboard.");
if (!r.ok) throw new Error(`lookup failed: ${r.status}`);
// Amounts exceed 2^53 over a lifetime — parse as BigInt, never as a float.
const balance = BigInt(r.headers.get("X-Credit-Balance-MicroUsd") ?? "0");
const data = await r.json();
if (data.bogon) return null;
console.log(data.location?.country, data.threats?.isVpn, balance);import os, requests
# Pick the lookup kind explicitly via the URL path.
r = requests.get(
"https://api.lookip.io/v1/lookup/max/8.8.8.8",
headers={"Authorization": f"Bearer {os.environ['LOOKIP_KEY']}"},
timeout=10,
)
r.raise_for_status()
balance = int(r.headers.get("X-Credit-Balance-MicroUsd", "0"))
print(r.json(), balance)Need a typed client? The response shape per kind is stable — copy it into your codebase as a Zod schema or TypeScript interface, or hand the spec above to an AI assistant with the copy button at the top of this page.
For a generator that reads OpenAPI, the machine-readable description of everything on this page is at https://api.lookip.io/openapi.json — OpenAPI 3.1, built from the same source as this page, so the two cannot disagree about a route or an error code.