wxd

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, GET /v1/latency, and GET /v1/history need an access token. Send it one of three ways:

A missing, unknown, or expired 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.

A token has scopes. Every scope is read-only; nothing on this host writes. stream opens the socket. latest reads GET /v1/latest and GET /v1/latency. history reads GET /v1/history and the rows on the History page. A token issued without a scope list has all three, which is every token issued before 30 October 2026. A token without the scope a path needs gets HTTP 403 with {"error":"forbidden","message":"this token does not have the <scope> scope"}, before the upgrade on the socket. The ack lists the token's label and scopes.

Rotation: the operator issues a second token for the same label and gives the old one an expiry, 24 hours unless chosen otherwise. Both work until then, so a client can switch without a gap. After the expiry the old token gets 401. Limits are per label, so the two tokens share them. There is no rotation endpoint: tokens are kept in a file the feed process can read but not write.

Limits

Each token has its own limits. A token may hold 5 sockets at once. It may make 60 GET /v1/latest requests, 30 GET /v1/latency requests, and 30 GET /v1/history requests in any 60 seconds. Over a limit gets HTTP 429 with {"error":"rate_limited","message":"...","retry_after_s":N} 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 server-wide cap answers HTTP 503 with {"error":"unavailable","message":"too many connections","retry_after_s":30} and Retry-After: 30. Two handshakes that pass the check at the same moment can overshoot it; the extra one is closed right after the upgrade with code 1013 (try again later). Treat 1013 like the 503 and wait 30 seconds.

Reconnect accounting: a socket counts against its token from the upgrade until the server sees it close. A clean close frees the slot at once. A connection that drops without a close frame (a lost network, a killed process) holds its slot until the keepalive notices, at most 40 seconds. A client that reconnects faster than that after an unclean drop can see 429 while its old socket still counts; wait for Retry-After and try again.

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, its label, its scopes, and an expiry when one is set. It logs the label, never the token.

Socket

This is protocol 2, since 30 October 2026 (v0.7.0). Protocol 1 changes are listed under Protocol changes at the end of this section.

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":2,"server_time":"2026-10-30T05:10:00.123Z","boot_id":"3f9c2a7d1b0e4c55","seq":4821,"label":"rs-pm","scopes":["stream","latest","history"],"limits":{"max_sockets":5,"latest_per_min":60,"history_per_min":30,"latency_per_min":30,"live_queue_limit":1000,"warn_at":500,"replay_frames":5000},"keepalive":{"interval_s":20.0,"timeout_s":20.0},"deprecated":["fresh","snapshot"]}

server_time is this host's clock, UTC with milliseconds. Compare it with your own clock to estimate the offset. boot_id names this run of the process. seq is the number of the newest publication so far. A different boot_id after a reconnect means the server restarted and seq started again from 1. limits are this token's limits; null means that limit is off. deprecated names publication fields that are still sent but will be removed.

The client sends text frames, each one JSON object:

{"action":"subscribe","channels":["obs.knyc","hf.knyc","cli.knyc","atis.klax","hrrr.klga"],"id":"sub-1"}

Actions are subscribe, unsubscribe, ping, and status. unsubscribe uses the same shape as subscribe. Names are stripped and lowercased. A subscribe may also carry since_seq, boot_id, and snapshot (see Resume) and mode (see Slow clients). Unsubscribe ignores them. {"action":"ping"} gets {"type":"pong","ref":...,"server_time":"..."}, for round-trip time. {"action":"status"} gets a status frame (see Status frames).

Any request may carry id, a string of at most 64 characters or an integer. Every reply to that request carries it back as ref: subscribed, unsubscribed, pong, status, and each error. Without an id, ref is null. A bad id is a bad_request error with ref null, and the request is not carried out. Publications do not carry ref.

ChannelMeaning
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 replayed frames (see Resume), then the cached values sorted by channel, then, after the first subscribe on a socket, one status frame:

  1. {"type":"subscribed","ref":"sub-1","channels":["obs.knyc"],"seq":4821,"resume":null,"replayed":null,"mode":"all","stations":{"obs.knyc":["KNYC"]},"cadence":{"obs":{...}}} or {"type":"unsubscribed","ref":null,"channels":["obs.knyc"]}
  2. {"type":"error","ref":"sub-1","code":"channel_format","message":"...","channel":"nope"} when a name was bad
  3. {"type":"publication","channel":"obs.knyc","cached":true,"fresh":false,"snapshot":true,"replay":false,"seq":null,"published_at":"...","data":{...},"sent_at":"..."}
  4. {"type":"status","server_time":"...","changed":[],"feeds":{...}}

