Base URL: https://api.wxalerts.org
The Storm Prediction Center’s convective outlooks: Day 1 to Day 3 categorical, and the Day 1 tornado, hail and wind probabilities. One route lists what is issued, the other returns one outlook’s risk areas.
GET /outlooks
GET /outlooks/day1-categorical/geojson
GET /outlooks/day1-tornado/geojson
No key needed. Browsers block calls from other websites (CORS allows only
wxalerts.org), so call it from a server, script or app. Responses carry Cache-Control: public, max-age=300, and
SPC is polled about every five minutes, so there is nothing to gain from
asking more often. Limits are on the overview.
Which outlooks are issued
GET /outlooks lists the six map products without geometry. Build a product
picker from it rather than from a hard-coded list: a product that has never
been fetched is absent, and one whose polling is failing says so.
{
"server_time": "2026-11-09T19:06:41.749666Z",
"outlooks": [
{
"product": "day1-categorical",
"day": 1,
"hazard": "categorical",
"label": "Day 1 categorical",
"note": "Today's severe risk in the five named categories, from General Thunderstorms through High. Valid to 12Z tomorrow.",
"issued_at": "2026-11-09T16:09:00Z",
"valid_from": "2026-11-09T16:30:00Z",
"valid_to": "2026-11-10T12:00:00Z",
"forecaster": "Mosier/Moore",
"areas": 1,
"empty_label": null,
"fetched_at": "2026-11-09T19:03:05.075558Z",
"changed_at": "2026-11-09T16:20:25.151796Z",
"expired": false,
"stale": false,
"last_error": null
},
{
"product": "day1-tornado",
"day": 1,
"hazard": "tornado",
"label": "Day 1 tornado probability",
"note": "The chance of a tornado within 25 miles of a point, today. The hatched area is where 10% or more of those would be EF2 or stronger.",
"issued_at": "2026-11-09T16:09:00Z",
"valid_from": "2026-11-09T16:30:00Z",
"valid_to": "2026-11-10T12:00:00Z",
"forecaster": "Gleason",
"areas": 0,
"empty_label": "Less Than 2% All Areas",
"fetched_at": "2026-11-09T19:03:05.075558Z",
"changed_at": "2026-11-09T16:20:25.151796Z",
"expired": false,
"stale": false,
"last_error": null
},
"..."
]
}
Outlooks come back ordered by day, then hazard alphabetically.
| field | |
|---|---|
product |
the slug to pass to /outlooks/{product}/geojson |
day, hazard |
which day (1 to 3) and which outlook: categorical, tornado, hail or wind |
label, note |
a short name, and one sentence on what the outlook actually claims. Show the note; it is the difference between “15% wind” and “a 15% chance of 58 mph gusts within 25 miles” |
issued_at |
when the forecaster published this version |
valid_from, valid_to |
the period the outlook is about. Day 1 runs to 12Z tomorrow whatever time it was issued, so valid_to is not when it goes stale |
forecaster |
as SPC signs it |
areas |
how many risk areas there are to draw. Zero is a normal, common answer |
empty_label |
SPC’s own phrasing when there is nothing to draw, usually "Less Than 5% All Areas" ("Less Than 2% All Areas" for tornado). null when there are areas |
fetched_at |
when wxalerts last reached SPC for this product |
changed_at |
when the content last actually changed. It lags fetched_at by design, since most polls find nothing new |
expired |
valid_to is in the past. Say so on the map rather than drawing it silently |
stale |
polling has failed for more than three of its usual intervals (about 15 minutes for these products). What is stored may still be right; it is no longer confirmed |
last_error |
why the last poll failed, or null after a success |
One outlook’s areas
GET /outlooks/{product}/geojson returns a GeoJSON FeatureCollection with
two extra members: server_time, and outlook, the same summary object as
above. There is no bounding box parameter: an outlook is a handful of national
polygons totalling a few kilobytes, and the point of the layer is the shape of
the day across the country.
{
"type": "FeatureCollection",
"server_time": "2026-11-09T19:06:50.484971Z",
"outlook": {
"product": "day1-categorical", "day": 1, "hazard": "categorical",
"label": "Day 1 categorical",
"note": "Today's severe risk in the five named categories, from General Thunderstorms through High. Valid to 12Z tomorrow.",
"issued_at": "2026-11-09T16:09:00Z", "valid_from": "2026-11-09T16:30:00Z", "valid_to": "2026-11-10T12:00:00Z",
"forecaster": "Mosier/Moore", "areas": 1, "empty_label": null,
"fetched_at": "2026-11-09T19:03:05.075558Z", "changed_at": "2026-11-09T16:20:25.151796Z",
"expired": false, "stale": false, "last_error": null
},
"features": [
{
"type": "Feature",
"geometry": {
"type": "MultiPolygon",
"coordinates": [[[[-101.06, 30.7], [-99.82, 31.12], [-98.8, 31.09], "...", [-101.06, 30.7]]], "..."]
},
"properties": {
"product": "day1-categorical",
"level": "TSTM",
"rank": 2,
"label": "General Thunderstorms Risk",
"stroke": "#55BB55",
"fill": "#C1E9C1",
"significant": false
}
}
]
}
| property | |
|---|---|
level |
TSTM, MRGL, SLGT, ENH, MDT or HIGH on a categorical outlook. On a probability outlook, the percentage as a string: "5%", "15%", "30%" and so on. "SIG" for the hatched significant area |
rank |
the order the level sorts in. SPC’s own number for categorical levels (2, 3, 4, 5, 6, 8), the percentage itself for probabilities, and 100 for SIG |
label |
SPC’s wording for the level, such as "Slight Risk" |
stroke, fill |
SPC’s own colors, as hex |
significant |
true on the hatched significant-severe area |
Coordinates are rounded to four decimal places (about 11 m) and are not simplified, so a boundary matches the one SPC drew.
Drawing it
Use stroke and fill. The colors of a convective outlook are the product,
and readers already know how to read them.
Features come back lowest risk first, which is also the order to draw them: a Slight Risk area sits inside the General Thunderstorms area it is part of, and drawing them the other way round hides it.
On the tornado, hail and wind outlooks, significant: true marks the hatched
10% significant-severe area, and it is always the last feature. Hatch it
rather than filling it. It is a second statement about ground the
probabilities have already colored (“of those storms, 10% or more would be EF2
or stronger”), and a solid fill reads as a higher probability than SPC issued.
An outlook with nothing to draw returns zero features, with a 200. That is
not an error. The summary’s empty_label carries the forecaster’s phrasing,
and is how a client tells no risk (areas: 0 with an empty_label) from no
data.
Every product
The six map products, the ones /outlooks lists:
{product} |
|
|---|---|
day1-categorical |
today’s categorical risk, to 12Z tomorrow |
day2-categorical |
tomorrow, 12Z to 12Z |
day3-categorical |
the day after tomorrow, 12Z to 12Z |
day1-tornado |
chance of a tornado within 25 miles, with EF2+ hatching |
day1-hail |
chance of 1 inch or larger hail within 25 miles, with 2 inch hatching |
day1-wind |
chance of 58 mph or stronger gusts within 25 miles, with 75 mph hatching |
The route also serves SPC’s other products, which /outlooks does not list.
Read their summary from the outlook member of their own response.
{product} |
|
|---|---|
severe-day4 to severe-day8 |
Day 4 to 8 severe probability. SPC draws only 15% and 30% |
fire-day1, fire-day2 |
fire weather from wind and low humidity: levels ELEV, CRIT, EXTM |
drythunder-day1, drythunder-day2 |
dry thunderstorms: levels IDRT, SDRT |
fire-day3 to fire-day8 |
probability of critical fire weather |
drythunder-day3 to drythunder-day8 |
probability of dry thunderstorms |
md |
active mesoscale discussions: level and rank are the discussion number, label its “concerning” line. empty_label is "No active mesoscale discussions" when there are none |
Errors
| status | |
|---|---|
404 |
not a product slug. The detail lists every valid one |
503 |
a real product that has never been fetched on this deployment. Retry later rather than dropping it from a menu |

