WarDrivingMapper
Sign in Sign up

FAQ & uploader docs

You don't need our purpose-built device to contribute. Anything that produces a list of (bssid, ssid, lat, lon, …) tuples can POST to our ingest endpoint. WiGLE exports, custom scripts, hobby firmware — all welcome.

1 — Get an API key

  1. Create an account (or sign in).
  2. Open the Dashboard.
  3. Click New API key. The full key is shown once — copy it now; only its prefix is retained.
  4. If you lose the key, revoke it and create a new one. There's no recovery.

Keys are 32 random bytes (~52 chars in base32). The server stores only a SHA-256 hash. A full DB leak can't be replayed against the API.

2 — Authenticate

Send the key as a Bearer token in the Authorization header:

Authorization: Bearer YOUR_KEY_HERE
Content-Type: application/json

Missing or invalid keys return 401 Unauthorized with {"ok":false,"error":"invalid_key"}.

3 — POST your scans

Endpoint: POST https://wardrivingmapper.com/api/v1/scans

Body shape:

{
  "device_id": "your-device-name",
  "records": [
    {
      "bssid": "AA:BB:CC:DD:EE:FF",
      "ssid":  "MyNetwork",
      "rssi":  -67,
      "ch":    6,
      "enc":   "WPA2",
      "lat":   42.732500,
      "lon":  -84.555500,
      "alt":   245.3,
      "hdop":  0.9,
      "sats":  9,
      "ts":    "2026-05-17T14:23:01Z"
    }
    // … up to 500 records per request
  ]
}

Field reference:

FieldTypeRequiredNotes
device_idstringyesTop-level, alphanumeric / ._-, max 64 chars. Identifies the device or script that uploaded.
records[].bssidstringyesMAC, format AA:BB:CC:DD:EE:FF (six hex pairs, colon-separated, any case).
records[].ssidstringyesNetwork name. May be empty for hidden networks. Max 64 chars after control-byte strip.
records[].rssiintegernoSignal strength in dBm, range -120 … 0.
records[].chintegernoWi-Fi channel (1-196 spans 2.4/5/6 GHz).
records[].encstringnoEncryption label. Free-form, but use Open / WEP / WPA / WPA2 / WPA3 / WPA/2 / WPA2/3 for consistent map colouring.
records[].latfloatrecommendedWGS-84 latitude, -90 … 90. Records without lat or lon are accepted but won't appear on the map (geometry is NULL).
records[].lonfloatrecommendedWGS-84 longitude, -180 … 180.
records[].altfloatnoAltitude in metres.
records[].hdopfloatnoHorizontal DOP from your GPS.
records[].satsintegernoSatellite count.
records[].tsstringnoISO-8601 UTC timestamp: YYYY-MM-DDTHH:MM:SSZ. Anything else is dropped.

Response (success):

{ "ok": true, "accepted": 100, "duplicates": 0, "rejected": 0 }

Duplicates are silently merged — uploading the same scan twice is safe and idempotent. Records that fail validation are counted in rejected rather than failing the whole batch.

4 — One-shot curl example

curl -X POST https://wardrivingmapper.com/api/v1/scans \
  -H "Authorization: Bearer $WDM_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "device_id": "my-laptop",
    "records": [{
      "bssid": "AA:BB:CC:DD:EE:FF",
      "ssid":  "TestNetwork",
      "rssi":  -55,
      "ch":    6,
      "enc":   "WPA2",
      "lat":   42.7325,
      "lon":  -84.5555,
      "ts":    "2026-05-17T14:23:01Z"
    }]
  }'

5 — Uploading from CSV

The ingest endpoint takes JSON, not CSV — but most exports (WiGLE, Kismet, custom) are CSV. The format below is a clean superset that maps 1:1 onto the JSON record fields. Save your data with these columns, then convert with the script below.

Sample CSV (header + 3 rows):

bssid,ssid,rssi,ch,enc,lat,lon,alt,hdop,sats,ts
AA:BB:CC:DD:EE:FF,DeNike,-55,6,WPA2,42.732500,-84.555500,245.3,0.9,9,2026-05-17T14:23:01Z
11:22:33:44:55:66,xfinitywifi,-78,11,Open,42.731900,-84.556300,244.8,1.1,7,2026-05-17T14:23:03Z
DE:AD:BE:EF:CA:FE,<hidden>,-83,36,WPA2/3,42.733000,-84.554500,246.1,1.3,6,2026-05-17T14:23:05Z

Column order must match. Use <hidden> (literal text including the angle brackets) when the SSID is empty.

Python converter (stdlib only — no pip install needed):

#!/usr/bin/env python3
"""Convert a CSV to JSON batches and POST to WarDrivingMapper.
Usage: WDM_KEY=xxx python3 upload.py my-scans.csv"""
import csv, json, os, sys, urllib.request

ENDPOINT  = "https://wardrivingmapper.com/api/v1/scans"
BATCH_SIZE = 500          # server cap
DEVICE_ID  = "csv-upload" # appears as device_id in your dashboard

