> ## Documentation Index
> Fetch the complete documentation index at: https://docs.mobs.ws/llms.txt
> Use this file to discover all available pages before exploring further.

> ## Agent Instructions
> The WebSocket at wss://mobs.ws/v1/ws/metar is the product. The HTTP API is a fallback for backfill only.
> Follow /agents.md for connect, auth, fields, heartbeat, reconnect, and dedupe. Never log MOBS_TOKEN or a URL that includes ?token=.

# Use MOBS from an AI agent

> Connect to the WebSocket first. Use HTTP only to backfill the latest report per airport.

The product is the WebSocket at `/v1/ws/metar`. New METAR and SPECI reports are pushed as `new_metar_arrived` messages. The HTTP API is a fallback: it returns only the latest report per airport, and there is no history endpoint.

API keys are issued on request from [support@mobs.ws](mailto:support@mobs.ws). Store yours in `MOBS_TOKEN`. Never log the token, and never log a URL that includes `?token=`.

## Paste this into your agent

```text theme={"dark"}
Connect to MOBS over WebSocket. HTTP is backfill only.

- Read the API key from the environment variable MOBS_TOKEN.
  Never log the token, and never log a URL that includes it.
- Open wss://mobs.ws/v1/ws/metar?airports=ICAO,ICAO
  (comma list, case-insensitive). Default mode is raw.
- Authenticate with the header
  Authorization: Bearer $MOBS_TOKEN.
  Browsers cannot set socket headers; they may pass
  ?token=$MOBS_TOKEN on the handshake URL instead.
  If no Bearer header is present, the server reads
  ?token=, then the website cookie. Do not use the
  website cookie. The server does not accept a
  WebSocket subprotocol as the key.
- The first server message is subscription_updated.
  No reports are sent on connect.
- Then GET https://mobs.ws/v1/metar/latest?airports=…
  with the same Bearer header to backfill the latest
  report per airport. After every reconnect, backfill
  again. There is no history API; missed reports other
  than the current latest cannot be recovered.
- Send {"action":"ping"} every 20 seconds. The reply is
  {"type":"pong"}. After 60 seconds with no JSON ping
  and no valid subscribe or unsubscribe, the server
  closes with 4000. RFC 6455 protocol pings do not
  reset that idle timer.
- Parse JSON text frames. Types:
  subscription_updated (icaos list),
  pong,
  error (socket stays open; codes invalid_airports,
  bad_request, unknown_action, too_many_airports),
  new_metar_arrived (data is an array of records).
- Each record always has icao, raw_metar, obs_time,
  report_type (METAR or SPECI), arrived_at. In mode=
  parsed a parsed object may be added; absent keys are
  omitted. Do not expect correction, replaces_report_id
  or revision on the wire.
- A SPECI is a new_metar_arrived record with
  report_type "SPECI" (raw_metar starts with SPECI).
- A correction is a later record with the same icao and
  obs_time and a different raw_metar. Keep the later
  record. In parsed mode, parsed.corrected may be true.
- Dedupe on icao + obs_time, replacing the stored
  record when raw_metar changes.
- Reconnect with exponential backoff and jitter on
  unexpected close, 4000 (fix the heartbeat first),
  4008 (24 h lifetime; reconnect), and 1013 (temporary:
  cap overshoot or a slow reader). Do not retry 4403
  (API key revoked or expired). Backoff is capped at
  about 30 s (with jitter, about 15-45 s).
- Reset the backoff after the first message, or after
  about 10 s of an open socket (the 10 s timer starts
  when the socket opens, not when it is created). A
  socket that is accepted and closed at once (for
  example 1013) must not reset the delay.
- After 24 hours the server closes with 4008; reconnect
  and backfill again.
```

## Connect

```text theme={"dark"}
wss://mobs.ws/v1/ws/metar?airports=KNYC,KLGA
```

| Query | Required | Meaning |
| - | - | - |
| `airports` | No | Comma list of ICAO codes; case-insensitive, duplicates removed. An empty list connects; subscribe later. |
| `mode` | No | `raw` (default) or `parsed`, fixed for the life of the connection. |
| `token` | No | API key, for browsers only. Other clients send the header. |

The `stream` scope is required. A refused handshake never upgrades: 401 (no or invalid key), 403 (wrong scope), 400 (uncovered airport), 422 (bad `airports` or `mode`, or over the airport cap), 429 (per-key socket cap), 503 (server-wide cap).

## Authentication

Non-browser clients send `Authorization: Bearer $MOBS_TOKEN` on the handshake (and on HTTP). If no Bearer header is present, the server reads `?token=`, then the website cookie. Do not use the website cookie. There is no WebSocket subprotocol for the key.

## Airport filter

Pass `airports` on the URL, or send JSON on an open socket:

```json theme={"dark"}
{"action":"subscribe","icaos":["KORD"]}
{"action":"unsubscribe","icaos":["KLGA"]}
```

