API
A read-only JSON API for building a mesh bot, a Discord bot or a dashboard on MeshWars data. Ask for a key and it is yours.
Getting started
Everything lives under /api/v1 and returns JSON. One route needs no key — the index, so you can see what you would be getting:
curl https://meshwars.com/api/v1
Everything else takes a key in an X-API-Key header:
curl -H "X-API-Key: $MESHWARS_KEY" \
https://meshwars.com/api/v1/status
Getting one: ask malice in the Intermountain Mesh Discord and say what you are building. Keys are free and issued per integration.
The key is shown once when it is issued and only its hash is stored, so it cannot be looked up later. Lose it and you get a new one.
The contract. Response shapes only ever gain fields. A field that exists keeps its name and its meaning. If something has to change incompatibly it becomes /api/v2 and this stays as it is. The site's own routes — /api/mc/board and friends — carry no such promise and will change without warning; do not build on them.
Conventions
Authentication
Send your key as an X-API-Key header on every request except /api/v1 itself. A missing or revoked key is a 401.
Keys are per integration rather than per person, which is the point of them: a bot that misbehaves can be turned off on its own, instead of an address being blocked that might be a whole mesh community behind one connection.
Do not put a key in a web page. Cross-origin reads are allowed, so a browser dashboard will work — but anything in front-end code is readable by whoever loads it. Put the key on a server you control and have the page call that.
Choosing a board
MeshWars runs two games. Every route except /status takes a board parameter, so one integration serves both.
?board=meshcore (the default) ?board=meshtastic
mc and mt are accepted as well. An unknown board is a 400 rather than a silent fallback.
Times and dates
Anything named *_at or *_ts is unix seconds, UTC. Anything that is genuinely a calendar date — a net date, a month — is a string like 2026-08-19 or 2026-08, in Boise time, because that is what those things actually are.
Limits
| Rate limit | 120 requests per minute, per key |
|---|---|
| Over the limit | 429 with a JSON body saying what the budget is |
| Cross-origin | GET and HEAD allowed from anywhere |
| Authentication | X-API-Key header, on everything but the index |
| Compression | gzip, if you send Accept-Encoding |
The limit is deliberately generous — polling every few seconds is the expected use, not an abuse of it. It is counted per key, so your budget is yours and nobody else can spend it.
Errors
{"error": "unknown board", "detail": "board must be one of: meshcore, meshtastic"}
Every error carries error and detail. 400 for a bad parameter, 401 for a missing or revoked key, 404 for something that does not exist, 429 for the rate limit.
Status
GET /api/v1/status
Both boards in one call: season, standings, player count, squares owned, and the state of the weekly net. This is the one to build a !status command on — it exists so a bot does not need four requests to answer one question.
{
"generated_at": 1787457894,
"boards": [
{
"board": "meshcore",
"season": {
"id": 2,
"started_at": 1787029453,
"ends_at": 1802581453,
"status": "active",
"winner": null,
"seconds_remaining": 15123559
},
"standings": [
{
"team": "GREEN",
"squares": 2390,
"checkin_points": 75.0,
"total": 2465.0,
"rank": 1
},
{
"team": "RED",
"squares": 1909,
"checkin_points": 50.0,
"total": 1959.0,
"rank": 2
}
],
"players": 23,
"squares_owned": 6004
}
],
"net": {
"open": false,
"weekday": 2,
"opens_hour_local": 17,
"closes_hour_local": 23,
"timezone": "America/Boise",
"current_net_date": null,
"next_opens_at": 1787785200,
"base_points": 25.0,
"streak_bonus_per_net": 5.0,
"streak_bonus_max": 25.0
}
}
Seasons
GET /api/v1/seasons?board=meshcore
The running season and every closed one, newest first, each with its final per-team tally and winner. A season runs 180 days; seconds_remaining on the current one saves you the arithmetic.
Teams
GET /api/v1/teams?board=meshcore
Standings, ranked. total is what decides the season: squares held plus check-in points, which count in the same units.
{
"board": "meshcore",
"season": {
"id": 2,
"started_at": 1787029453,
"ends_at": 1802581453,
"status": "active",
"winner": null,
"seconds_remaining": 15123559
},
"teams": [
{
"team": "GREEN",
"squares": 2390,
"checkin_points": 75.0,
"total": 2465.0,
"rank": 1
},
{
"team": "RED",
"squares": 1909,
"checkin_points": 50.0,
"total": 1959.0,
"rank": 2
},
{
"team": "BLUE",
"squares": 929,
"checkin_points": 0.0,
"total": 929.0,
"rank": 3
}
]
}
Players
GET /api/v1/players?board=meshcore
GET /api/v1/players/{name}?board=meshcore
The whole roster, or one player by display name, matched case-insensitively. 404 if they are not registered on that board. Disabled accounts are absent from both.
{
"board": "meshcore",
"season_id": 2,
"player": {
"name": "kraroed",
"team": "GREEN",
"joined_at": 1787074788,
"radios": [
"c177e995"
],
"captures": 1038,
"captures_from_other_teams": 100,
"checkins": 0,
"checkin_points": 0.0,
"last_checkin_net_date": null,
"current_streak": null,
"last_position_ts": 1787337190
}
}
current_streak is the run they are carrying right now — the streak on their most recent check-in, not their longest ever. It is null for anyone who has not checked in this season.
Rankings
GET /api/v1/top?board=meshcore&kind=captures
Top 20 for the running season, the same list the site's own Season Rankings shows. kind is captures or checkins.
{
"board": "meshcore",
"kind": "checkins",
"players": [
{
"display_name": "Malice",
"team": "ORANGE",
"points": 25.0,
"streak": 1
},
{
"display_name": "Littleaton",
"team": "GREEN",
"points": 25.0,
"streak": 1
}
]
}
Board
GET /api/v1/board?board=meshcore
Every owned square in the running season with its bounds and holder. Several thousand of them — this is by far the heaviest route here, so fetch it on a timer and cache it rather than per command. If you only want what changed, use the capture feed instead.
Cells
GET /api/v1/cells/{cell_id}?board=meshcore
One square: who holds it, every team's score on it, when it last changed hands, and the repeaters heard from it. Cell ids look like 16090_-29380 and come back on the board and capture routes.
Captures
GET /api/v1/captures?board=meshcore&since=0&limit=100
The event feed, newest first. Poll it with the newest ts you have already seen as since and you get only what has happened since — the ordering guarantees that works.
{
"board": "meshcore",
"season_id": 2,
"count": 2,
"captures": [
{
"cell_id": "15191_-29143",
"ts": 1787449108,
"player": "Pantsy91",
"team": "PURPLE",
"from_team": null,
"by_air": false
},
{
"cell_id": "15191_-29144",
"ts": 1787449011,
"player": "Pantsy91",
"team": "PURPLE",
"from_team": "RED",
"by_air": false
}
]
}
from_team is null when the square was unclaimed, which means it was an expansion rather than an attack. by_air marks a claim made at aircraft speed; it still counts as territory but not toward the exploration honors.
limit is 1–500 and defaults to 100.
Results
GET /api/v1/results?board=meshcore&limit=12
Finished months, newest first, each with its standings and its honors, plus open_month_closes_at for the month still being played.
The month in progress is deliberately absent — a month is judged when it ends, not as it goes. Until the first month closes, months is empty and only the closing time is there.
Net
GET /api/v1/net?board=meshcore
Whether the weekly net is open right now, when the next one opens, what a check-in is worth, and everyone who checked in to the most recent one.
{
"board": "meshcore",
"net": {
"open": false,
"weekday": 2,
"opens_hour_local": 17,
"closes_hour_local": 23,
"timezone": "America/Boise",
"current_net_date": null,
"next_opens_at": 1787785200,
"base_points": 25.0,
"streak_bonus_per_net": 5.0,
"streak_bonus_max": 25.0
},
"latest_net_date": "2026-08-19",
"latest_checkins": [
{
"player": "DrkSkyz",
"team": "GREEN",
"points": 25.0,
"streak": 1
},
{
"player": "KB7WHO Ken",
"team": "GREEN",
"points": 25.0,
"streak": 1
}
]
}
current_net_date is only set while the net is actually open. next_opens_at is always the next start, so a reminder bot needs nothing else.
Two worked examples
A status command
H = {"X-API-Key": os.environ["MESHWARS_KEY"]}
r = requests.get("https://meshwars.com/api/v1/status", headers=H).json()
mc = r["boards"][0]
lead = mc["standings"][0]
days = mc["season"]["seconds_remaining"] // 86400
print(f'{lead["team"]} leads with {lead["total"]:.0f} '
f'({lead["squares"]} squares). Season ends in {days} days.')
if r["net"]["open"]:
print("The net is on right now.")
Announcing captures
seen = 0 # keep this between polls
while True:
r = requests.get("https://meshwars.com/api/v1/captures",
headers=H, params={"since": seen, "limit": 100}).json()
for c in reversed(r["captures"]): # oldest first, to announce in order
if c["from_team"]:
say(f'{c["player"]} took a square from {c["from_team"]} for {c["team"]}')
else:
say(f'{c["player"]} claimed new ground for {c["team"]}')
seen = max(seen, c["ts"])
time.sleep(60)
Start seen at the current time rather than zero if you do not want the whole season announced on first run.