API
Everything on this site that is worth reading by a machine is reachable over plain HTTP and JSON. Reading needs no account. Posting observations from a rig needs a bearer token, which you can issue and revoke yourself.
Overview
- Base URL
https://wardrive.thugs.red- Format
- JSON in, JSON out, UTF-8
- Versioning
- Path-prefixed:
/api/v1/… - Read auth
- None
- Write auth
Authorization: Bearer wdrv_…- Rate limit
- 120 ingest requests per token per minute
- Batch size
- 500 observations, 1 MB of body
- Times
- UTC everywhere, no exceptions
Every response is application/json; charset=utf-8. Errors carry a
stable machine-readable error code and a human
message:
{"error":"invalid_bounds","message":"…"}
Match on error, not on message — the wording may be
improved, the codes will not change under you.
Endpoints
| Method | Path | Auth | What it does |
|---|---|---|---|
GET |
/api/v1/networks |
none | Access points inside a bounding box |
GET |
/api/v1/tracks |
none | Driving paths inside a bounding box |
POST |
/api/v1/ingest |
bearer token | Post live observations straight to the map |
GET |
/feed.xml |
none | RSS of recently discovered networks |
GET |
/sitemap.xml |
none | Sitemap index — every public page and network |
Anything else is deliberately absent. A method that is not listed for a path
returns 405 with an Allow header rather than being
quietly accepted, so POST /api/v1/networks is an error and not a
no-op.
GET /api/v1/networks
Access points whose best-known position falls inside a bounding box, strongest signal first. This is the call the map itself makes.
| Parameter | Required | Notes |
|---|---|---|
south | yes | Latitude, −90…90 |
north | yes | Latitude, −90…90 |
west | yes | Longitude, −180…180 |
east | yes | Longitude, −180…180 |
encryption |
no | One of open, wep, wpa, wpa2, wpa3, wpa2_wpa3, unknown |
Inverted bounds are normalised rather than rejected, because a map drag can
legitimately hand back south > north. A missing or unparseable
coordinate is 400 invalid_bounds.
curl -s "https://wardrive.thugs.red/api/v1/networks?south=55.60&west=12.45&north=55.75&east=12.65&encryption=open"
{
"count": 2,
"networks": [
{
"public_id": "3f2a…",
"bssid": "aa:bb:cc:dd:ee:ff",
"ssid": "CoffeeGuest",
"encryption": "open",
"channel": 6,
"best_lat": 55.6761,
"best_lon": 12.5683,
"best_signal_dbm": -48,
"last_seen_at": "2026-08-01 19:04:11"
}
]
}
- Cap
- 5 000 networks per response, strongest signal first
- Caching
Cache-Control: private, max-age=30- Detail page
/networks/{bssid}for the full history
The cap is not paginated on purpose: if you hit it, the answer is a smaller box, not a deeper page — a bounding box tight enough to draw is a bounding box small enough to return.
GET /api/v1/tracks
The driving paths behind the data: one entry per upload, each an ordered list
of [lat, lon] points in observation order. Same four bounding-box
parameters as above.
{
"count": 1,
"tracks": [
{"upload_id": "9c1e…",
"points": [[55.6761, 12.5683], [55.6764, 12.5691], [55.6772, 12.5702]]}
]
}
Only approved uploads produce tracks, and a path of a single
point is not returned — one fix is a dot, not a drive. Live-ingest
observations have no upload behind them, so they appear in
/api/v1/networks but never here.
POST /api/v1/ingest
Post what your rig hears while it is still driving. Records land in the map immediately — there is no moderation queue on this path, which is the whole point of it, and also why it is token-authenticated, rate-limited and attributed to the token's owner.
curl -X POST https://wardrive.thugs.red/api/v1/ingest \
-H "Authorization: Bearer wdrv_yourtokenhere" \
-H "Content-Type: application/json" \
-d '{"observations":[
{"bssid":"aa:bb:cc:dd:ee:ff","ssid":"MyNet","enc":"WPA2",
"rssi":-55,"lat":55.6761,"lon":12.5683,"ch":6,"ts":1754000000}
]}'
Body shapes that all work
A microcontroller should not have to build a particular envelope, so four shapes are accepted:
{"observations": [ … ]} // canonical
{"networks": [ … ]} // alias
{"data": [ … ]} // alias
[ … ] // a bare array
{"bssid": "…", "lat": …} // a single observation, no wrapper
Response
HTTP/1.1 202 Accepted
{"accepted":28,"rejected":2,
"errors":[{"index":5,"reason":"unusable_gps_fix"},
{"index":9,"reason":"invalid_bssid"}]}
202- At least one observation stored
422- Well-formed request, nothing usable in it
errors- First 20 rejections, by request index
A partial success is still a 202: read rejected
rather than the status alone. index is the position in the array
you sent, so a rig can log exactly which reading the server would not take.
Rejections are reported per record instead of failing the batch, because
throwing away 499 good observations over one bad row would be the wrong
trade for someone debugging in a moving car.
Retrying
Keep the batch buffered until you see a 2xx, then clear it. On
429 honour the Retry-After header; on
413 halve the batch; on 503 keep buffering.
There is no idempotency key. Posting the same reading twice does not create a second access point — observations are folded into the BSSID they belong to — but it does record a second sighting, so a batch you retry after the server had in fact stored it will inflate that network's sighting count. Nothing else is distorted: the position stays the strongest-signal fix and the first/last-seen window is unchanged. Retry on a timeout or a 5xx; do not replay a batch the server already answered.
Observation fields
Field names are matched case-insensitively and every listed alias works, so a WiGLE-shaped row and an ESP32 sketch can both post without a translation layer. Unknown keys are ignored rather than rejected.
| Field | Aliases | Required | Notes |
|---|---|---|---|
bssid |
mac, address |
yes | Any separator or none: AA:BB:…, aa-bb-…, aabbccddeeff |
lat |
latitude |
yes | Decimal degrees |
lon |
lng, long, longitude |
yes | Decimal degrees |
ssid |
name |
no | Truncated at 32 bytes; control characters stripped; empty becomes null |
enc |
encryption, auth, authmode, security |
no | Free text, normalised — [WPA2-PSK-CCMP][ESS] becomes wpa2 |
rssi |
signal, dbm, level |
no | dBm. A positive number is negated; anything outside −120…−10 is dropped |
ch |
channel, chan |
no | Integer channel number |
freq |
frequency, frequency_mhz |
no | MHz |
alt |
altitude |
no | Metres |
ts |
time, timestamp, observed_at |
no | Unix seconds or milliseconds, or a parseable date. Omit it and the server's clock is used |
Two rules are worth knowing before you debug a rejection.
A record with no usable fix is refused, including the
0,0 that a receiver without satellite lock reports — that
artefact would otherwise put a fake access point in the Gulf of Guinea.
Timestamps outside a sane window are ignored rather than
trusted: anything before 2000 or more than a day in the future falls back to
the server's time, which is what a rig with a dead RTC actually needs.
Error codes
| Status | Code | Meaning |
|---|---|---|
400 | invalid_bounds | A bounding-box parameter is missing or not a coordinate |
400 | empty_body | No request body |
400 | invalid_json | Body is not JSON, or not a JSON object/array |
400 | no_observations | Valid JSON with nothing observation-shaped in it |
401 | unauthenticated | No Authorization: Bearer header |
401 | invalid_token | Unknown, revoked, expired — or the owner is no longer active |
413 | batch_too_large | More than 500 observations |
413 | body_too_large | Body over 1 MB |
422 | — | Nothing in the batch could be stored; see errors |
429 | rate_limited | Over 120 requests in 60 seconds; see Retry-After |
503 | ingest_disabled | An admin has switched live ingest off |
503 | — | Maintenance mode; the whole site is answering this |
The four token failures share one response by design. Whether a token is
unknown, revoked, expired or belongs to a suspended account, the answer is
the same 401 invalid_token, so a device probing tokens learns
nothing from the difference.
Per-record rejection reasons
invalid_bssid- Missing, or not twelve hex digits once separators are stripped
missing_coordinates- No latitude or no longitude in the record
unusable_gps_fix- Coordinates present but unusable — almost always
0,0, i.e. no lock invalid_record- Shape not recognised as an observation
storage_error- The record was fine and the write failed. Retry it
Tokens
A token is a credential for your account's contributions: whatever it posts is attributed to you. Tokens are stored hashed, shown exactly once at creation, and cannot be recovered — if a token is lost, revoke it and issue another.
- Prefix
wdrv_, so one is recognisable in a log- Header
Authorization: Bearer wdrv_…- Limit
- 20 active tokens per account
- Revocation
- Immediate — the next request from that device fails
Issue one per device rather than sharing one, so that losing a rig costs you that rig and not your whole fleet. Each token records its last use and a request count, which is the cheapest way to notice a device that has stopped reporting — or one that is reporting when it should not be.
Join or sign in, then issue a token on the API tokens page. Reading needs no account — only posting does.
Feeds, sitemaps and file uploads
/feed.xml- RSS 2.0 — recently discovered networks
/sitemap.xml- Sitemap index
/sitemap-pages.xml- Static pages and public forum threads
/sitemap-networks-{n}.xml- Network deep links, 5 000 per page
Feeds and sitemaps are built from the anonymous visibility scope, so a hidden access point cannot leak into a crawler's index or somebody's feed reader, where it would outlive the decision to hide it.
There is no API for file uploads. A Kismet netxml, WiGLE CSV or ESP32 JSON dump goes through the uploader, which queues it for moderation before it is parsed. If you want a script to feed the map without human review, that is what ingest is for.
Recipes
Count open networks in a box
curl -s "https://wardrive.thugs.red/api/v1/networks?south=55.6&west=12.4&north=55.8&east=12.7&encryption=open" \
| jq '.count'
Post a batch from Python
import os, requests
BASE = "https://wardrive.thugs.red"
TOKEN = os.environ["WARDRIVE_TOKEN"] # never hard-code it
batch = [{
"bssid": "aa:bb:cc:dd:ee:ff",
"ssid": "MyNet",
"enc": "WPA2",
"rssi": -55,
"lat": 55.6761,
"lon": 12.5683,
}]
r = requests.post(
f"{BASE}/api/v1/ingest",
headers={"Authorization": f"Bearer {TOKEN}"},
json={"observations": batch},
timeout=20,
)
r.raise_for_status()
result = r.json()
print(result["accepted"], "accepted,", result["rejected"], "rejected")
for err in result["errors"]:
print(" rejected", batch[err["index"]]["bssid"], "-", err["reason"])
Convert a WiGLE CSV and post it
import csv, itertools, os, requests
BASE, TOKEN = "https://wardrive.thugs.red", os.environ["WARDRIVE_TOKEN"]
def rows(path):
with open(path, newline="", encoding="utf-8", errors="replace") as fh:
next(fh) # the WigleWifi pre-header line
for row in csv.DictReader(fh):
if row.get("Type", "WIFI") != "WIFI":
continue
yield {
"bssid": row["MAC"],
"ssid": row["SSID"],
"enc": row["AuthMode"],
"rssi": row["RSSI"],
"ch": row["Channel"],
"lat": row["CurrentLatitude"],
"lon": row["CurrentLongitude"],
"ts": row["FirstSeen"],
}
batches = iter(lambda it=iter(rows("WigleWifi_20260805.csv")): list(itertools.islice(it, 400)), [])
for batch in batches:
r = requests.post(f"{BASE}/api/v1/ingest",
headers={"Authorization": f"Bearer {TOKEN}"},
json={"observations": batch}, timeout=30)
print(r.status_code, r.json()["accepted"])
Worked example with error handling and rate-limit backoff: Do it yourself via API.
Gotchas
-
No CORS headers. Browser JavaScript on another origin
cannot call these endpoints; a cross-origin
fetchwill fail on the preflight. Call them from a server, a script or a device. -
HTTPS only, and write
https://yourself. Plain HTTP is answered with a301to the same path — but the query string is not carried across, so anhttp://call to/api/v1/networks?south=…arrives without its bounding box and fails asinvalid_bounds.curlalso drops theAuthorizationheader across a redirect unless you pass--location-trusted. Both problems disappear if the scheme in your config ishttps. -
Read responses depend on who is asking. Hidden access
points are excluded from the query itself, not filtered afterwards, and a
signed-in browser session can therefore legitimately see rows an anonymous
caller does not.
Cache-Controlon both read endpoints isprivatefor that reason — do not put a shared cache in front of them. -
Timestamps are UTC
DATETIMEstrings (2026-08-01 19:04:11), not ISO 8601 with a zone suffix. Parse them as UTC; there is no local time anywhere in the data. - The rate limit counts accepted batches, not records. One request of 500 observations is far cheaper than 500 requests of one, for you and for the server.
-
Ingest can be switched off. An admin can disable live
ingest site-wide; a rig should treat
503as "keep buffering and try later", not as a fatal error.
Something missing, or an endpoint behaving differently from this page? Say so in the forum — the documentation being wrong is a bug.