The server replies `{"type":"subscription_updated","icaos":[…]}` with the resulting sorted list. `subscribe` adds, `unsubscribe` removes. Unsubscribing a code you do not hold is ignored. No wildcards.

## Message types

Client JSON actions: `ping`, `subscribe`, `unsubscribe`. Anything else is `unknown_action`. Binary frames are `bad_request`.

| Server `type` | When |
| - | - |
| `subscription_updated` | On connect, and after a valid subscribe or unsubscribe. Body: `icaos`. |
| `pong` | Reply to `{"action":"ping"}`. No other fields. |
| `new_metar_arrived` | A new report for a subscribed airport. `data` is an array; today each message has one record. |
| `error` | Bad client message. The socket stays open. Codes: `invalid_airports`, `bad_request`, `unknown_action`, `too_many_airports`. May include `message` and `icaos`. |

## Record fields

Every record, in both modes:

| Field | Meaning |
| - | - |
| `icao` | Station ICAO code |
| `raw_metar` | Report text, starting with `METAR` or `SPECI` |
| `obs_time` | Observation time from the report, UTC `+00:00`, whole seconds |
| `report_type` | `METAR` or `SPECI` |
| `arrived_at` | When MOBS decoded the report, UTC `+00:00`, milliseconds |
| `parsed` | Only in `mode=parsed`. Object of decoded fields; absent keys are omitted. |

In `mode=parsed`, `parsed` is MOBS's own decoded set. Only present, non-null keys are included: `temp_c`, `dewp_c`, `wdir`, `wdir_vrb`, `wspd_kt`, `wgst_kt`, `visib_sm`, `visib_m`, `visib_or_greater`, `visib_or_less`, `altim_hpa`, `slp_mb`, `wx`, `clouds`, `flt_cat`, `corrected`. Numbers that need decimals are strings; units are in the field name.

## SPECI

A SPECI uses the same `new_metar_arrived` message. `report_type` is `SPECI` and `raw_metar` starts with `SPECI`. polymarket.com daily temperature markets do not settle on a SPECI; filter on `report_type` if you are tracking those markets. Settlement uses the Aviation Weather Center METAR API, the T-group in tenths when present otherwise the body temperature in whole °C, and the station's local calendar day. See [Stations](https://docs.mobs.ws/stations.md#daily-temperature-markets).

## Corrections

Store-internal fields `correction`, `replaces_report_id` and `revision` are not on the wire. A correction arrives as a new record with the same `icao` and `obs_time` and a different `raw_metar`. Keep the later record. In `parsed` mode, `parsed.corrected` may be `true`. An off-minute report can also arrive twice: first typed `SPECI`, then `METAR` once a second source confirms the type.

Dedupe on `icao` and `obs_time`, replacing the stored record when `raw_metar` changes.

## Heartbeat and idle

Send `{"action":"ping"}` every 20 seconds. The server replies `{"type":"pong"}`. After 60 seconds with no JSON `ping` and no valid `subscribe` or `unsubscribe`, the socket closes with `4000` (`idle timeout`). RFC 6455 protocol pings do not reset that timer; MOBS answers them but does not send its own. A connection that reaches 24 hours is closed with `4008` (`max connection age`).

## Reconnect

Close the old socket, wait with exponential backoff and jitter, and connect again with the same airports. Backoff is capped at about 30 s (with jitter, about 15–45 s). Reset the backoff after the first message, or after about 10 s up (the 10 s timer starts once the handshake completes), so a socket that is accepted and closed at once (for example `1013`) does not retry immediately. Then call `GET /v1/metar/latest` and merge. Reports issued while you were away are not resent; only the newest per airport can be read.

| Close code | Meaning | Do |
| - | - | - |
| `4000` | Idle: 60 s without a JSON ping or subscription change | Fix the heartbeat, then reconnect |
| `4008` | Connection reached 24 h | Reconnect |
| `4403` | API key revoked or expired | Get a valid key; do not retry blindly |
| `1013` | Temporary: socket or airport cap overshoot, or a slow reader | Reconnect with backoff |

## Python

