API · v1 · webhook

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.

Point-radius vs polygon.We currently match on a 10-mile radius around the storm centroid. NWS storm polygons are more precise but their schema isn't in our ingest yet, expect occasional false positives at the radius edge. For roofing lead-gen this is intentionally conservative.

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

FieldTypeDescription
urlstring · requiredHTTPS URL we POST to on each alert. http://, loopback (localhost, 127.x), and .local / .internal hosts are rejected.
activebooleanSet false to pause deliveries without losing the URL or cursors. Defaults true.
rotate_secretbooleanOn 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
}
MethodPathWhat it does
PUT/api/v1/storm-alerts/webhookCreate or update the URL. Pass rotate_secret: true to mint a fresh signing secret.
GET/api/v1/storm-alerts/webhookRead current config + counters. Secret is masked.
DELETE/api/v1/storm-alerts/webhookRemove 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

FieldTypeDescription
latnumber · requiredLatitude, decimal degrees. Range -90 to 90. Rounded to 4 decimals (~11m) on insert.
lngnumber · requiredLongitude, decimal degrees. Range -180 to 180. Rounded to 4 decimals (~11m) on insert.
addressstringHuman-readable address. Echoed back unchanged in every webhook payload, store whatever helps your team route the alert.
external_refstringYour 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": [ ... ] }
MethodPathWhat it does
POST/api/v1/storm-alerts/watchlistAdd one address or bulk (up to 500).
GET/api/v1/storm-alerts/watchlistList 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

FieldTypeDescription
typeenum`storm_alert` for real alerts, `storm_alert.test` for events fired via /webhook/test. Use this to skip test events in production routing.
versionstringAlways `v1`. New fields only added, never removed or renamed within v1. We'll publish a v2 if the shape needs to change.
delivered_atstringISO timestamp of when we POSTed. Distinct from event.event_date (when the storm happened).
watchlist.watchlist_idstringUUID of the watchlist row that matched. Useful if you want to PATCH or DELETE it from your handler.
watchlist.external_refstringWhatever you stored at watchlist creation. Echoed unchanged.
watchlist.lat / lngnumberThe watched coordinate (rounded to 4 decimals).
watchlist.addressstringThe address string you submitted (or null if you didn't).
event.idstringOur internal storm_event UUID. Stable across retries and dedup boundaries.
event.noaa_event_idstringUpstream NOAA Storm Events Database id (when sourced from NCEI). Null for NWS active-alert events.
event.event_typestringNOAA classification: "Hail", "Thunderstorm Wind", "Tornado", etc.
event.event_datestringDate the storm landed (YYYY-MM-DD). Use this for claim-window math.
event.hail_size_inchesnumber | nullLargest reported hail diameter in inches. Null when the event is wind-only.
event.wind_speed_mphnumber | nullPeak measured wind speed in mph. Null when the event is hail-only.
event.city / state_abbrstringNearest reported city + 2-letter state code.
event.latitude / longitudenumberCoordinates 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:

  1. Parse t (unix seconds) and v1 (hex HMAC) from the header.
  2. Reject if abs(now - t) > 5 minutes, guards against replay.
  3. Recompute HMAC-SHA256(secret, "{t{"}"}.{raw_body{"}"}").
  4. Compare to v1 using a constant-time equality check.
Critical: verify against the raw request body, not a re-serialized JSON object. Most HTTP frameworks parse the body before your handler sees it, you must access the raw bytes (e.g. 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 "", 200

Testing

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." }
HTTPCodeWhat to do
400invalid_jsonBody wasn't valid JSON. Check Content-Type.
400url_required / invalid_urlMissing or unparseable webhook URL.
400https_requiredURL must use https:// (not http).
400loopback_not_allowedLocalhost / .local / .internal hosts rejected.
400lat_lng_required / lat_lng_out_of_rangeMissing coords or out of valid bounds.
400too_many_addressesBulk add capped at 500 per request.
401missing_api_key / invalid_api_keyX-API-Key header missing or unrecognized.
403key_revokedKey was revoked or paused. Contact support.
404not_foundWatchlist id doesn't belong to your key, or webhook isn't configured.

Billing

Storm Alerts is free during the beta.Every delivery is logged against your API key's usage with 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.

Set up in 60 seconds

Configure your webhook in the dashboard.

Save URL, copy signing secret, fire a test, add addresses, all without leaving the page.

Open dashboard →