Batch IP Geolocation with Python
Enrich a list of IPs against POST /v1/lookup in batches of 100, with row association, checkpointed output, and 429 backoff — called directly over HTTP, since the official Python SDK doesn't cover this endpoint.
Before you start
- A Watch-IP account and a secret lookup key — POST /v1/lookup is server-to-server only; never call it from a browser or ship the key in a client.
- Python 3.9+.
- A local list of IPs to enrich — this guide reads them from a CSV with one IP per line.
1. Why this uses direct HTTP instead of the SDK
watch-ip-sdk, the official Python package, only implements GET /v1/geo, the browser-facing visitor endpoint — it has no method for POST /v1/lookup. This guide calls the API directly with urllib from the standard library, matching the SDK's own zero-dependency approach rather than adding a new one just for this.
2. Read and validate the input list
Keep each IP's original row position so a result can be matched back to whatever record it came from — the API returns results in the same order it received the IPs, which only helps if your own list stays in that order too.
Python
import csv
with open("ips.csv", newline="") as f:
ips = [row[0].strip() for row in csv.reader(f) if row and row[0].strip()]
print(f"Loaded {len(ips)} IPs")3. Chunk into batches of 100
POST /v1/lookup accepts 1–100 IPs per call and validates the whole batch before looking anything up — one malformed address fails the entire chunk, so validate IP syntax locally first if your input list might be dirty.
Python
def chunk(items, size=100):
for i in range(0, len(items), size):
yield items[i : i + size]4. Submit each batch and checkpoint results
Write results as each batch completes rather than holding everything in memory until the end, so a failure partway through a long list doesn't lose the batches that already succeeded. found: false means the IP is syntactically valid but has no entry in the database — a normal result, not an error.
Python
import json
import time
import urllib.error
import urllib.request
API_URL = "https://api.watch-ip.com/v1/lookup"
SECRET_KEY = "YOUR_SECRET_KEY"
def lookup_batch(ips):
request = urllib.request.Request(
API_URL,
data=json.dumps({"ips": ips}).encode(),
headers={
"Authorization": f"Bearer {SECRET_KEY}",
"Content-Type": "application/json",
},
)
with urllib.request.urlopen(request) as response:
return json.load(response)["results"]
with open("results.jsonl", "a") as out:
for batch in chunk(ips):
try:
results = lookup_batch(batch)
except urllib.error.HTTPError as err:
if err.code == 429:
time.sleep(60) # back off for the rest of the current one-minute window
results = lookup_batch(batch)
else:
raise
for result in results:
out.write(json.dumps(result) + "\n")5. Resuming a partial job
Because results are appended one batch at a time, restarting after a crash only needs to skip the IPs already written to results.jsonl rather than resubmitting the whole list — resubmitting a duplicate IP is billed again, since each input in a batch is metered individually rather than deduplicated.
Python
done = set()
with open("results.jsonl") as f:
for line in f:
done.add(json.loads(line)["ip"])
remaining = [ip for ip in ips if ip not in done]Errors and edge cases
A 400 means the batch had more than 100 IPs, an empty list, or a malformed address somewhere in it — the whole chunk fails, not just the bad entry. A 403 means a publishable key was used instead of a secret key. Mixed IPv4 and IPv6 addresses in the same batch are fine; each is looked up independently, with the same nullable fields either way.
Next step
See the bulk IP lookup reference for the exact response schema, and errors and rate limits for the full status-code list this endpoint can return.