```python theme={"dark"}
import asyncio, json, os, random
from websockets.asyncio.client import connect
from websockets.exceptions import (
    ConnectionClosedError,
    InvalidStatus,
)
import httpx

TOKEN = os.environ["MOBS_TOKEN"]
AIRPORTS = "KNYC,KLGA"
WS_URL = (
    "wss://mobs.ws/v1/ws/metar"
    f"?airports={AIRPORTS}"
)
HEADERS = {"Authorization": f"Bearer {TOKEN}"}
SEEN = {}  # (icao, obs_time) -> raw_metar


def merge(rec):
    key = (rec["icao"], rec["obs_time"])
    prev = SEEN.get(key)
    if prev == rec["raw_metar"]:
        return
    SEEN[key] = rec["raw_metar"]
    print(rec["icao"], rec["report_type"], rec["raw_metar"])


async def backfill():
    async with httpx.AsyncClient(timeout=30) as client:
        r = await client.get(
            "https://mobs.ws/v1/metar/latest",
            params={"airports": AIRPORTS},
            headers=HEADERS,
        )
        r.raise_for_status()
        for rec in r.json().get("data", []):
            merge(rec)


async def heartbeat(ws):
    while True:
        await asyncio.sleep(20)
        await ws.send(json.dumps({"action": "ping"}))


async def arm_reset():
    global delay
    await asyncio.sleep(10)
    delay = 1


async def session():
    global delay
    async with connect(
        WS_URL, additional_headers=HEADERS
    ) as ws:
        beat = asyncio.create_task(heartbeat(ws))
        hold = asyncio.create_task(arm_reset())
        try:
            await backfill()
            async for text in ws:
                delay = 1
                hold.cancel()
                msg = json.loads(text)
                kind = msg.get("type")
                if kind == "new_metar_arrived":
                    for rec in msg["data"]:
                        merge(rec)
                elif kind == "error":
                    print("error", msg)
        finally:
            hold.cancel()
            beat.cancel()


delay = 1


async def main():
    global delay
    while True:
        try:
            await session()
        except ConnectionClosedError as exc:
            if exc.rcvd and exc.rcvd.code == 4403:
                raise SystemExit(
                    "API key revoked"
                ) from exc
            print("session ended", exc)
        except InvalidStatus as exc:
            if exc.response.status_code in (
                401, 403
            ):
                raise SystemExit(
                    "API key rejected"
                ) from exc
            print("session ended", exc)
        except SystemExit:
            raise
        except Exception as exc:
            print("session ended", exc)
        wait = delay * (0.5 + random.random())
        print("reconnect in", round(wait, 1), "s")
        await asyncio.sleep(wait)
        delay = min(delay * 2, 30)  # about 30 s


asyncio.run(main())
```

Install with `pip install httpx "websockets>=17"`.

## JavaScript (browser or Node)

Browsers cannot set socket headers; this sample uses `?token=`. Prefer the Bearer header from Python or another HTTP-capable client. Never log this URL.

```javascript theme={"dark"}
const TOKEN = globalThis.process?.env?.MOBS_TOKEN
  ?? "YOUR_API_KEY";
const AIRPORTS = "KNYC,KLGA";
const SEEN = new Map();
let attempt = 0;

function merge(rec) {
  const key = rec.icao + " " + rec.obs_time;
  if (SEEN.get(key) === rec.raw_metar) return;
  SEEN.set(key, rec.raw_metar);
  console.log(rec.icao, rec.report_type, rec.raw_metar);
}

async function backfill() {
  const url = (
    "https://mobs.ws/v1/metar/latest"
    + "?airports=" + encodeURIComponent(AIRPORTS)
  );
  const res = await fetch(url, {
    headers: {Authorization: "Bearer " + TOKEN},
  });
  if (!res.ok) throw new Error("backfill " + res.status);
  const body = await res.json();
  for (const rec of body.data || []) merge(rec);
}

function openSocket() {
  const qs = new URLSearchParams({
    airports: AIRPORTS,
    token: TOKEN,
  });
  const ws = new WebSocket(
    "wss://mobs.ws/v1/ws/metar?" + qs
  );
  const beat = setInterval(() => {
    if (ws.readyState === WebSocket.OPEN) {
      ws.send('{"action":"ping"}');
    }
  }, 20000);
  let hold = null;
  function markOk() {
    attempt = 0;
    if (hold) clearTimeout(hold);
  }
  ws.onmessage = (e) => {
    markOk();
    const msg = JSON.parse(e.data);
    if (msg.type === "new_metar_arrived") {
      for (const rec of msg.data) merge(rec);
    }
  };
  ws.onopen = () => {
    hold = setTimeout(() => {
      attempt = 0;
    }, 10000);
    backfill().catch(console.error);
  };
  ws.onclose = (e) => {
    clearInterval(beat);
    if (hold) clearTimeout(hold);
    if (e.code === 4403) {
      throw new Error("API key revoked");
    }
    const wait = Math.min(
      30000,  // about 30 s
      1000 * 2 ** attempt
    ) * (0.5 + Math.random());
    attempt = Math.min(attempt + 1, 5);
    setTimeout(openSocket, wait);
  };
}

openSocket();
```

## HTTP backfill only

There is no history endpoint (`/v1/history` is retired). Use HTTP only to read the current latest report per airport, including after a reconnect.

| Endpoint | Use |
| - | - |
| `GET /v1/metar/latest?airports=…` | Resync: `data`, opaque `versions`, and `missing` |
| `GET /v1/metar?airports=…` | Latest records only; airports with nothing cached are omitted |
| `GET /v1/metar/latency?airports=…` | 72 h average of `obs_time` to `arrived_at` |

`airports` is required on the first two. Optional `mode=raw` (default) or `parsed`. Same record shape as the socket. Auth is `Authorization: Bearer $MOBS_TOKEN`. The `latest` scope is required. Public catalogs: `GET /v1/status` and `GET /v1/stations`.


This documentation is built and hosted on [Mintlify](https://mintlify.com), a developer documentation platform.