seq in subscribed is the newest publication number when the snapshot was taken. The snapshot reflects every publication up to it, so it is the number to resume from. resume and replayed are null unless the subscribe sent since_seq.

stations resolves each accepted name to the stations it covers here. A wildcard lists every station this process collects for that feed: all of the Cities page for obs, hf, and the models, the ones with a climate code for cli, the ones with a bulletin for atis. A single station that is not collected for that feed resolves to an empty list, and the subscription still stands. cadence has one entry per feed named in the request:

FieldMeaning
sourceThe feed's source value
poll_sSeconds between polls of the source
fast_poll_sSeconds between polls in the fast stretch (METAR: :15 to :00 of each minute; D-ATIS: :45 to :05 of each hour), null when the feed has none
expected_every_sHow often a new product is expected per station. 3600 for METAR, D-ATIS, HRRR, and NBM, 60 for the minute feed, 21600 for GFS, null for climate
scheduleThe same in words
routine_minuteobs and atis only: each station's routine METAR minute past the hour

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","cached":false,"fresh":true,"replay":false,"seq":4822,"published_at":"2026-10-29T23:41:24.512Z","data":{"station":"KNYC","report_id":"obs/KNYC/METAR/2026-10-29T23:41:00Z","revision":0},"sent_at":"2026-10-29T23:41:24.514Z"}

Fields are always in this order and always present, except the deprecated ones. cached: true marks a snapshot: a value that was already in memory when you subscribed, or the broadcast after a feed's first fill. Do not use those frames to measure latency. cached: false means the product just arrived or changed and was pushed to subscribers. This is the flag the project started with, and the one metar.ws uses. replay is true only on frames resent by a resume.

fresh and snapshot are the protocol 1 names for the same thing, kept for now so clients written for protocol 1 keep working: fresh is the opposite of cached, and snapshot: true appears only on cached frames. They are listed in the ack's deprecated and will be removed in a later release. Read cached.

seq numbers every publication this process broadcasts, live or the first-fill snapshot, across all channels, starting at 1. It only goes up. A restart starts it again, with a new boot_id. Snapshot frames sent in answer to a subscribe have seq null; the subscribed reply carries the number they are current to.

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. sent_at is the last field of every publication. It is when the frame was written to your socket, so sent_at minus published_at is time spent queued on the server for you.

Keepalive

Each socket has one keepalive timer, every 20 seconds. On each tick the server sends a protocol ping (RFC 6455), and, if it has written nothing to that socket for 20 seconds, also the text frame {"type":"ping","server_time":"..."}. Browsers and WebSocket libraries answer protocol pings by themselves. If neither a protocol pong nor any other frame comes back within 20 seconds, the server closes the socket with code 1011 and reason keepalive timeout. There is no other idle timeout: a socket with no subscriptions, or one waiting half an hour for the next METAR, stays open as long as pings are answered.

You may answer the text ping with {"type":"pong"} and any other fields, or ignore it. The server accepts a pong silently. Your side: on an idle socket you hear something at least every 20 seconds, so a client that hears nothing for 45 seconds should reconnect. The ack's keepalive gives both numbers.

Slow clients

By default ("mode":"all") every live frame is queued for you. A client that falls 1000 live messages behind is closed with code 1008 and reason slow consumer. Snapshot and replayed frames do not count toward that limit. At 500 queued, the server first sends {"type":"warning","code":"backpressure","queued":500,"limit":1000}, ahead of the queued frames. It warns again only after the queue drops below 250, and at most once every 30 seconds.

A subscribe with "mode":"latest" switches the whole socket to coalescing: a live frame for a value that still has an unsent frame in your queue replaces that frame in place, so you get the newest value sooner and the queue cannot grow past one frame per value. A value is one channel, except climate, where the full-day report and the preliminary beside it are two. Replaced frames are dropped, so seq skips on that channel; the skipped numbers are still in the resume buffer. "mode":"all" switches back. The subscribed reply says which mode the socket is in. Snapshot, replayed, and control frames are never coalesced.

Status frames

The server pushes {"type":"status","server_time":"...","changed":["obs"],"feeds":{...}} to every socket with at least one subscription when a feed finishes its first fill, starts failing, changes its failure detail, or recovers. It sends one after the first subscribe on a socket, with changed empty, and one in answer to {"action":"status"}, with ref. feeds has obs, hf, cli, atis, and grid (all three models):

