> ## 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=.

# WebSocket API

> Push of new METAR and SPECI reports on /v1/ws/metar.

## Connect

```text WSS · API key · stream theme={"dark"}
wss://mobs.ws/v1/ws/metar?airports=KNYC,KLGA&mode=raw
```

| Parameter | Required | Description |
| - | - | - |
| `airports` | No | Comma list of ICAO codes; case-insensitive, duplicates removed. Optional: an empty list connects and subscribes later. Placeholder · A2 |
| `mode` | No | `raw` (default) or `parsed`, fixed for the life of the connection. Placeholder · A3 |
| `token` | No | Your API key, for browsers only; other clients send it in the `Authorization` header. |

```python Python theme={"dark"}
import asyncio, json, os
from websockets.asyncio.client import connect

URL = "wss://mobs.ws/v1/ws/metar?airports=KNYC,KLGA"
HEADERS = {"Authorization": f"Bearer {os.environ['MOBS_API_KEY']}"}

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

async def main():
    async with connect(URL, additional_headers=HEADERS) as ws:
        beat = asyncio.create_task(heartbeat(ws))
        try:
            async for text in ws:
                msg = json.loads(text)
                if msg["type"] == "new_metar_arrived":
                    for rec in msg["data"]:
                        print(rec["icao"], rec["raw_metar"])
        finally:
            beat.cancel()

asyncio.run(main())
```

```bash websocat theme={"dark"}
websocat -H "Authorization: Bearer $MOBS_API_KEY" \
  "wss://mobs.ws/v1/ws/metar?airports=KNYC,KLGA"
```

```javascript JavaScript (browser) theme={"dark"}
// Browsers cannot set socket headers; use ?token= here only.
const ws = new WebSocket(
  "wss://mobs.ws/v1/ws/metar?airports=KNYC&token=YOUR_API_KEY");
setInterval(() => ws.send('{"action":"ping"}'), 20000);
ws.onmessage = (e) => console.log(JSON.parse(e.data));
```

### Refused before the upgrade

| Status | Refused before the upgrade when |
| - | - |
| 401 | No API key or an invalid API key |
| 403 | API key without the `stream` scope |
| 400 | An airport outside coverage. Placeholder · A11 |
| 422 | Malformed `airports` or `mode`, or over the airport cap. Placeholder · A11 |
| 429 | Per-API-key socket limit reached |
| 503 | Server-wide cap reached |

## On connect

The server sends one `subscription_updated` with the resulting list and nothing else: no reports.

> [!NOTE]
>
> Placeholder · A4 · Placeholder · A5

## Change the airport list

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

```json Example · body is a placeholder (A5) theme={"dark"}
{"type":"subscription_updated",
 "icaos":["KNYC","KORD"]}
```

`subscribe` adds and `unsubscribe` removes only the named codes. Unsubscribing a code you do not hold is ignored. No wildcards: list every airport. There are no request ids.

## New reports

```json Example data · report text elided theme={"dark"}
{"type":"new_metar_arrived",
 "data":[
  {"icao":"KNYC",
   "raw_metar":"METAR KNYC 092251Z …",
   "obs_time":"2026-11-09T22:51:00+00:00",
   "report_type":"METAR",
   "arrived_at":"<UTC time, ms>"}]}
```

`data` is an array: handle one or more records. Today each message carries one record.

> [!NOTE]
>
> Placeholder · A7

## 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 |
| `report_type` | `METAR` or `SPECI` |
| `arrived_at` | When MOBS decoded the report |

Times are ISO 8601 UTC with `+00:00`: whole seconds on `obs_time`, milliseconds on `arrived_at`.

> [!WARNING]
>
> Time format: To be confirmed · D3. Also Placeholder · A12.

### Parsed fields

MOBS's own decoded set: `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`. Absent values are left out, numbers that need decimals are strings, and units are in the field name.

> [!NOTE]
>
> Parsed fields: Placeholder · A1

```json Example data theme={"dark"}
"parsed":{
  "temp_c":"11.7",
  "dewp_c":"-2.8",
  "wdir":310,
  "wspd_kt":9,
  "visib_sm":"10+",
  "visib_or_greater":true,
  "altim_hpa":"1023.0",
  "slp_mb":"1023.1",
  "clouds":[
   {"cover":"FEW",
    "base_ft":25000}],
  "flt_cat":"VFR"}
```

### Corrections

A correction is a new record with the same `icao` and `obs_time` and a different `raw_metar`. An off-minute report can also arrive twice: first typed `SPECI`, then `METAR` once a second source confirms the type.

> [!WARNING]
>
> To be confirmed · D9

## Daily temperature markets

polymarket.com daily temperature markets settle on the Aviation Weather Center METAR API. A SPECI does not count. Temperature is the T-group in tenths of a degree Celsius when that remark is present; otherwise the body temperature group, in whole degrees Celsius. The day is the station's local calendar day. Denver is KDEN. Station codes and routine minutes are on [Stations](https://docs.mobs.ws/stations.md#daily-temperature-markets).

## Heartbeat

Send `{"action":"ping"}` every 20 seconds; the reply is `{"type":"pong"}` with no other fields. After 60 seconds with no JSON ping and no valid subscribe or unsubscribe, the socket closes with `4000`. Protocol pings do not reset that timer; MOBS answers them but does not send its own.

> [!NOTE]
>
> The pong body is Placeholder · A8. The idle rule is Placeholder · A16.

## Errors on the socket

```json Example · body is a placeholder (A6) theme={"dark"}
{"type":"error",
 "code":"invalid_airports",
 "message":"…",
 "icaos":["<code>"]}
```

The socket stays open. Codes: `invalid_airports`, `bad_request`, `unknown_action`, `too_many_airports`.

A reader that falls too far behind is closed with `1013`; no warning is sent first.

> [!NOTE]
>
> Error bodies are Placeholder · A6. The slow-reader close is Placeholder · A14.

## Close codes

| 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 API key; do not retry blindly |
| `1013` | Temporary: socket or airport cap overshoot, or a slow reader | Reconnect with backoff |
| `1008` | Reserved: bad path | Check the URL |

## Reconnecting

**1. Reconnect with backoff**

Close the old socket, wait with backoff and jitter, and connect once more with your airports.

**2. Resync over HTTP**

Call `GET /v1/metar/latest` and merge. Reports issued while you were away are not resent; only the newest per airport can be read.

**3. Deduplicate**

Deduplicate on `icao`, `obs_time` and `raw_metar` together.


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