key = os.environ.get("WDM_KEY")
if not key:
    sys.exit("set WDM_KEY env var to your API key")

def cast(row):
    out = {"bssid": row["bssid"], "ssid": row["ssid"]}
    for k in ("rssi","ch","sats"):
        if row.get(k): out[k] = int(row[k])
    for k in ("lat","lon","alt","hdop"):
        if row.get(k): out[k] = float(row[k])
    for k in ("enc","ts"):
        if row.get(k): out[k] = row[k]
    return out

def post(records):
    body = json.dumps({"device_id": DEVICE_ID, "records": records}).encode()
    req = urllib.request.Request(ENDPOINT, data=body, headers={
        "Authorization": f"Bearer {key}",
        "Content-Type":  "application/json",
    })
    with urllib.request.urlopen(req) as r:
        return json.loads(r.read())

with open(sys.argv[1]) as f:
    batch = []
    sent = accepted = duplicates = rejected = 0
    for row in csv.DictReader(f):
        batch.append(cast(row))
        if len(batch) >= BATCH_SIZE:
            r = post(batch); sent += len(batch); batch = []
            accepted += r["accepted"]; duplicates += r["duplicates"]; rejected += r["rejected"]
    if batch:
        r = post(batch); sent += len(batch)
        accepted += r["accepted"]; duplicates += r["duplicates"]; rejected += r["rejected"]

print(f"sent {sent} records — accepted={accepted} duplicates={duplicates} rejected={rejected}")

A native POST /api/v1/scans.csv endpoint that takes a multipart upload directly is on the roadmap, including a WiGLE-compatible column adapter. For now, this 30-line script is the supported path.

6 — Limits & errors

LimitValueWhat happens
Records per request500400 too_many
Payload bytes256 KB413 too_large
Per-user rate60 req / min429 — retry with backoff
Per-IP rate120 req / minsame

Error shape:

{ "ok": false, "error": "<kind>", "message": "Human-readable explanation." }

Common error kinds: missing_key, invalid_key, bad_json, bad_records, bad_device_id, too_many, too_large.

7 — Privacy & visibility

  • BSSIDs are masked (AA:BB:CC:XX:XX:XX) for non-logged-in viewers on the public map. Logged-in users see full MACs.
  • Your username appears on the pin as the uploader. If you want pseudonymity, sign up under a pseudonym — usernames are public.
  • You're crowd-sourcing. Anything you upload to the public instance becomes part of the global map for everyone. Self-host (Docker compose) if you want a private map for yourself only.
  • Duplicates are silently merged per (user_id, bssid, ssid, observed_at). Re-running uploads is safe.

8 — Hardware (build your own WardriveCFG)

You can build the reference device yourself. Three off-the-shelf parts and four jumper wires for the GPS — plus about five minutes of soldering to attach the battery leads to the board's BAT pads.

Bill of materials

PartNotesLink
ESP32 board Waveshare ESP32-S3-LCD-1.47B — built-in 1.47" display, 8 MB PSRAM, 16 MB flash, on-board charger (BAT+ / BAT- solder pads, no JST connector) Amazon
GPS module UART · 9600 baud · 3.3 V (u-blox NEO-6M / -7M / -8M family all work) Amazon
Battery 1S 3.7 V LiPo with bare leads (~500–2000 mAh). Solder + / - leads to the board's BAT+ / BAT- pads — the 1.47B variant has no on-board connector. Amazon

Wiring

Waveshare ESP32-S3-LCD-1.47B      GPS module
────────────────────────────      ──────────
VBAT (or 3V3) ───────────────►    VCC   power — do NOT use 5V (see note)
GND           ───────────────►    GND
GPIO 43 (TX)  ───────────────►    RX   (data into GPS)
GPIO 44 (RX)  ◄───────────────    TX   (data from GPS)

UART1 · 9600 baud · 3.3 V logic

Battery: solder LiPo leads to the BAT+ / BAT- pads on the back of
         the board. The 1.47B variant has no JST connector — bare
         pads only. (Optional: solder on a JST-PH header first if
         you want hot-swap battery swaps.)

Power the GPS from VBAT or 3V3 — never 5V. 5V is only present while USB is plugged in, so a GPS wired to 5V goes dead the instant you unplug and run on battery — which is the whole point of a portable wardriver. VBAT (the battery rail) and 3V3 are both live on battery and on USB (the on-board charger feeds the VBAT / system rail), so the GPS keeps working either way. Most NEO-M8N breakouts have a low-dropout onboard regulator (e.g. MIC5205) and lock fine straight off VBAT (3.0–4.2 V); if your module uses a high-dropout regulator (e.g. AMS1117) and won't get a fix on battery, use 3V3 instead.

⚠ Never tie GPS VCC to both VBAT and 5V (or otherwise bridge those rails). Doing so backfeeds 5 V onto the ~3.7 V battery rail, bypasses the charge controller, and trips your computer's USB over-current protection — the board stops enumerating over USB entirely (it just won't show up) until you remove the bridge. Pick one supply and stick to it.

