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:
- Buffer, and only clear the buffer on a 2xx. A failed POST must cost you nothing.
- 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.
- Back off on 429, keep buffering on 503. The first has a
Retry-After; the second means come back later. - 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
- One token per device, revoked when the device is retired or lost.
- Batch. 120 requests per token per minute is the ceiling, and you should not be near it — a rig scanning every second and posting every 30 sends two requests a minute.
- Send a
User-Agentthat says what you are. It is how a puzzling pattern of traffic gets a question in the forum instead of a revoked token. - Post real observations. Test data belongs in one recognisable BSSID you can point at, not a synthetic city.
- If something looks like a bug in the API, it probably is; say so rather than working around it silently.
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.