Source: src/wxd/web/reference.md
Reference
Contract for this process. Background reading on another product lives in metar.ws (reference)/. That folder is not this API.
The board, the cities list, the feeds list, history, this page, and the spec are served on the same host as the socket. On mobs.ws the board, the cities list, this page, and the spec are static files. The feeds list and history are drawn by the feed process. GET /v1/status, GET /v1/latest, GET /v1/history, and GET /v1/stations are helpers. They are not stream frames.
Access
The socket, GET /v1/latest, and GET /v1/history need an access token. Send it one of three ways:
Authorization: Bearer <token>header. Use this from servers and scripts.?token=<token>on the URL. Browsers cannot set headers on a WebSocket, so the board uses this one.- The
mobs_tokencookie. The board sets it so the History page can read reports.
A missing or unknown token gets HTTP 401 with {"error":"unauthorized","message":"a valid access token is required"} and WWW-Authenticate: Bearer. On the socket that happens before the upgrade, so no ack is sent. The token check runs before the connection cap. A good token over the cap still gets 503.
Limits
Each token has its own limits. A token may hold 5 sockets at once. It may make 60 GET /v1/latest requests and 30 GET /v1/history requests in any 60 seconds. Over a limit gets HTTP 429 with {"error":"rate_limited","message":"..."} and a Retry-After header in whole seconds. A refused request does not count. The sixth socket gets 429 before the upgrade, and the open sockets stay up. The token check comes first, then the token's socket limit, then the server-wide cap of 256 sockets, which answers 503. The windows live in memory, so a restart clears them. Polling GET /v1/latest once a second stays inside the limit. For anything faster, use the socket.
The pages, the stylesheet, the page script, GET /v1/status, and GET /v1/stations stay public. They hold no weather reports and no token information. The board asks for a token before it shows any data.
Tokens are issued by the operator. There are no accounts. The server keeps only a SHA-256 digest of each token and a label, and it logs the label, never the token.
Socket
Connect to /v1/ws on this host with a token. Any other query parameter is ignored. The server speaks first:
{"type":"ack","service":"wxd","protocol":1}
The client sends text frames:
{"action":"subscribe","channels":["obs.knyc","hf.knyc","cli.knyc","atis.klax","hrrr.klga"]}
unsubscribe uses the same shape. Names are stripped and lowercased.
| Channel | Meaning |
|---|---|
obs.<icao> | Latest official METAR or SPECI for one collected station |
hf.<icao> | Latest MADIS one-minute ASOS for one station |
cli.<icao> | Latest NWS daily climate report for one station |
atis.<icao> | Latest FAA D-ATIS observation for one airport |
hrrr.<icao> | Latest HRRR 2 m temperature rollup for one station |
nbm.<icao> | Latest NBM 2 m temperature rollup for one station |
gfs.<icao> | Latest GFS 2 m temperature rollup for one station |
obs.* | Every official station currently cached |
hf.* | Every minute station currently cached |
cli.* | Every climate station currently cached |
atis.* | Every D-ATIS station currently cached |
hrrr.* | Every HRRR station currently cached |
nbm.* | Every NBM station currently cached |
gfs.* | Every GFS station currently cached |
<icao> is four letters or digits. obs.* is only the official feed. A client that wants each feed subscribes to each. Central Park has no D-ATIS bulletin, so atis.knyc replays nothing.
One reply covers the accepted names, then one error for each rejected name, then the cached values sorted by channel:
{"type":"subscribed","channels":["obs.knyc"]}orunsubscribed{"type":"error","code":"channel_format","message":"...","channel":"nope"}when a name was bad{"type":"publication","channel":"obs.knyc","published_at":"...","data":{...}}
Error codes are bad_request, unknown_action, and channel_format. An error leaves the socket up. There is no plan check and no channel cap.
A publication looks like this:
{"type":"publication","channel":"obs.knyc","fresh":true,"published_at":"2026-10-29T23:41:24.512Z","data":{"station":"KNYC","revision":0}}
fresh: true means the product just arrived or changed and was pushed to subscribers. A value that was already in memory when you subscribed, including the snapshot after the first fill, has no fresh field. The server never sends fresh: false. After 20 seconds with nothing to write, the server sends {"type":"ping"}. Ignore it. The connection also uses protocol pings. A client that falls 1000 live messages behind is closed with code 1008 and reason slow consumer. Replay frames do not count toward that limit.
published_at is when this process sent the frame, UTC with milliseconds. It sits beside data, not inside it, so it is not part of the report and history does not keep it. Every frame from one poll carries the same stamp, and every frame of one subscribe replay carries the same stamp.
Missing values are left out. Empty strings and empty lists are left out. Zero and true stay.
Official reports
Source is the NOAA Aviation Weather Center METAR cache, every 1.5 seconds from 15 seconds past each minute to the top of the minute, when new files usually appear, and every 5 seconds otherwise. The poll sends only If-Modified-Since. The cache sits behind mirrors that are out of step, and a copy older than the newest one already decoded is ignored. The file is worldwide. Version 1 keeps the stations listed on the Cities page. Other ids in the cache are dropped. A channel name is still four letters or digits. A subscription for a station this process does not keep replays nothing.
source is awc. The channel is obs.<icao>. METAR and SPECI share that channel. report_type says which. The raw report is the cache text, unchanged.
The identity of a report is station, report type, and report time. The same body is not a second event. receipt_time, revision, and cache_modified_at move on fetch and are not part of that comparison. A different body for the same identity is a correction: revision increments from 0. Subscribe replays the newest report_time on that channel. An older slot can still be pushed once the feed is live, and it does not replace that newest value.
ingest_age_s is whole seconds from report_time to receipt_time, truncated toward zero. poll_lag_ms is milliseconds from cache_modified_at to receipt_time, and it is omitted when the cache header was missing. Both are set only when this process first decodes that observation after the opening fill. The fill leaves them off, because those reports were already in the cache. A correction keeps the first values, while its own receipt_time is when the new body was decoded. Neither field is part of the report identity. History keeps them with the report.
| Field | Form |
|---|---|
station | ICAO |
report_type | METAR or SPECI |
report_time | Observation time, UTC, Z |
expected_time | Next routine METAR time for this station, UTC, Z. The minute is fixed per station. A report already on that minute points at the following hour. A SPECI points at the next routine slot and does not change the minute |
receipt_time | When this process decoded the row |
cache_modified_at | Cache Last-Modified, when the header was present |
ingest_age_s | Whole seconds from report_time to the first receipt_time after the opening fill. Omitted on that fill |
poll_lag_ms | Milliseconds from cache_modified_at to that same receipt. Omitted when the header was missing |
raw | Report text |
temp_c, dewp_c | Decimal strings, one place |
wdir | Degrees. Omitted when the wind is variable |
wdir_vrb | true when the cache says VRB |
wspd_kt, wgst_kt | Integers. Gust omitted when absent |
visib_sm | The cache token, including a trailing + or a leading M |
visib_m | Statute miles times 1609.344, half-up, only for an exact value |
visib_or_greater | true for 10+ and 6+. No visib_m in that case |
visib_or_less | true for a leading M |
altim_hpa | Decimal string. 29.92 inHg is 1013.25. 30.49 inHg is 1032.55 |
slp_mb | Decimal string, one place |
wx | Present-weather tokens |
clouds | {cover, base_ft} when the cache has a base |
flt_cat | Copied from NOAA. Omitted when the cache says null. Not computed here |
corrected | Present and true only when the cache says TRUE |
revision | Integer added by the store |
Minute reports
Source is NCEP MADIS HF-METAR, classic NetCDF, about every 7 seconds. The poller keeps the same stations as the official feed. During the first 10 minutes of an hour the previous hour file is read too. A 404 on a file that is not published yet is a normal poll.
source is madis. The channel is hf.<icao>. There is no raw, no report_type, and no flt_cat. The rawMessage column in the file is an LDAD dump, not a METAR, so it is not published.
Temperatures are decimal strings converted from kelvin. Wind is knots converted from metres per second. Altimeter is hPa converted from pascals. A sky base is feet converted from metres. CLR, SKC, NCD, NSC, and CAVOK omit the base. A value is omitted when it is NaN or the absolute value is at least 1e30.
Each of temperature, dewpoint, wind direction, wind speed, wind gust, visibility, and altimeter has a MADIS data descriptor. The value is also omitted when that character is X (failed the validity check), Q (failed a later check), B (marked bad), or I (interpolated). C, S, V, G, Z, T, and W stay. Z means MADIS applied no check. A file with no descriptor for that variable is treated the same way. The character is not copied onto the event. Sky cover and present weather have no descriptor in this file.
The identity is station and report time. Revision works the same way as on the official feed.
| Field | Form |
|---|---|
station, report_time, receipt_time, revision | Same clocks and counter as the official feed |
temp_c, dewp_c | Decimal strings |
wdir, wspd_kt, wgst_kt | Direction in degrees, speed and gust in knots |
visib_m | Metres, integer |
altim_hpa | Decimal string |
wx | Tokens from the file |
clouds | {cover} or {cover, base_ft} |
source | madis |
Climate reports
Source is the NWS API, api.weather.gov, about every 45 seconds. The poll reads the CLI location list for each code on the Cities page, then the product body when the newest product id changes. When that product is preliminary, the newest full-day body in the latest eight products is fetched too. Love Field (KDAL) has no location code in that API, so it is not requested. Downtown Los Angeles (KCQT) is not in the city list. A subscription for a station this process does not keep replays nothing.
source is nws. The channel is cli.<icao>. Temperatures are whole degrees Fahrenheit, integers, taken from the TEMPERATURE (F) block. M and MM are omitted. The raw product text is kept.
The identity of a report is the station and the NWS product id. receipt_time and revision are not part of that comparison. A different body for the same product id is a correction: revision increments from 0. The channel's cached value is the newest issuance. During the afternoon that is the preliminary for the day still in progress. The newest full-day report is cached beside it and replayed on the same channel, including after a restart. A subscriber who is connected when a new product is issued still receives it. When the newest issuance is itself the full-day report, that is the only cached value.
climate_date is the calendar date printed on the summary line. It is not shifted to UTC. report_time is when NWS issued the product, in UTC, not the climate date. preliminary is false on the full-day report, which has no valid-as-of line. That false is present in the JSON. A time may be printed as 259 PM or 4:13 PM. The event uses 2:59 PM and 4:13 PM.
| Field | Form |
|---|---|
station | ICAO |
climate_date | YYYY-MM-DD from the summary line |
preliminary | true when the product says it is valid as of a local time. Otherwise false |
valid_as_of | Local time on a preliminary, such as 4:00 PM. Omitted on a full-day report |
max_f, min_f | Whole degrees Fahrenheit |
max_time_local, min_time_local | Local time of that temperature, such as 2:59 PM |
report_time | Issuance time, UTC, Z |
receipt_time | When this process decoded the product |
raw | Full product text |
source | nws |
product_id | NWS product id |
issuing_office | Office id, such as KOKX |
revision | Integer added by the store |
polymarket.com still settles on the routine METAR temperature remark. Polymarket US settles on this climate report. The two numbers are not the same field.
D-ATIS
Source is the public mirror at https://atis.info/api/<icao>, every 5 seconds from :45 to :05 past each hour and every 15 seconds otherwise, all airports at once, for each airport on the Cities page that has a bulletin. Central Park (KNYC) is not requested. This process is not a SWIM client. source is atis.info.
raw is the original bulletin text. When arrival and departure share an observation time, both bulletins are kept, separated by a blank line. No METAR string is rebuilt, so the event has no rebuilt-raw flag. weather is the weather sentence copied out of the preferred bulletin. Parsed fields come from that sentence. A remarks group T replaces the whole-degree temperature and dewpoint with tenths. 10SM stays 10 statute miles. It is not the official-feed 10+ limit.
The observation time is the HHMMZ printed in the bulletin. The mirror's updatedAt supplies the calendar day. There is no expected_time. special is present and true only when the bulletin says SPECIAL.
The identity is the station and that observation time. A different weather sentence at the same time is a correction and revision increments. An information-letter change, or a runway line, with the same weather sentence is not offered.
This bulletin does not settle the temperature contracts. polymarket.com still uses the routine METAR temperature remark. Polymarket US still uses the climate report.
| Field | Form |
|---|---|
station | ICAO |
report_type | ATIS |
report_time | Observation time printed in the bulletin, UTC, Z |
receipt_time | When this process decoded the bulletin |
source | atis.info |
special | true when the bulletin says SPECIAL. Omitted otherwise |
codes | {type, code} for each bulletin at this observation time. type is combined, arr, or dep. code is the information letter |
weather | Weather sentence copied from the bulletin |
raw | Original bulletin text. Two bulletins are separated by a blank line |
temp_c, dewp_c | From the T group when it is present, one decimal place. Otherwise the whole degrees spoken in the bulletin, with no invented tenth |
wdir, wdir_vrb, wspd_kt, wgst_kt | Same roles as the official feed |
visib_sm, visib_m, visib_or_greater, visib_or_less | Same roles as the official feed. A leading P becomes a greater-than limit |
altim_hpa | From the A group, same conversion as the official feed |
slp_mb | From an SLP remark, one decimal place |
wx, clouds | Tokens and {cover, base_ft} from the weather sentence |
revision | Integer added by the store |
Model grids
Source is NOAA NOMADS, about every 30 minutes. The channels are hrrr.<icao>, nbm.<icao>, and gfs.<icao>. source is nomads. This process reads the GRIB index and fetches the 2 m temperature message by byte range. It keeps the nearest cell. It does not download the whole grid.
report_time is the model run time. A later cycle replaces the cached value. More hours from the same run are a correction: revision increments. There is no expected_time, and no ingest-age fields. Station latitude and longitude stay on GET /v1/stations. grid_lat, grid_lon, and grid_km are the sampled cell, not the station.
Hours are every 3 through 48. NBM has no hour 0, so that walk starts at hour 3. A cycle is used once forecast hour 18 is on the server. A newer cycle that has only the first few hours does not replace it. days is the local-day high and low in degrees Celsius, one decimal place. A sample belongs to the local date that contains it. The sample at local midnight belongs to the new day. day_complete is true only when the widest gap from local midnight to the next midnight, including both ends, is 6 hours or less. A partial day is still published, with day_complete false. Empty days are omitted.
receipt_time is when that poll of the run began. These forecasts do not settle the temperature contracts. polymarket.com still uses the routine METAR temperature remark. Polymarket US still uses the climate report.
| Field | Form |
|---|---|
station | ICAO |
model | hrrr, nbm, or gfs |
source | nomads |
report_time | Model run time, UTC, Z |
receipt_time | When this process began the poll that produced the event |
grid_lat, grid_lon | Sampled cell, decimal degrees, four places. Longitude is −180..180 |
grid_km | Distance from the station to that cell, kilometres, one decimal place |
days | {date, max_c, min_c, day_complete, hours, gap_h, through}. date is the local calendar date. through is the latest sample in that day, UTC. hours is how many samples fell in the day. gap_h is the widest gap in hours |
revision | Integer added by the store |
Latest
GET /v1/latest returns the value each channel holds now, the same frames a subscribe replay sends, as one JSON object. It needs a token.
curl -s -H "Authorization: Bearer $MOBS_TOKEN" "https://mobs.ws/v1/latest?feed=obs,cli&station=KNYC,KLGA"
feed is optional, a comma list of obs, hf, cli, atis, hrrr, nbm, and gfs. It defaults to all seven. station is optional, a comma list of up to 50 ids of four letters or digits. It defaults to every station cached. A bad value returns 400 and {"error":"..."}.
{"service":"wxd","published_at":"2026-10-30T02:51:40.120Z","count":1,"publications":[{"type":"publication","channel":"obs.knyc","published_at":"2026-10-30T02:51:40.120Z","data":{"station":"KNYC","revision":0}}]}
Publications are sorted by channel and never carry fresh. A climate channel can return two frames: the newest issuance and the newest full-day report. A station with nothing cached is left out, so an empty list is a 200. Poll this for a snapshot. Use the socket for new reports as they arrive.
Stations
GET /v1/stations returns the catalog this process collects. Each object has station, place, polymarket_com, polymarket_us, routine (such as :51), cli (empty when there is no daily climate product), atis (true when that airport has a D-ATIS bulletin), lat, lon, and elev_m. Latitude and longitude are degrees. Elevation is metres. The numbers are the Aviation Weather Center station coordinates read on 26 October 2026. They are not repeated on each weather event. The Cities page is the same list.
Status
GET /v1/status is public and needs no token. version is the release this process runs, the git describe of the deployed commit, or dev for a checkout. It returns JSON for the board: whether each feed has finished its first fill, how many stations are in the last-value cache, and the last poll. Nulls are allowed here. Stream publications still omit empty fields.
Each of obs, hf, cli, atis, and grid includes trouble. It is null when that feed has not failed, or when the last failure recovered more than 30 minutes ago. While the feed is failing, trouble has failing: true, a consecutive count, at, detail, and recovered_at empty. After it recovers, the same object stays for 30 minutes with failing: false and recovered_at set. detail is poll failed, stations such as KSEA missed, a minute file name plus failed, or model names such as hrrr, nbm failed. A minute file that is not published yet (HTTP 404) is not a failure. grid is one line for HRRR, NBM, and GFS. live is true when any of the three has finished its first fill. stations counts stations that have at least one model cached. A model failure is not written onto the minute feed. The status body does not include exception text.
Each of obs, hf, cli, and atis includes lag: null until a new upstream version arrives after the opening fill, then n, p50_ms, p90_ms, and last_ms over the newest 200 samples. For obs and hf it is milliseconds from the file's Last-Modified to published_at. For cli it is from the newest product's issuance time. For atis it is from the mirror's updatedAt. obs.replies counts METAR cache downloads, new versions decoded, and mirror copies skipped as older or same. Each hf file has modified_at, the file's Last-Modified.
History
GET /v1/history needs a token. It returns reports this process has decoded since it started. They stay in memory for 48 hours, measured by report_time. A restart clears them. This is not an archive.
station is required and is four letters or digits. kind is optional: obs, hf, cli, atis, hrrr, nbm, or gfs. from is inclusive and to is exclusive. Both are whole seconds, YYYY-MM-DDTHH:MM:SSZ. limit defaults to 500 and must be from 1 to 2000. The response keeps the newest rows and lists them oldest first. matched is the count before that limit. count is how many rows came back. Each row is {"kind","data"}, and data is the same object as a stream publication, including revision.
A repeated fingerprint is one row. A correction is another row with the next revision. The quiet first fill is included. An unknown station that matches the id shape returns 200 and an empty list. A bad query returns 400 and {"error":"..."}.
GET /history is the same query as a page. The page shows at most 100 rows unless limit is set, newest first, and it does not print the raw text. It links to the JSON for that query. The page reads the mobs_token cookie the board sets. Without a valid token a search returns 401 and no rows.