Powered from VBAT/3V3 the GPS stays on while the device is in its "off" (deep-sleep) state. The firmware drops the module into low-power UBX backup mode at power-off to minimize drain, but the module's own power LED may remain lit — that's expected, the heavy current draw is what's cut.

Pinout from firmware/main/config.h — if you're building from a newer firmware revision, double-check the PIN_GPS_RX / PIN_GPS_TX defines. The full hardware reference, including the on-board ETA6098 charger details and the BAT-ADC divider, lives in the firmware README.

9 — Firmware: build & flash

The companion firmware is open-source at github.com/sdenike/wardrivingmapper-firmware. Below is the short version of how to get it onto the Waveshare board. The README in that repo is the deep-dive source of truth.

1. Install ESP-IDF v5.3 (one-time)

macOS

brew install cmake ninja dfu-util python3

git clone --recursive https://github.com/espressif/esp-idf.git ~/esp/esp-idf
cd ~/esp/esp-idf && git checkout v5.3
./install.sh esp32s3

echo '. ~/esp/esp-idf/export.sh' >> ~/.zshrc
source ~/.zshrc

Or run ./setup_macos.sh from the firmware repo — same steps, scripted.

Linux (Ubuntu / Debian)

sudo apt update
sudo apt install -y git wget flex bison gperf \
  python3 python3-pip python3-venv \
  cmake ninja-build ccache \
  libffi-dev libssl-dev dfu-util libusb-1.0-0

git clone --recursive https://github.com/espressif/esp-idf.git ~/esp/esp-idf
cd ~/esp/esp-idf && git checkout v5.3
./install.sh esp32s3

echo '. ~/esp/esp-idf/export.sh' >> ~/.bashrc   # or ~/.zshrc
source ~/esp/esp-idf/export.sh

# Add yourself to the dialout group for serial-port access — log out + back
# in (or `newgrp dialout`) for it to take effect.
sudo usermod -aG dialout "$USER"

Or run ./setup_linux.sh from the firmware repo — same steps, scripted; works on apt, dnf, and pacman distros. On Windows: use the official ESP-IDF Windows installer.

2. Clone & build

git clone https://github.com/sdenike/wardrivingmapper-firmware.git
cd wardrivingmapper-firmware
idf.py set-target esp32s3
idf.py build

For subsequent pulls from this repo, use ./cli/update.sh instead — it does git pull + rm sdkconfig + idf.py fullclean + build + flash + monitor. The rm sdkconfig step is mandatory whenever upstream sdkconfig.defaults changes, otherwise your build keeps the old KConfig values silently and the flashed binary won't match the source code (typical symptoms: a stack-overflow panic on boot, or the AP web admin timing out at 192.168.4.1).

3. Flash the device

# Find your serial port:
ls /dev/cu.usbmodem*                          # macOS
ls -l /dev/ttyACM* /dev/ttyUSB* 2>/dev/null   # Linux

# Build + flash + open serial monitor in one shot
idf.py -p /dev/cu.usbmodem* flash monitor     # macOS
idf.py -p /dev/ttyACM0      flash monitor     # Linux

If esptool can't auto-enter download mode: hold the BOOT button, unplug USB, plug back in, then release BOOT. After flashing, power-cycle the board cleanly. On Linux, "Permission denied" on the port means you're not in the dialout group yet — finish step 1.

4. Operating the device

One physical control: the BOOT button. Short-press cycles to the next screen. Long-press (~1 s) activates whatever the current menu screen offers.

The screen carousel: SCANNAVWi-Fi SetupForce UploadPower Off → (loops).

5. First-boot configuration

  1. Power on. The LCD shows the SCAN dashboard.
  2. Short-press BOOT until you reach the Wi-Fi Setup screen.
  3. Long-press BOOT (~1 s). The device starts an access point named WarDrivingMapper with password configure1; the LCD shows a QR code with those credentials so a phone can join without typing.
  4. From a laptop or phone, join that AP, then open http://192.168.4.1.
  5. Fill in:
    • API URL: https://wardrivingmapper.com/api/v1/scans
    • API key: the one you generated on your dashboard.
    • Trusted Wi-Fi: at least one home/office SSID + password — the device only auto-uploads when it sees one of these.
  6. Short-press BOOT to exit setup. The device returns to scanning.

6. Forcing an upload

Short-press BOOT until you reach the Force Upload screen, then long-press to push the current /storage/wardrive.jsonl to the backend immediately regardless of motion or trusted-Wi-Fi state. The auto-upload toggle in setup-mode admin controls whether automatic uploads also fire when you're parked near a trusted network.

7. Useful commands

Swap /dev/cu.usbmodem* with /dev/ttyACM0 on Linux.

idf.py -p /dev/cu.usbmodem* monitor       # serial only (already flashed)
idf.py -p /dev/cu.usbmodem* erase-flash   # wipe NVS, reset all config
idf.py fullclean && idf.py build         # clean rebuild

Pin assignments + full architecture overview live in the firmware repo's README. Once paired with an iPhone via the iOS admin app, you can also do all of step 4 over Bluetooth instead of the Wi-Fi AP.