Storm Alerts
Register addresses. Get a signed HTTP POST when a qualifying hail or wind event lands within 10 miles. Built on the same NOAA Storm Events feed that powers the storm history block on /v1/enrich.
Overview
The Storm Alerts API is a webhook-push surface. You maintain a watchlist of (lat, lng) points; once a day we scan every active row against new entries in our storm_events table and POST a signed JSON payload to your configured URL for each match.
No polling, no separate auth plane, the same X-API-Key you use for /v1/enrich authenticates these endpoints too. A single webhook URL per key; all matched events for any address under that key fan out to the same endpoint.
Quickstart
Three calls and you're wired up:
# 1. Save your webhook URL — captures the signing secret.
curl -X PUT https://www.rooftap.app/api/v1/storm-alerts/webhook \
-H "X-API-Key: rt_live_abc123..." \
-H "Content-Type: application/json" \
-d '{ "url": "https://your-app.example.com/webhooks/storm-alerts" }'
# → response includes signing_secret (shown ONCE — store it).
# 2. Fire a test event so you can verify your handler.
curl -X POST https://www.rooftap.app/api/v1/storm-alerts/webhook/test \
-H "X-API-Key: rt_live_abc123..." -d '{}'
# 3. Add an address to the watchlist.
curl -X POST https://www.rooftap.app/api/v1/storm-alerts/watchlist \
-H "X-API-Key: rt_live_abc123..." \
-H "Content-Type: application/json" \
-d '{ "lat": 30.2672, "lng": -97.7431,
"address": "123 Main St, Austin, TX 78701",
"external_ref": "lead-9821" }'Prefer a UI? Everything above is also available in the dashboard at /dashboard/integrations/storm-alerts, set the URL, rotate the secret, add addresses, fire a test, see delivery history.
Authentication
Send your X-API-Key header on every request. Same rt_live_ (or rt_test_) key you use for /v1/enrich, no separate key plane.
Webhook deliveries themselves don't carry an X-API-Key, they're outbound from us to you. Authenticity is established via the RoofTap-Signature header (see below).
Match thresholds
An alert fires when a storm event in our database satisfies all of these:
- Distance: within 10 miles of the watchlist coordinate (haversine).
- Severity: hail ≥ 0.75" OR wind ≥ 50 mph.
- Recency: event_date strictly newer than the last event we already alerted you on for that address. First-scan floor is the watchlist row's
created_at, no historical noise the moment you register.
Cadence: the matcher runs once daily (~14:00 UTC, just after our NOAA ingest finishes). Up to 24h latency between a storm landing and your webhook firing. Sub-hour cadence is on the roadmap; ask if you need it.
Webhook config
One webhook URL per API key. PUT creates or updates; the response carries the signing secret only at create or explicit rotation.
Body fields
| Field | Type | Description |
|---|---|---|
| url | string · required | HTTPS URL we POST to on each alert. http://, loopback (localhost, 127.x), and .local / .internal hosts are rejected. |
| active | boolean | Set false to pause deliveries without losing the URL or cursors. Defaults true. |
| rotate_secret | boolean | On PUT, set true to mint a fresh signing_secret. The plaintext appears in the response exactly once. |
PUT https://www.rooftap.app/api/v1/storm-alerts/webhook
X-API-Key: rt_live_abc123...
Content-Type: application/json
{ "url": "https://your-app.example.com/webhooks/storm-alerts" }
200 OK
{
"ok": true,
"webhook": {
"id": "5a6...",
"url": "https://your-app.example.com/webhooks/storm-alerts",
"active": true,
"created_at": "2026-05-22T12:00:00Z"
},
"signing_secret": "whsec_4f9c...",
"secret_shown_once": true
}| Method | Path | What it does |
|---|---|---|
| PUT | /api/v1/storm-alerts/webhook | Create or update the URL. Pass rotate_secret: true to mint a fresh signing secret. |
| GET | /api/v1/storm-alerts/webhook | Read current config + counters. Secret is masked. |
| DELETE | /api/v1/storm-alerts/webhook | Remove the config. Alerts stop firing immediately. |
Watchlist
Each row is one (lat, lng) we monitor. Dedupe is on rounded coords (4 decimals, ~11m), re-adding the same point is a no-op and returns the existing row, not an error.
Body fields
| Field | Type | Description |
|---|---|---|
| lat | number · required | Latitude, decimal degrees. Range -90 to 90. Rounded to 4 decimals (~11m) on insert. |
| lng | number · required | Longitude, decimal degrees. Range -180 to 180. Rounded to 4 decimals (~11m) on insert. |
| address | string | Human-readable address. Echoed back unchanged in every webhook payload, store whatever helps your team route the alert. |
| external_ref | string | Your internal id for this address (lead id, account id, parcel id). Echoed back in every webhook payload so you don't need to track our id. |
Add one
POST https://www.rooftap.app/api/v1/storm-alerts/watchlist
X-API-Key: rt_live_abc123...
{
"lat": 30.2672,
"lng": -97.7431,
"address": "123 Main St, Austin, TX 78701",
"external_ref": "lead-9821"
}
201 Created
{
"ok": true,
"added": 1,
"addresses": [
{ "id": "8f2...", "lat": 30.2672, "lng": -97.7431,
"address": "123 Main St, Austin, TX 78701",
"external_ref": "lead-9821", "active": true,
"created_at": "2026-05-22T12:01:00Z" }
]
}Bulk add
Send up to 500 addresses per request:
POST https://www.rooftap.app/api/v1/storm-alerts/watchlist
X-API-Key: rt_live_abc123...
{
"addresses": [
{ "lat": 30.2672, "lng": -97.7431, "external_ref": "lead-9821" },
{ "lat": 32.7767, "lng": -96.7970, "external_ref": "lead-9822" },
{ "lat": 29.7604, "lng": -95.3698, "external_ref": "lead-9823" }
]
}
201 Created
{ "ok": true, "added": 3, "addresses": [ ... ] }| Method | Path | What it does |
|---|---|---|
| POST | /api/v1/storm-alerts/watchlist | Add one address or bulk (up to 500). |
| GET | /api/v1/storm-alerts/watchlist | List addresses with keyset pagination (?limit=100&cursor=<created_at>). |
| PATCH | /api/v1/storm-alerts/watchlist/[id] | Toggle active, update external_ref or address. |
| DELETE | /api/v1/storm-alerts/watchlist/[id] | Remove an address. Cursor history is lost. |
Webhook payload
All deliveries POST JSON with this exact shape. Test events are identical except type: "storm_alert.test" and a sentinel event.id of all zeros, skip them in production routing by branching on type.
POST https://your-app.example.com/webhooks/storm-alerts
Content-Type: application/json
RoofTap-Signature: t=1716379200,v1=8c2a...
User-Agent: RoofTap-StormAlerts/1.0
{
"type": "storm_alert",
"version": "v1",
"delivered_at": "2026-05-22T12:30:00Z",
"watchlist": {
"watchlist_id": "8f2...",
"external_ref": "lead-9821",
"lat": 30.2672,
"lng": -97.7431,
"address": "123 Main St, Austin, TX 78701"
},
"event": {
"id": "evt_a1b2...",
"noaa_event_id": "1234567",
"event_type": "Hail",
"event_date": "2026-05-22",
"hail_size_inches": 1.75,
"wind_speed_mph": null,
"city": "Austin",
"state_abbr": "TX",
"latitude": 30.2698,
"longitude": -97.7402
}
}Payload fields
| Field | Type | Description |
|---|---|---|
| type | enum | `storm_alert` for real alerts, `storm_alert.test` for events fired via /webhook/test. Use this to skip test events in production routing. |
| version | string | Always `v1`. New fields only added, never removed or renamed within v1. We'll publish a v2 if the shape needs to change. |
| delivered_at | string | ISO timestamp of when we POSTed. Distinct from event.event_date (when the storm happened). |
| watchlist.watchlist_id | string | UUID of the watchlist row that matched. Useful if you want to PATCH or DELETE it from your handler. |
| watchlist.external_ref | string | Whatever you stored at watchlist creation. Echoed unchanged. |
| watchlist.lat / lng | number | The watched coordinate (rounded to 4 decimals). |
| watchlist.address | string | The address string you submitted (or null if you didn't). |
| event.id | string | Our internal storm_event UUID. Stable across retries and dedup boundaries. |
| event.noaa_event_id | string | Upstream NOAA Storm Events Database id (when sourced from NCEI). Null for NWS active-alert events. |
| event.event_type | string | NOAA classification: "Hail", "Thunderstorm Wind", "Tornado", etc. |
| event.event_date | string | Date the storm landed (YYYY-MM-DD). Use this for claim-window math. |
| event.hail_size_inches | number | null | Largest reported hail diameter in inches. Null when the event is wind-only. |
| event.wind_speed_mph | number | null | Peak measured wind speed in mph. Null when the event is hail-only. |
| event.city / state_abbr | string | Nearest reported city + 2-letter state code. |
| event.latitude / longitude | number | Coordinates of the storm event itself (not the watched address). Use with watchlist.lat/lng to compute the offset distance. |
Signature verification
Every webhook carries a RoofTap-Signature header. The scheme is Stripe-style: timestamp plus HMAC-SHA256 of <timestamp>.<raw body> using your per-key signing secret.
RoofTap-Signature: t=1716379200,v1=8c2a3f...
To verify:
- Parse
t(unix seconds) andv1(hex HMAC) from the header. - Reject if
abs(now - t) > 5 minutes, guards against replay. - Recompute
HMAC-SHA256(secret, "{t{"}"}.{raw_body{"}"}"). - Compare to
v1using a constant-time equality check.
express.raw() or request.get_data()) for the HMAC to match.Node.js (Express)
import crypto from "node:crypto";
const SECRET = process.env.STORM_ALERTS_SECRET; // whsec_...
const TOLERANCE_SEC = 5 * 60;
function verify(rawBody, header) {
// Header: "t=<unix_seconds>,v1=<hex_hmac>"
const parts = Object.fromEntries(
header.split(",").map(p => p.trim().split("="))
);
const t = Number(parts.t);
const v1 = parts.v1;
if (!t || !v1) return false;
if (Math.abs(Date.now() / 1000 - t) > TOLERANCE_SEC) return false;
const expected = crypto
.createHmac("sha256", SECRET)
.update(`${t}.${rawBody}`)
.digest("hex");
const a = Buffer.from(expected, "hex");
const b = Buffer.from(v1, "hex");
return a.length === b.length && crypto.timingSafeEqual(a, b);
}
// Express handler — note: use express.raw() so the body is a Buffer.
app.post("/webhooks/storm-alerts",
express.raw({ type: "application/json" }),
(req, res) => {
const rawBody = req.body.toString("utf8");
const sig = req.header("RoofTap-Signature") || "";
if (!verify(rawBody, sig)) return res.status(401).end();
const event = JSON.parse(rawBody);
// ...route the alert, return 200 quickly...
res.status(200).end();
}
);Python (Flask)
import hmac, hashlib, time
from flask import Flask, request, abort
SECRET = os.environ["STORM_ALERTS_SECRET"] # whsec_...
TOLERANCE_SEC = 5 * 60
def verify(raw_body: bytes, header: str) -> bool:
# Header: "t=<unix_seconds>,v1=<hex_hmac>"
parts = dict(p.strip().split("=", 1) for p in header.split(","))
try:
t = int(parts["t"]); v1 = parts["v1"]
except (KeyError, ValueError):
return False
if abs(time.time() - t) > TOLERANCE_SEC:
return False
expected = hmac.new(
SECRET.encode("utf-8"),
f"{t}.{raw_body.decode('utf-8')}".encode("utf-8"),
hashlib.sha256,
).hexdigest()
return hmac.compare_digest(expected, v1)
app = Flask(__name__)
@app.post("/webhooks/storm-alerts")
def storm_alerts():
if not verify(request.get_data(), request.headers.get("RoofTap-Signature", "")):
abort(401)
event = request.get_json()
# ...route the alert, return 200 quickly...
return "", 200Testing
POST /api/v1/storm-alerts/webhook/test fires a synthetic storm_alert.testevent at your configured URL. Use this to confirm signing + handler routing before any real storm lands. Test fires don't write to delivery history and don't count toward billing once metering is enabled.
POST https://www.rooftap.app/api/v1/storm-alerts/webhook/test
X-API-Key: rt_live_abc123...
{ "address": "123 Main St, Austin, TX 78701" }
200 OK
{
"ok": true,
"duration_ms": 142,
"http_status": 200,
"response_excerpt": "OK",
"network_error": null,
"sent": {
"url": "https://your-app.example.com/webhooks/storm-alerts",
"signature_header": "RoofTap-Signature",
"payload": {
"type": "storm_alert.test",
"version": "v1",
...
}
}
}For local development, point your URL at a tunneling service like webhook.site or ngrok, both work with our HTTPS requirement out of the box.
Retry policy
Your endpoint should respond with any 2xx within 10 seconds. On non-2xx (or network timeout) we retry with this backoff:
- Attempt 1 → fail → retry in 5 minutes
- Attempt 2 → fail → retry in 30 minutes
- Attempt 3 → fail → retry in 4 hours
- Attempt 4 → fail → retry in 24 hours
- Attempt 5 → fail → marked dead, cursor advances, no further retries.
4xx responses (except 408 and 429) skip retries and go straight to dead, there's no point pounding an endpoint that's returning 400 or 401. Fix the issue, then refire historical alerts manually by PATCHing the watchlist row.
Make your handler idempotent: deliveries are at-least-once, not exactly-once. We key on (watchlist_id, event.id)internally, but a successful delivery you don't acknowledge in time will re-fire after the backoff. event.id is stable across retries, dedupe on it.
Errors
Synchronous control-plane errors return JSON with { ok: false, error, message }. The error field is a stable machine-readable code.
400 Bad Request · https_required
{ "ok": false, "error": "https_required",
"message": "Webhook URL must use https://." }
400 Bad Request · loopback_not_allowed
{ "ok": false, "error": "loopback_not_allowed",
"message": "Webhook URL cannot point at a loopback or internal host." }
400 Bad Request · lat_lng_out_of_range
{ "ok": false, "error": "lat_lng_out_of_range" }
401 Unauthorized · invalid_api_key
{ "ok": false, "error": "invalid_api_key",
"message": "API key not recognized." }
404 Not Found · not_found
{ "ok": false, "error": "not_found",
"message": "No watchlist row with that id under this key." }| HTTP | Code | What to do |
|---|---|---|
| 400 | invalid_json | Body wasn't valid JSON. Check Content-Type. |
| 400 | url_required / invalid_url | Missing or unparseable webhook URL. |
| 400 | https_required | URL must use https:// (not http). |
| 400 | loopback_not_allowed | Localhost / .local / .internal hosts rejected. |
| 400 | lat_lng_required / lat_lng_out_of_range | Missing coords or out of valid bounds. |
| 400 | too_many_addresses | Bulk add capped at 500 per request. |
| 401 | missing_api_key / invalid_api_key | X-API-Key header missing or unrecognized. |
| 403 | key_revoked | Key was revoked or paused. Contact support. |
| 404 | not_found | Watchlist id doesn't belong to your key, or webhook isn't configured. |
Billing
billable: false. We'll publish per-delivery pricing once we have enough partner traffic to calibrate it; you'll get at least 30 days' notice before any change takes effect.Watchlist size, webhook config calls, and test fires are always free. Pricing, when it lands, applies only to successfully delivered (2xx) production alerts.
Configure your webhook in the dashboard.
Save URL, copy signing secret, fire a test, add addresses, all without leaving the page.