metar.ws← customer portal

metar.ws API

Real-time METAR weather observations over WebSocket. You subscribe to city channels; we push each new observation to you the moment our pollers see it — typically seconds after the station publishes.

There is no SDK to install: the API is plain JSON over a standard WebSocket. Anything that speaks RFC-6455 — Python, Node, Go, a browser tab, websocat — is a first-class client. Complete, runnable examples in four languages are at the bottom of this page.


Quick start

  1. Sign up at app.metar.ws. New accounts start on the free Sandbox plan — no card required.

  2. Create an API key on the Keys page. The key (mts_live_…) is shown once — store it like a password.

  3. Connect and subscribe:

    pip install websockets
    python -m websockets "wss://stream.metar.ws/v1/ws?key=mts_live_YOUR_KEY"
    # then type:
    {"action":"subscribe","channels":["sandbox.demo"]}
    
  4. Fire a test alert. On the Billing page press Send test alert — within a second a demo METAR observation arrives on your connection. Your integration now works end-to-end.

  5. Upgrade (Billing → Upgrade to Starter) to switch from the demo channel to live metar.obs.* data. Your existing keys switch plans automatically — no re-issue, no reconnect logic needed on your side.


Authentication

Every connection authenticates with an API key issued in the portal. Two equivalent ways to present it:

Form Example When to use
Query parameter wss://stream.metar.ws/v1/ws?key=mts_live_… Works everywhere; the only option for the browser WebSocket API
Header Authorization: Bearer mts_live_… on the upgrade request Keeps the key out of URL/access logs; use it whenever your client supports upgrade headers

Keys can be listed, renamed and revoked in the portal at any time. Revocation is pushed to the stream layer: open connections using a revoked key are closed within a second.

Key safety: the key is your only credential. Don't commit it, don't ship it in client-side code you distribute, rotate it (revoke + create) if you suspect a leak.


Sandbox mode

The free tier is a full-fidelity integration environment, not a crippled trial. Everything behaves exactly like production — key issuance, connection, subscription, message shapes — except the data source:


Channels

Two data namespaces, both keyed by the lowercase ICAO airport code and carrying the identical payload shape.

metar.obs.<icao> — official METAR

One channel per station, ~50 stations across four continents — every Polymarket highest-temperature market's resolving station and then some. Sample:

metar.obs.eglc   London City        metar.obs.klga   New York LaGuardia
metar.obs.lfpb   Paris Le Bourget   metar.obs.rjtt   Tokyo Haneda
metar.obs.eddm   Munich             metar.obs.zbaa   Beijing Capital

New reports typically publish 1–5 minutes after the observation time and reach you within milliseconds of us picking them up. The station list grows — channels you can't name yet simply fail subscription with channel_format, so it's safe to probe.

metar.atis.<icao> — D-ATIS fast lane (US hubs)

METAR-equivalent weather extracted from FAA D-ATIS broadcasts — usually about one minute after the observation time, several minutes ahead of the official METAR mirrors. 12 US hubs:

metar.atis.katl  Atlanta        metar.atis.kord  Chicago O'Hare
metar.atis.klax  Los Angeles    metar.atis.ksfo  San Francisco
metar.atis.klga  NY LaGuardia   metar.atis.kmia  Miami
metar.atis.kdal  Dallas Love    metar.atis.kaus  Austin
metar.atis.khou  Houston Hobby  metar.atis.ksea  Seattle
metar.atis.kbos  Boston         metar.atis.kmsp  Minneapolis

Worth knowing:

Plus sandbox.demo (sandbox plan only).

Last-value replay: immediately after a successful subscribe the server replays the most recent cached observation per channel, so you get current weather instantly instead of waiting for the next report.


Protocol reference

Every frame in both directions is a single JSON object.

Connection lifecycle

On a successful upgrade the server speaks first:

{
  "type": "ack",
  "plan": "starter",
  "owner": "3f2c…",
  "limits": { "max_channels": 10, "max_connections": 1 }
}

Consume the ack before sending commands.

Client → server commands

Two commands, selected by the top-level action:

{"action": "subscribe",   "channels": ["metar.obs.eglc", "metar.obs.rksi"]}
{"action": "unsubscribe", "channels": ["metar.obs.eglc"]}

Responses:

{"type": "subscribed",   "channels": ["metar.obs.eglc", "metar.obs.rksi"]}
{"type": "unsubscribed", "channels": ["metar.obs.eglc"]}

