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.
- Stream endpoint:
wss://stream.metar.ws/v1/ws - Customer portal (keys, billing):
https://app.metar.ws
Quick start
Sign up at app.metar.ws. New accounts start on the free Sandbox plan — no card required.
Create an API key on the Keys page. The key (
mts_live_…) is shown once — store it like a password.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"]}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.
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:
The only subscribable channel is
sandbox.demo.Live
metar.obs.*channels answerpermission_denied.The Send test alert button (portal → Billing) publishes a fixed demo observation to your live connections only:
{ "type": "publication", "channel": "sandbox.demo", "data": { "station": "DEMO", "temp_c": "21.50", "report_time": "2026-07-12T16:34:15Z", "raw": "METAR DEMO 011200Z 24008KT CAVOK 21/12 Q1018 NOSIG", "dewp_c": "12", "wdir": 240, "wspd_kt": 8, "visib_m": 9999, "cavok": true, "altim_hpa": 1018 } }datahas the identical shape to live observations, so the parser you write against sandbox runs unchanged in production.report_timeis stamped fresh on every alert (useful for testing your dedup logic). Test alerts are rate-limited: minimum 5 s between alerts, 200 per day.
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:
rawis reconstructed from the ATIS text, not the official METAR string — the values match, the exact formatting may differ.The same observation later appears on the station's
metar.obs.*channel with the official text. Subscribing to both? Dedupe by (station,report_time).Only observations we can prove are new are published: ATIS re-broadcasts, information-letter changes and runway/NOTAM-only updates never produce events.
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:
- Last-value replay means you're current immediately after re-subscribing.
- A plan change closes your connections on purpose — the next connect picks up the new plan's limits. Treat it like any other disconnect.
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