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.
Nets
GET /api/v1/nets
The enabled check-in nets, by id, label and board. This is how you find a net's numeric id — the one Net above does not hand out and Announcements' net_id filter expects — so an operator can pick a net by name instead of guessing.
Needs no key, the same as Announcements below, and shares that route's rate limits and budget: present a key and you get the normal per-key budget instead of the anonymous tier.
{
"nets": [
{
"id": 1,
"label": "Weekly Net (Freq51 MC)",
"board": "mc",
"weekday": 2,
"start_hour": 17,
"end_hour": 23,
"timezone": "America/Boise"
}
]
}
Only enabled nets are listed, ordered by id. Nothing about how a net is polled — its connector, broker, channel or hashtag — is included; that is this operator's own upstream configuration, not something a caller choosing a net needs or should see.
Announcements
GET /api/v1/announcements?since=0
The public announcement feed — daily recaps, weekly recaps, month honors and weekly net wrap-ups. This is the one route on this page that needs no key, and that is deliberate: an announcement is already public broadcast news about a public game, headline results headed for an open radio channel anyone can already listen to. Gating it behind a key would protect nothing, and would only mean every bot author has to ask the operator for one before their bot could work at all.
A key is still welcome here. Present one and you are upgraded onto the normal per-key budget this page already documents, instead of the tighter anonymous tier below.
Rate limits
| No key | 6 requests per hour, per IP address |
|---|---|
| With a key | the normal 120 requests per minute, per key (above) |
| Over either limit | 429, with a Retry-After header giving the seconds to wait |
Polling with a cursor
since takes the next_since from your previous response; the first call omits it. This matters more here than anywhere else on this page: a bot that only wants NEW announcements from here on should fast-forward on its first run — make one call, keep its next_since, and throw away whatever announcements came with it — otherwise its very first poll replays the feed's whole history.
Parameters
since | only announcements after this id; omit for the oldest available |
|---|---|
kinds | comma-separated, e.g. daily_recap,month_honors; omit for every kind |
board | meshcore or meshtastic (mc/mt also accepted); omit for both |
net_id | only wrap-ups for one weekly net — see Nets above for the id/label list |
limit | 1–100, default 20 |
text_budget | 20–1000 bytes — not characters — default 150 |
{
"announcements": [
{
"id": 214,
"kind": "net_wrapup",
"key": "3:2026-08-19",
"board": "meshcore",
"net_id": 3,
"created_at": 1787458001,
"content": {
"kind": "net_wrapup",
"period_label": "Wed 19 Aug"
},
"text": "MC net Wed 19 Aug: DrkSkyz streak 4, KB7WHO Ken streak 1"
}
],
"next_since": 214,
"poll_interval_seconds": 900
}
poll_interval_seconds is the server's own recommended cadence — honour it rather than choosing your own interval. It is set well above how often anything genuinely new can appear, so a bot that just reads this number back picks a sane cadence instead of guessing.
text vs content
text is the pre-rendered plain-text line, already fitted to text_budget bytes of UTF-8 — safe to relay straight onto a mesh channel as-is. content is the same announcement in its structured form, for a consumer that wants to format it differently. Using text is the easy path, and it exists so five different bots do not each reimplement UTF-8 byte-fitting and get it wrong.
Caching
Send back the ETag you were given as an If-None-Match header and get a 304 with no body when nothing has changed — cheaper for you and for the server than fetching the same page again.
Kinds
daily_recap | which teams moved rank today, and the day's single biggest square gain |
|---|---|
weekly_recap | the week's placement changes and new ground explored |
month_honors | a closed month's final standings and awards |
net_wrapup | one weekly net's check-ins, top streaks first |
A quiet period produces no announcement at all rather than an empty one — a day with no rank changes and no gains, for instance, adds nothing to the feed.
Three 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.
Following announcements
since = requests.get("https://meshwars.com/api/v1/announcements").json()["next_since"]
etag = None
poll_interval = 900 # the server's own recommendation; overwritten below
while True:
headers = {"If-None-Match": etag} if etag else {}
r = requests.get("https://meshwars.com/api/v1/announcements",
params={"since": since}, headers=headers)
if r.status_code != 304:
body = r.json()
etag = r.headers.get("ETag")
poll_interval = body["poll_interval_seconds"]
for a in body["announcements"]:
print(a["text"])
since = body["next_since"]
time.sleep(poll_interval)
The first call just reads next_since and throws away whatever announcements came with it — fast-forwarding to "now" so the first real poll does not print the whole season's history. No X-API-Key here; add one only if you want the larger per-key budget instead of the anonymous tier.