THUGS(red) Wardrive
Sign in Join

Do it yourself via API

If your rig is homemade, or your dumps come out of something we have never heard of, you do not have to wait for a parser. Get a token, POST JSON, and your networks are on the live map seconds later. This guide walks the whole path — first call, a real client with retries, reading the archive back — and the API reference is the page to keep open beside it.

Which path do you want?

Ingest API
Live, no moderation queue, needs a token. For rigs and scripts that produce observations continuously.
File upload
Moderated, no token, a whole drive at a time. For files a parser already understands — the uploader.

A one-off conversion of an old dump is easier as a file. Anything that runs repeatedly belongs on the API. There is no HTTP endpoint for file uploads on purpose: a queue that no human ever looks at is not a moderation queue.

Step 1: get a token

Sign in and create one on the API tokens page, one per device, labelled so you know which box to re-flash later. It is shown once — stored hashed, unrecoverable — so put it somewhere your script can read it before you close the tab:

export WARDRIVE_TOKEN=wdrv_your_token_here

An environment variable, a .env that is not in your repository, a file with mode 600 — anything but a literal in the source you are about to push. Whatever the token posts is attributed to your account, so treat losing it as losing your contributions' good name. Revoking is instant and free; do it the moment a rig leaves your possession.

Step 2: prove the plumbing works

Before writing a client, send one observation by hand. Use coordinates you can recognise, because you are about to go and look at them on the map:

curl -i -X POST https://wardrive.thugs.red/api/v1/ingest \
  -H "Authorization: Bearer $WARDRIVE_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"observations":[
        {"bssid":"aa:bb:cc:dd:ee:ff","ssid":"HelloWorld","enc":"WPA2",
         "rssi":-55,"lat":55.6761,"lon":12.5683,"ch":6}
      ]}'
HTTP/1.1 202 Accepted
{"accepted":1,"rejected":0,"errors":[]}

That is the whole contract. If instead you got:

401 unauthenticated
The header never arrived. Check the shell actually expanded $WARDRIVE_TOKEN
401 invalid_token
Wrong, revoked, expired — or your account is not active
400 invalid_json
Usually shell quoting. Put the body in a file and use --data-binary @body.json
422
The request was fine, the record was not — read errors
503 ingest_disabled
An admin has switched live ingest off. Not your bug

Then find it: /networks/aa:bb:cc:dd:ee:ff. Seeing your own test row on the site is worth more than any amount of reading, and it tells you the coordinates went in the way round you thought.

Step 3: the smallest useful client

Anything that can speak HTTP will do. A shell loop over a file of readings, for instance — mostly to make the point that this needs no framework:

#!/usr/bin/env bash
# Reads lines of: bssid,ssid,rssi,lat,lon  and posts them in one batch.
set -euo pipefail

BODY=$(jq -Rn '[inputs | split(",") | {
          bssid: .[0], ssid: .[1], rssi: (.[2]|tonumber),
          lat:   (.[3]|tonumber), lon: (.[4]|tonumber)
        }] | {observations: .}' < readings.csv)

curl -sS -X POST https://wardrive.thugs.red/api/v1/ingest \
  -H "Authorization: Bearer ${WARDRIVE_TOKEN}" \
  -H "Content-Type: application/json" \
  --data-binary "${BODY}" | jq .

Step 4: a client you can leave running

A rig that drives for three hours will meet a dropped connection, a rate limit and a lorry between it and the sky. Four rules make the difference between a client that copes and one you have to babysit:

  1. Buffer, and only clear the buffer on a 2xx. A failed POST must cost you nothing.
  2. Batch up to a few hundred. The ceiling is 500 observations or 1 MB; one big request beats five hundred small ones for both of us.
  3. Back off on 429, keep buffering on 503. The first has a Retry-After; the second means come back later.
  4. Never retry a batch the server already answered. There is no idempotency key, so a replayed batch counts its sightings twice.
#!/usr/bin/env python3
"""Post buffered observations to wardrive.thugs.red, politely."""

import os
import time

import requests

BASE       = "https://wardrive.thugs.red"
TOKEN      = os.environ["WARDRIVE_TOKEN"]
MAX_BATCH  = 400          # under the 500 ceiling, with room for growth
TIMEOUT    = 30

session = requests.Session()
session.headers.update({
    "Authorization": f"Bearer {TOKEN}",
    "User-Agent":    "my-rig/1.0 (+https://example.org)",
})