Subscribes are validated per channel; a batch with one bad channel still subscribes the good ones (the bad one gets its own error frame). Unknown channels in unsubscribe are silently ignored.

Observations

{
  "type": "publication",
  "channel": "metar.obs.eglc",
  "data": {
    "station":     "EGLC",
    "temp_c":      "25",
    "report_time": "2026-07-12T18:20:00Z",
    "raw":         "METAR EGLC 121820Z AUTO 08019KT 9999 NCD 25/04 Q1024",
    "dewp_c":      "4",
    "wdir":        80,
    "wspd_kt":     19,
    "visib_m":     9999,
    "altim_hpa":   1024
  }
}

Always present:

field type notes
station string ICAO code of the reporting station, uppercase (e.g. EGLC)
temp_c string Temperature °C. A string on purpose — decimal precision survives; parse with a decimal type, not float, if you do arithmetic
report_time string RFC-3339 UTC observation time from the METAR report
raw string The unparsed METAR — the complete report, always authoritative

Parsed from the METAR (naming follows NOAA's aviationweather.gov METAR JSON, with explicit unit suffixes). Every field below is optional — it is omitted when the group is absent from the report or not machine-parseable, so read them defensively:

field type notes
dewp_c string Dew point °C (decimal string, like temp_c; "-12" for M12)
wdir int Wind direction, degrees true. Omitted when variable
wdir_vrb bool true when wind direction is variable (VRB)
wspd_kt int Wind speed, knots (MPS reports converted)
wgst_kt int Gusts, knots; omitted when no gust group
visib_m int Prevailing visibility, metres. 9999 = 10 km or more (statute-mile reports converted; P6SM maps to 9999)
cavok bool true for CAVOK reports (implies visib_m: 9999, no cloud layers)
altim_hpa int QNH pressure, hPa (A2998-style inHg reports converted)
wx string Present weather codes, space-joined — e.g. "-RA BR", "HZ", "FU"
clouds array Cloud layers in report order: [{"cover":"BKN","base_ft":1200}]. Cover is FEW/SCT/BKN/OVC/VV; empty/omitted for clear skies (NCD, SKC, CAVOK)

Errors

{
  "type": "error",
  "code": "permission_denied",
  "message": "your plan does not allow this channel",
  "action": "subscribe",
  "channel": "metar.obs.eglc"
}
code meaning
bad_request Malformed command JSON
unknown_action action is not subscribe / unsubscribe
channel_format Channel name doesn't exist in the server's naming scheme
permission_denied Your plan doesn't cover this channel (e.g. live data on sandbox)
channel_limit Subscribing would exceed your plan's channel cap

Errors are informational and never close the connection. Fatal events (key revoked, plan changed, slow consumer) close the socket with a WebSocket close frame instead — just reconnect (see below).

Heartbeats

The server pings every 20 s at the WebSocket protocol level. Stock libraries (websockets, ws, gorilla/websocket) answer automatically — you write no keepalive code. An idle but healthy connection stays open indefinitely between reports.

Reconnection

The protocol is stateless beyond the open connection: on any disconnect just wait a few seconds, reconnect, and re-subscribe. Two things make this completely safe:


Plans & limits

Plan Price Connections Channels API keys Data
Sandbox Free 1 10 1 sandbox.demo only
Starter €49/mo — 7-day free trial 2 10 1 All live channels
Pro €199/mo 10 1000 5 All live channels

Need bigger limits, SLAs or on-prem? Contact us.

Upgrades and cancellations are self-service (portal → Billing, powered by Stripe) and propagate to open connections within seconds. When a subscription ends, the account returns to Sandbox — keys keep working, scoped back to the demo channel.

The ack frame always tells you the effective limits of the connection you just opened — prefer reading it over hardcoding this table.


Examples

All examples: set your key, run, watch observations arrive. On sandbox, subscribe to sandbox.demo and use the portal's Send test alert.

Python

# pip install websockets
import asyncio, json
from urllib.parse import urlencode

import websockets

KEY = "mts_live_YOUR_KEY"
CHANNELS = ["metar.obs.eglc", "metar.obs.rksi"]

async def stream():
    url = "wss://stream.metar.ws/v1/ws?" + urlencode({"key": KEY})
    async with websockets.connect(url) as ws:
        ack = json.loads(await ws.recv())
        print("connected:", ack["plan"], ack["limits"])

        await ws.send(json.dumps({"action": "subscribe", "channels": CHANNELS}))

        async for frame in ws:
            msg = json.loads(frame)
            if msg["type"] == "publication":
                d = msg["data"]
                print(f'{d["station"]}: {d["temp_c"]}°C at {d["report_time"]}')
            elif msg["type"] == "error":
                print("error:", msg["code"], msg.get("channel", ""))

async def main():
    while True:                      # reconnect loop — deploys/blips are normal
        try:
            await stream()
        except Exception as e:
            print(f"disconnected: {e} — reconnecting in 3s")
        await asyncio.sleep(3)

asyncio.run(main())

Node.js

// npm install ws
import WebSocket from "ws";

const KEY = "mts_live_YOUR_KEY";
const CHANNELS = ["metar.obs.eglc", "metar.obs.rksi"];

function connect() {
  const ws = new WebSocket(
    `wss://stream.metar.ws/v1/ws?key=${encodeURIComponent(KEY)}`,
  );

  ws.on("message", (buf) => {
    const msg = JSON.parse(buf.toString());
    switch (msg.type) {
      case "ack":
        console.log("connected:", msg.plan, msg.limits);
        ws.send(JSON.stringify({ action: "subscribe", channels: CHANNELS }));
        break;
      case "publication":
        console.log(`${msg.data.station}: ${msg.data.temp_c}°C (${msg.data.report_time})`);
        break;
      case "error":
        console.error("error:", msg.code, msg.channel ?? "");
        break;
    }
  });

  ws.on("close", () => setTimeout(connect, 3000)); // reconnect loop
  ws.on("error", (e) => console.error("ws error:", e.message));
}

connect();

Go

package main

import (
    "encoding/json"
    "log"
    "net/url"
    "time"

    "github.com/gorilla/websocket"
)

const key = "mts_live_YOUR_KEY"

var channels = []string{"metar.obs.eglc", "metar.obs.rksi"}

type frame struct {
    Type    string   `json:"type"`
    Plan    string   `json:"plan,omitempty"`
    Code    string   `json:"code,omitempty"`
    Channel string   `json:"channel,omitempty"`
    Data    struct {
        Station    string `json:"station"`
        TempC      string `json:"temp_c"`
        ReportTime string `json:"report_time"`
        Raw        string `json:"raw"`
    } `json:"data,omitempty"`
}

func stream() error {
    u := url.URL{Scheme: "wss", Host: "stream.metar.ws", Path: "/v1/ws",
        RawQuery: url.Values{"key": {key}}.Encode()}
    ws, _, err := websocket.DefaultDialer.Dial(u.String(), nil)
    if err != nil {
        return err
    }
    defer ws.Close()

    var ack frame
    if err := ws.ReadJSON(&ack); err != nil {
        return err
    }
    log.Printf("connected: plan=%s", ack.Plan)

    sub := map[string]any{"action": "subscribe", "channels": channels}
    if err := ws.WriteJSON(sub); err != nil {
        return err
    }

    for {
        var msg frame
        if err := ws.ReadJSON(&msg); err != nil {
            return err
        }
        switch msg.Type {
        case "publication":
            log.Printf("%s: %s°C at %s", msg.Data.Station, msg.Data.TempC, msg.Data.ReportTime)
        case "error":
            log.Printf("error: %s %s", msg.Code, msg.Channel)
        }
    }
}

func main() {
    for { // reconnect loop
        if err := stream(); err != nil {
            log.Printf("disconnected: %v — reconnecting in 3s", err)
        }
        time.Sleep(3 * time.Second)
    }
}

Browser

<script>
  const ws = new WebSocket(
    "wss://stream.metar.ws/v1/ws?key=mts_live_YOUR_KEY",
  );
  ws.onmessage = (ev) => {
    const msg = JSON.parse(ev.data);
    if (msg.type === "ack") {
      ws.send(JSON.stringify({ action: "subscribe", channels: ["sandbox.demo"] }));
    } else if (msg.type === "publication") {
      console.log(msg.data.station, msg.data.temp_c + "°C");
    }
  };
</script>

⚠️ Anything shipped to a browser exposes your API key to whoever opens DevTools. Use browser connections for internal dashboards and demos; for public-facing products, proxy the stream through your backend.


Support: hello@metar.ws