FieldMeaning
liveThe feed has finished its first fill
failingThe last poll failed, or missed stations or files
consecutiveFailed polls in a row, 0 when not failing
detailWhat failed, as on GET /v1/status, null when not failing
failed_atWhen the latest failure was noted, null when not failing
last_poll_atWhen the last poll finished
next_poll_atWhen the next poll is due
expected_next_atobs: the earliest routine METAR still ahead across the stations. atis: the same for the airports with a bulletin. Null for the others, which have no fixed schedule

Protocol changes

Protocol 2 (v0.7.0, 30 October 2026) changed these protocol 1 fields. Update a protocol 1 client like this:

Resume

A client that reconnects can ask for the publications it missed instead of only a snapshot. Remember the boot_id from the ack and the highest seq you have seen, from publications or from subscribed. Then subscribe with them:

{"action":"subscribe","channels":["obs.*","hf.*"],"since_seq":4822,"boot_id":"3f9c2a7d1b0e4c55"}

The server keeps the newest 5000 broadcasts in memory, fewer if they pass 16 MB. The reply says how the resume went:

{"type":"subscribed","ref":null,"channels":["obs.*","hf.*"],"seq":4830,"resume":"full","replayed":3,"mode":"all","stations":{...},"cadence":{...}}

replayed is the count of replayed frames. "snapshot":false skips the snapshot in every case, with or without since_seq. "snapshot":true asks for it even after a full resume; it comes after the replayed frames. Send boot_id whenever you send since_seq. Without it, a restart can only be noticed when since_seq is higher than the new run's count. Resume only covers channels you had before. For a channel that is new to you, subscribe without since_seq. A bad since_seq, snapshot, boot_id, or mode is a bad_request error and nothing is subscribed.

Event fields

Every event carries these, whatever the feed. Times are ISO 8601 UTC with milliseconds. Clock fields are always present and null when unknown. Other fields are still left out when the report does not have them, rather than sent as a guessed value: empty strings and empty lists are left out, zero and false stay.

FieldMeaning
stationICAO
sourceWhere it came from: awc (the Aviation Weather Center METAR cache file), madis, nws, atis.info, or nomads. Each feed has exactly one upstream path; there is no tgftp or NOAAPort path
report_idThe report's identity, stable across revisions and restarts: the feed, station, and identity fields joined with /, such as obs/KNYC/METAR/2026-10-30T04:51:00Z or cli/KNYC/<product id>/<issuance>. Its time part keeps whole seconds
revision0 for the first body seen for that identity, then 1, 2, ... for each different body
correctiontrue when this event replaces an earlier body of the same report (revision above 0), or the official cache marks it corrected. Otherwise false
supersedesThe revision this one replaces, null on revision 0. With report_id it names the earlier event
report_timeThe time printed on the product (the model run for models)
receipt_timeWhen this process decoded it
source_timeThe source's own publish stamp: METAR cache and MADIS file Last-Modified, the NWS issuance time, the D-ATIS mirror's updatedAt. Null on models
ingest_age_sWhole seconds from report_time to receipt_time, on the first receipt after the opening fill. Null on the fill, and on models
poll_lag_msMilliseconds from source_time to that same receipt. Null on the fill, when source_time is missing, and on models

ingest_age_s and poll_lag_ms are measured once per report and kept on its corrections; a correction's own receipt_time is when the new body was decoded. Same body from another fetch is not an event, so a new receipt_time or source_time alone never publishes.

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 (here the same as source_time) to receipt_time, and it is null 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 null, 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.

FieldForm
stationICAO
report_typeMETAR or SPECI
report_timeObservation time, UTC, Z
expected_timeNext 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_timeWhen this process decoded the row
cache_modified_atCache Last-Modified, null when the header was missing. Same value as source_time
ingest_age_sWhole seconds from report_time to the first receipt_time after the opening fill. Null on that fill
poll_lag_msMilliseconds from cache_modified_at to that same receipt. Null when the header was missing
rawReport text
temp_c, dewp_cDecimal strings, one place
wdirDegrees. Omitted when the wind is variable
wdir_vrbtrue when the cache says VRB
wspd_kt, wgst_ktIntegers. Gust omitted when absent
visib_smThe cache token, including a trailing + or a leading M
visib_mStatute miles times 1609.344, half-up, only for an exact value
visib_or_greatertrue for 10+ and 6+. No visib_m in that case
visib_or_lesstrue for a leading M
altim_hpaDecimal string. 29.92 inHg is 1013.25. 30.49 inHg is 1032.55
slp_mbDecimal string, one place
wxPresent-weather tokens
clouds{cover, base_ft} when the cache has a base
flt_catCopied from NOAA. Omitted when the cache says null. Not computed here
correctedPresent and true only when the cache says TRUE
revisionInteger 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.