def post_batch(batch):
    """Send one batch. Returns True if the server has it, False to keep it."""
    for attempt in range(5):
        try:
            r = session.post(f"{BASE}/api/v1/ingest",
                             json={"observations": batch}, timeout=TIMEOUT)
        except requests.RequestException as exc:
            # No response at all: safe to retry, the server may not have it.
            print("network error:", exc)
            time.sleep(2 ** attempt)
            continue

        if r.status_code == 429:
            wait = int(r.headers.get("Retry-After", 60))
            print(f"rate limited, sleeping {wait}s")
            time.sleep(wait)
            continue

        if r.status_code == 503:
            # Ingest disabled or maintenance. Keep the batch, try much later.
            print("ingest unavailable:", r.text[:120])
            return False

        if r.status_code == 413:
            # Too big. Split and let the caller re-drive the halves.
            half = len(batch) // 2
            if half == 0:
                return True                     # one unsendable record: drop it
            return post_batch(batch[:half]) and post_batch(batch[half:])

        if r.status_code >= 500:
            time.sleep(2 ** attempt)
            continue

        body = r.json()
        if r.status_code in (200, 202):
            print(f"accepted {body['accepted']}, rejected {body['rejected']}")
            for err in body.get("errors", []):
                print("  ", batch[err["index"]].get("bssid"), "->", err["reason"])
            return True

        # 4xx that is our fault: log it and drop the batch, or it loops forever.
        print("giving up on batch:", r.status_code, body)
        return True

    return False        # out of attempts; keep the buffer for the next round


def main():
    buffer = []
    for observation in read_from_your_hardware():        # your code here
        buffer.append(observation)
        if len(buffer) >= MAX_BATCH:
            if post_batch(buffer):
                buffer = []
    if buffer:
        post_batch(buffer)


if __name__ == "__main__":
    main()

Note what post_batch returns: whether the server has the data, not whether every record was liked. A 202 with rejections is a success — the rejected rows were unusable and re-sending them will not help.

Step 5: what a record needs

Three fields matter: a BSSID and a latitude and longitude. Everything else is optional and every name has aliases, so you rarely need a translation layer:

{"bssid": "aa:bb:cc:dd:ee:ff",   // or mac / address; separators optional
 "lat":   55.6761,                // or latitude
 "lon":   12.5683,                // or lng / long / longitude
 "ssid":  "MyNet",                // or name; 32 bytes, null if hidden
 "enc":   "[WPA2-PSK-CCMP][ESS]", // free text, normalised to wpa2
 "rssi":  -55,                    // or signal / dbm / level
 "ch":    6,                      // or channel / chan
 "ts":    1754000000}             // unix s or ms, or omit it entirely

Two rules bite people writing their first client. A record with no usable fix is refused, including 0,0 — check gps.location.isValid(), or whatever your hardware's equivalent is, before you build the record rather than shipping it and reading the rejection. And a ts from a dead clock is ignored rather than trusted: anything before 2000 or more than a day ahead falls back to the server's time, so an ESP32 with no RTC should simply leave the field out. The field table has the rest.

Converting a format nothing here reads

Same idea, minus the hardware: parse whatever you have, emit those records. A Kismet .kismet SQLite log, an old NetStumbler file, a dump from a scanner someone wrote in 2009 — if you can get a BSSID and a position out of it, it can go in.

import itertools

def batched(iterable, size):
    it = iter(iterable)
    while chunk := list(itertools.islice(it, size)):
        yield chunk

for batch in batched(my_parser("old-dump.whatever"), 400):
    post_batch(batch)

Sanity-check the first batch by hand before you loop over four years of archive. A sign error in a longitude is invisible in a debugger and extremely visible on a map, and a batch of 20 000 wrong points is a moderator's problem rather than a script's.

Worth saying: if the format is one other people also have, post the parser in the forum. Three of this site's parsers started as somebody's throwaway script.

Reading the archive back

The read endpoints need no token at all — a bounding box in, JSON out. Handy for a dashboard, a coverage check, or working out where not to bother driving again:

# How many open networks are in this box?
curl -s "https://wardrive.thugs.red/api/v1/networks\
?south=55.60&west=12.45&north=55.75&east=12.65&encryption=open" | jq '.count'

# Encryption breakdown, cheaply
curl -s "https://wardrive.thugs.red/api/v1/networks\
?south=55.60&west=12.45&north=55.75&east=12.65" \
  | jq -r '.networks[].encryption' | sort | uniq -c | sort -rn

Responses cap at 5 000 networks, strongest signal first, so if count comes back at exactly 5 000 you are looking at a truncated answer — walk a grid of smaller boxes rather than asking for a bigger one. /api/v1/tracks gives the driving paths behind the data in the same way, from approved uploads.

Two things that will trip a script: there are no CORS headers, so this cannot be called from browser JavaScript on another origin, and an http:// URL is redirected to https:// without its query string — write the s.

Etiquette

Full endpoint, field, status-code and error reference: the API page. Prebuilt rig instead of a script: the ESP32 build posts to exactly this endpoint.