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
- Create an account (or sign in).
- Open the Dashboard.
- Click New API key. The full key is shown once — copy it now; only its prefix is retained.
- 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:
| Field | Type | Required | Notes |
|---|---|---|---|
device_id | string | yes | Top-level, alphanumeric / ._-, max 64 chars. Identifies the device or script that uploaded. |
records[].bssid | string | yes | MAC, format AA:BB:CC:DD:EE:FF (six hex pairs, colon-separated, any case). |
records[].ssid | string | yes | Network name. May be empty for hidden networks. Max 64 chars after control-byte strip. |
records[].rssi | integer | no | Signal strength in dBm, range -120 … 0. |
records[].ch | integer | no | Wi-Fi channel (1-196 spans 2.4/5/6 GHz). |
records[].enc | string | no | Encryption label. Free-form, but use Open / WEP / WPA / WPA2 / WPA3 / WPA/2 / WPA2/3 for consistent map colouring. |
records[].lat | float | recommended | WGS-84 latitude, -90 … 90. Records without lat or lon are accepted but won't appear on the map (geometry is NULL). |
records[].lon | float | recommended | WGS-84 longitude, -180 … 180. |
records[].alt | float | no | Altitude in metres. |
records[].hdop | float | no | Horizontal DOP from your GPS. |
records[].sats | integer | no | Satellite count. |
records[].ts | string | no | ISO-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
| Limit | Value | What happens |
|---|---|---|
| Records per request | 500 | 400 too_many |
| Payload bytes | 256 KB | 413 too_large |
| Per-user rate | 60 req / min | 429 — retry with backoff |
| Per-IP rate | 120 req / min | same |
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
| Part | Notes | Link |
|---|---|---|
| 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:
SCAN → NAV →
Wi-Fi Setup → Force Upload →
Power Off → (loops).
5. First-boot configuration
- Power on. The LCD shows the SCAN dashboard.
- Short-press BOOT until you reach the Wi-Fi Setup screen.
- Long-press BOOT (~1 s). The device starts an access point named
WarDrivingMapperwith passwordconfigure1; the LCD shows a QR code with those credentials so a phone can join without typing. - From a laptop or phone, join that AP, then open
http://192.168.4.1. - 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.
- API URL:
- 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.