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 limit120 requests per minute, per key
Over the limit429 with a JSON body saying what the budget is
Cross-originGET and HEAD allowed from anywhere
AuthenticationX-API-Key header, on everything but the index
Compressiongzip, 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 key6 requests per hour, per IP address
With a keythe normal 120 requests per minute, per key (above)
Over either limit429, 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

sinceonly announcements after this id; omit for the oldest available
kindscomma-separated, e.g. daily_recap,month_honors; omit for every kind
boardmeshcore or meshtastic (mc/mt also accepted); omit for both
net_idonly wrap-ups for one weekly net — see Nets above for the id/label list
limit1–100, default 20
text_budget20–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_recapwhich teams moved rank today, and the day's single biggest square gain
weekly_recapthe week's placement changes and new ground explored
month_honorsa closed month's final standings and awards
net_wrapupone 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.