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 /networks/export.csv none The current search as a spreadsheet
GET /networks/export.json none The same rows as JSON
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, or several comma-separated
any search filter Every filter the network listing accepts also narrows this call

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. Unknown parameters are ignored rather than rejected, so you can pass a whole search URL's query string straight through.

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
100 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, and the same search filters — which here select the drives on which the matching networks were seen, so a filtered path layer draws parts of drives rather than whole ones.

{
  "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.

A response carries at most 500 000 points in total across all tracks. The cap falls on whole uploads rather than on the tail of each path, so a box that reaches it is missing drives, not ends of drives — ask for a smaller box.

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.

Search filters

The filters behind the network listing are ordinary query parameters, and the same vocabulary narrows /api/v1/networks, /api/v1/tracks and the exports. So a search you built in the browser can be pasted into a script: copy the query string off the URL and it means the same thing. Anything unrecognised is ignored, and anything out of range is clamped rather than rejected — a stale bookmark degrades to a wider answer instead of an error.

ParameterValuesNotes
q text Free-text term. Case-insensitive unless case=1
in any, ssid, bssid, vendor Which field q applies to; default any
match contains, starts, ends, exact, wildcard, regex Default contains. % and _ in q are always literal; wildcard uses * and ?; regex is PCRE and is capped at 200 characters
case1Match the SSID's exact casing
vendortextManufacturer name contains
vendor_stateknown, unknownWhether the OUI resolved to a vendor
placetextPostcode, town, kommune or region contains
region, kommunetextExact, case-insensitive
encryption open, wep, wpa, wpa2, wpa3, wpa2_wpa3, unknown Comma-separated, or repeated as encryption[]
band 2.4, 5, 6 GHz, derived from the reported frequency
type infrastructure, ad_hoc, probe, bridge, unknown Comma-separated
channel_min, channel_max0…400Inclusive; given backwards, they swap
signal_min, signal_max−120…0dBm of the strongest sighting
sightings_min, sightings_maxintegerHow many times the network has been seen
date_fieldlast, firstWhich timestamp the date window applies to
from, toYYYY-MM-DDInclusive at both ends
ssid_statenamed, hiddenWhether the network broadcasts a name
gpsyes, noWhether a position was ever recorded
lat, lon, radiusdegrees, kmAll three together; radius is 0.01…500 km
uploaderusername or UUIDWho first contributed the network
state visible, hidden Moderation state. It can only ever narrow what you were already allowed to see, so asking for hidden as an anonymous caller returns nothing

Hidden access points are absent from every one of these responses, and no combination of filters can bring one back — visibility is applied in the SQL itself, ahead of the filters.

GET /networks/export.csv and .json

The current search as a file. Takes every search filter plus sort, dir and cols, and returns exactly the rows the listing would show, in the same order — an export that quietly differs from what was on screen is worse than none.

ParameterValuesNotes
cols ssid, bssid, vendor, encryption, band, channel, frequency, signal, sightings, first_seen, last_seen, postal, kommune, region, position, distance, uploader Comma-separated, in any order — the file uses the canonical order. latitude and longitude are always appended, whatever you ask for: an export of wardrive data without a position is no use to anything that would read it
sort, dirsee the listingSame keys the table headers use

Capped at 25,000 rows. Truncation is never silent: X-Export-Total carries how many rows matched and X-Export-Truncated is 1 when you got fewer, and the JSON body says the same thing in total, returned and truncated. If you need more than the cap, narrow the search — or use the bounding-box call, which is the right tool for bulk reads.

curl -s "https://wardrive.thugs.red/networks/export.csv?q=guest&encryption=open&cols=ssid,bssid,signal,last_seen"
{
  "total": 128,
  "returned": 128,
  "truncated": false,
  "limit": 25000,
  "networks": [
    {"ssid": "Guest", "bssid": "aa:bb:cc:dd:ee:ff", "signal": -62,
     "latitude": "55.6761000", "longitude": "12.5683000"}
  ]
}

Numbers come back as numbers; a field that was never recorded is null in JSON and an empty cell in CSV, never a dash. Coordinates stay strings, because the column is DECIMAL(10,7) and a float would export 55.676099999999995 for a fix that was recorded as 55.6761000.

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

Download every open network a member found in one kommune

curl -s "https://wardrive.thugs.red/networks/export.csv?encryption=open&kommune=K%C3%B8benhavn&uploader=someone&cols=ssid,bssid,vendor,last_seen" \
  -o open-networks.csv

Everything within 500 m of a point, nearest first

curl -s "https://wardrive.thugs.red/networks/export.json?lat=55.6761&lon=12.5683&radius=0.5&cols=ssid,distance" \
  | php -r '$d = json_decode(stream_get_contents(STDIN), true); printf("%d within 500 m\n", $d["total"]);'

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.