THUGS(red) Wardrive
Sign in Join

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

MethodPathAuthWhat 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.

ParameterRequiredNotes
southyesLatitude, −90…90
northyesLatitude, −90…90
westyesLongitude, −180…180
eastyesLongitude, −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.

FieldAliasesRequiredNotes
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

StatusCodeMeaning
400invalid_boundsA bounding-box parameter is missing or not a coordinate
400empty_bodyNo request body
400invalid_jsonBody is not JSON, or not a JSON object/array
400no_observationsValid JSON with nothing observation-shaped in it
401unauthenticatedNo Authorization: Bearer header
401invalid_tokenUnknown, revoked, expired — or the owner is no longer active
413batch_too_largeMore than 500 observations
413body_too_largeBody over 1 MB
422Nothing in the batch could be stored; see errors
429rate_limitedOver 120 requests in 60 seconds; see Retry-After
503ingest_disabledAn admin has switched live ingest off
503Maintenance 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

Something missing, or an endpoint behaving differently from this page? Say so in the forum — the documentation being wrong is a bug.