FieldForm
station, report_time, receipt_time, revisionSame clocks and counter as the official feed
temp_c, dewp_cDecimal strings
wdir, wspd_kt, wgst_ktDirection in degrees, speed and gust in knots
visib_mMetres, integer
altim_hpaDecimal string
wxTokens from the file
clouds{cover} or {cover, base_ft}
sourcemadis

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.

FieldForm
stationICAO
climate_dateYYYY-MM-DD from the summary line
preliminarytrue when the product says it is valid as of a local time. Otherwise false
valid_as_ofLocal time on a preliminary, such as 4:00 PM. Omitted on a full-day report
max_f, min_fWhole degrees Fahrenheit
max_time_local, min_time_localLocal time of that temperature, such as 2:59 PM
report_timeIssuance time, UTC, Z
receipt_timeWhen this process decoded the product
rawFull product text
sourcenws
product_idNWS product id
issuing_officeOffice id, such as KOKX
revisionInteger 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.

FieldForm
stationICAO
report_typeATIS
report_timeObservation time printed in the bulletin, UTC, Z
receipt_timeWhen this process decoded the bulletin
sourceatis.info
specialtrue 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
weatherWeather sentence copied from the bulletin
rawOriginal bulletin text. Two bulletins are separated by a blank line
temp_c, dewp_cFrom 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_ktSame roles as the official feed
visib_sm, visib_m, visib_or_greater, visib_or_lessSame roles as the official feed. A leading P becomes a greater-than limit
altim_hpaFrom the A group, same conversion as the official feed
slp_mbFrom an SLP remark, one decimal place
wx, cloudsTokens and {cover, base_ft} from the weather sentence
revisionInteger 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. source_time, ingest_age_s, and poll_lag_ms are null. 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.

FieldForm
stationICAO
modelhrrr, nbm, or gfs
sourcenomads
report_timeModel run time, UTC, Z
receipt_timeWhen this process began the poll that produced the event
grid_lat, grid_lonSampled cell, decimal degrees, four places. Longitude is −180..180
grid_kmDistance 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
revisionInteger added by the store

Latest

GET /v1/latest returns the value each channel holds now, as one JSON object. It needs a token with the latest scope.

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","protocol":2,"server_time":"2026-10-30T02:51:40.120Z","published_at":"2026-10-30T02:51:40.120Z","boot_id":"3f9c2a7d1b0e4c55","seq":4830,"count":1,"publications":[{"type":"publication","channel":"obs.knyc","cached":true,"replay":false,"seq":null,"published_at":"2026-10-30T02:51:40.120Z","data":{"station":"KNYC","revision":0},"age_s":40.12,"sent_at":"2026-10-30T02:51:40.120Z"}]}

Each frame is a socket snapshot frame without the deprecated fresh and snapshot, plus age_s: seconds from data.report_time to this response, with milliseconds, null when the event has no report time. seq in the envelope is the newest publication number, so a client can subscribe with since_seq and miss nothing. Publications are sorted by channel. 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.

Latency

GET /v1/latency needs a token with the latest scope. For each station and feed it gives the 50th and 90th percentile over the last 24 hours of live first versions (revision 0, not the opening fill, not corrections) on obs, hf, cli, and atis:

{"service":"wxd","server_time":"...","window_h":24.0,"stations":{"KNYC":{"obs":{"n":24,"age_p50_ms":151000,"age_p90_ms":203000,"lag_p50_ms":4100,"lag_p90_ms":9800}}}}

age is published_at minus report_time: from the observation to the push. lag is published_at minus source_time: from the source publishing it to the push. A percentile is null when no sample had that time. feed and station filter it like GET /v1/latest. The samples live in memory, so a restart starts the 24 hours again.

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. protocol is the socket protocol. server_time, boot_id, and seq are the same values an ack would give now. 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. Times are ISO 8601 UTC with milliseconds. Nulls are allowed here.

Each of obs, hf, cli, atis, and grid includes trouble, next_poll_at, and expected_next_at (as on status frames). trouble 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. Per-station figures are on GET /v1/latency.

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 YYYY-MM-DDTHH:MM:SSZ or YYYY-MM-DDTHH:MM:SS.sssZ. 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.