Lightning API · Documentation
One endpoint. Pass your API key and get back a JSON array of lightning flashes. Filter by time window and geographic bounding box. No SDK required.
All requests must include your API key in the X-API-Key header. Keys are generated from your dashboard and can be rotated at any time. Never expose your key in client-side code, proxy requests through your own backend.
Heads up: api.lightningapi.dev now also works as an API hostname, alongside api.warpulse.com. Nothing is required from you right now, both work identically and api.warpulse.com is not going away today. We plan to retire it in favor of api.lightningapi.dev in early 2027, and we will give plenty of advance notice before that happens.
curl "https://api.lightningapi.dev/v1/flashes?since_minutes=15" \
-H "X-API-Key: YOUR_API_KEY"https://api.lightningapi.dev/v1/flashesReturns an array of lightning flash events matching the given filters.
All parameters are optional. Without a bounding box, the response covers the full Americas, Atlantic, and Pacific region (including Hawaii and Alaska).
Narrow results to any rectangular region by passing all four bounding box parameters together. All four must be present, a partial set returns a 400 error. Coordinates use decimal degrees (WGS84).
# Florida curl "https://api.lightningapi.dev/v1/flashes" \ -H "X-API-Key: YOUR_API_KEY" \ -G \ -d since_minutes=15 \ -d min_lat=24.52 \ -d max_lat=31.00 \ -d min_lon=-87.63 \ -d max_lon=-80.03 # Custom box, NYC metro area curl "https://api.lightningapi.dev/v1/flashes" \ -H "X-API-Key: YOUR_API_KEY" \ -G \ -d since_minutes=30 \ -d min_lat=40.45 \ -d max_lat=41.15 \ -d min_lon=-74.30 \ -d max_lon=-73.65
Successful responses return HTTP 200 with a JSON object containing count, the number of flashes returned; since_utc, the start of the requested window; and a flashes array, ordered newest first. Each flash has five fields.
{
"count": 1284,
"since_utc": "2026-06-25 21:44:00.000000",
"flashes": [
{
"flash_id": 58097,
"lat": -5.91299,
"lon": -77.38203,
"flash_timestamp_utc": "2026-06-25 21:58:59.348063",
"type": "CG"
},
...
]
}Every numeric cap referenced above (since_minutes and limit on /v1/flashes) depends on your plan, shown below. Exceeding the rate limit returns HTTP 429; exceeding the history window or per-call limit with an explicitly-passed value returns HTTP 400.
PAYG
Monthly calls
per account
5,000 free calls, then $0.003/call (100,000 cap)
History window
per account
15 minutes
Limit / call
per account
500
Rate limit
per key
Fair usage policy
Max zones
per account
1
Starter
Monthly calls
per account
150,000
History window
per account
15 minutes
Limit / call
per account
2,000
Rate limit
per key
Fair usage policy
Max zones
per account
5
Pro
Monthly calls
per account
5,000,000
History window
per account
6 hours
Limit / call
per account
20,000
Rate limit
per key
Fair usage policy
Max zones
per account
25
Ultimate
Monthly calls
per account
25,000,000
History window
per account
24 hours
Limit / call
per account
50,000
Rate limit
per key
Fair usage policy
Max zones
per account
100
Supercell
Monthly calls
per account
Unlimited
History window
per account
7 days
Limit / call
per account
200,000
Rate limit
per key
300 req/sec
Max zones
per account
1,000
Enterprise
Monthly calls
per account
Negotiated
History window
per account
Negotiated
Limit / call
per account
Negotiated
Rate limit
per key
Negotiated
Max zones
per account
Negotiated
Commercial use is permitted on every current plan. Non-profit organisations on Pay As You Go can request a raised free allowance: see Licensing.
Monthly calls, history window, and limit per call all apply to your account as a whole, so extra API keys don't add quota. Rate limit is the one that's tracked separately per key, so each key gets its own allowance.
"History window" is the maximum value allowed for since_minutes. "Limit / call" is the maximum value allowed for limit (default 2,000, itself capped to your plan's value if you don't pass one explicitly). Both silently clamp down to your plan's cap if you never touch the parameter at all; passing a value that explicitly exceeds your plan's cap returns a 400 with a message naming the exact limit.
Monthly quotas reset on your billing anniversary each month, not the calendar month. Usage is visible on your dashboard.
Quota is consumed based on how many flashes a request returns, not per request: every 100 flashes returned costs 1 unit of your monthly quota, with a minimum of 1 unit per call. A request returning 50 flashes still costs 1 unit; a request returning 5,000 flashes costs 50. The X-Quota-Cost response header on every /v1/flashes call tells you exactly what that call cost.
Each request returns up to your plan's per-call limit: 500 flashes on Pay As You Go, 2,000 on Starter, 20,000 on Pro, 50,000 on Ultimate, and 200,000 on Supercell, with Enterprise limits set by agreement. Results are ordered newest first. If more flashes match your window and bounding box than a single request can return, you receive the most recent ones, count equals the limit you passed, and the older part of the window is not included in that response.
To build a complete history over a long window or a wide area:
since_minutes keeps each response well under the limit, and storing what comes back gives you the full window without large requests.count equals limit as incomplete, and split that area further before requesting it again.The type field (CG/IC classification) is available for Pro plans and above.
Beta. Live and included on the plans below. The shape of this API may still move before it settles, and anything that changes will be announced first.
Live radar as PNG map tiles, addressed as /{frame}/{z}/{x}/{y}.png. Coverage is the continental United States.
Tile requests use a short-lived token instead of your API key. Request one first:
curl -X POST "https://api.lightningapi.dev/v1/radar-tiles/token" \
-H "X-API-Key: YOUR_API_KEY"The response includes the token, a url_template and window_seconds. Tokens last one hour; request a new one on a 401.
Frame ids are YYYYMMDDHHMM, with a new frame about every two minutes. List the frames your plan can reach:
curl "https://tiles.lightningapi.dev/v1/frames?t=TOKEN"
# -> {"frames": ["202609030040", "202609030045"], "window_seconds": 86400}Then point a tile layer at the template. Frames outside your plan's window return 403. Tiles outside coverage return a transparent PNG.
https://tiles.lightningapi.dev/v1/radar/{frame}/{z}/{x}/{y}.png?t=TOKENOptional palette parameter: reflectivity (default),amber, amber-violet or thermal. Zoom 0 to 12.
To read the reflectivity at a point, for example to show a value under the cursor, request the frame's value with the same token:
curl "https://tiles.lightningapi.dev/v1/radar/202609030045/value?lat=35.47&lon=-97.52&t=TOKEN"
# -> {"frame": "202609030045", "lat": 35.47, "lon": -97.52, "dbz": 42.5}dbz is null where there is no return. Each lookup counts as one tile against your allowance, so request a value when the cursor comes to rest rather than on every movement.
To follow a point through a loop, request its history instead. It returns the value at that point for every frame your plan can reach, oldest first, in one request that counts as one tile:
curl "https://tiles.lightningapi.dev/v1/radar/history?lat=35.47&lon=-97.52&t=TOKEN"
# -> {"lat": 35.47, "lon": -97.52, "points": [{"frame": "202609030040", "dbz": 38.0}, ...]}On Pro and above, a page in the browser can read these responses directly from the tile host. Below Pro, call them from your own server. Tiles themselves display on every plan either way.
On Ultimate and Supercell, a whole loop for one tile comes back in a single request: every frame stacked top to bottom, oldest first, as one PNG 256 pixels wide. count sets how many of the most recent frames to include (default 60, up to 120), end optionally sets the last frame, and the X-Radar-Frames response header lists the frame ids in order. Each frame in the strip counts as one tile.
https://tiles.lightningapi.dev/v1/radar/loop/{z}/{x}/{y}.png?count=60&t=TOKENTiles have their own monthly allowance, counted separately from your API calls: 5,000 a month on Pay As You Go, 25,000 on Starter, 250,000 on Pro, 5,000,000 on Ultimate and no ceiling on Supercell. Past the allowance, tiles return 429; on Pay As You Go they keep serving and bill $0.0010 each against your spending cap. window_seconds follows your plan's history window, from 15 minutes to 7 days.
400Bad Request
Invalid parameter value or combination (e.g. min_lat without max_lat, limit exceeds plan cap).
401Unauthorized
Missing or invalid X-API-Key header.
429Too Many Requests
Rate limit exceeded for your plan. Back off and retry.
500Server Error
Unexpected server error. Contact support if the issue persists.
Pick a US state or region, draw a custom bounding box, or enter coordinates manually. The query updates live in your chosen language.
Pay As You Go & Starter cap at 15 min. Pro caps at 6 hours, Ultimate at 24 hours, Supercell at 7 days, and Enterprise has no history limit.
Pay As You Go caps at 500/call. Starter caps at 2,000/call. Pro caps at 20,000/call. Ultimate caps at 50,000/call. Supercell caps at 200,000/call. Enterprise limits are negotiated.
Bounding box (or use the map below)
Regions
US States
Generated query
curl "https://api.lightningapi.dev/v1/flashes" \
-H "X-API-Key: YOUR_API_KEY" \
-G \
-d since_minutes=15Define a zone (a center point and a radius) and get a signed webhook POST when a strike lands inside it. Delivery is near real-time and doesn't count against your monthly call quota. Manage zones via the API below, or from the dashboard, using the same X-API-Key header as /v1/flashes for API access. Zone count is capped by plan; see Max zones in the Plan Limits table above.
https://api.lightningapi.dev/developer/zonesCreate a zone. Returns webhook_secret once; store it, it is never shown again.https://api.lightningapi.dev/developer/zonesList your zones. Never includes webhook_secret.https://api.lightningapi.dev/developer/zones/{id}Delete a zone. Its alert history is deleted with it.https://api.lightningapi.dev/developer/zones/{id}/alertsRecent firings for a zone, with delivery_status. Useful for debugging a webhook endpoint that isn't receiving deliveries.curl -X POST https://api.lightningapi.dev/developer/zones \
-H "X-API-Key: YOUR_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"name": "Construction Site A",
"center_lat": 33.41,
"center_lon": -94.02,
"radius_km": 10,
"cooldown_minutes": 5,
"webhook_url": "https://your-server.example.com/lightning-webhook"
}'The first strike inside a zone fires immediately. Further strikes in that same zone are suppressed for cooldown_minutes (default 5) before it can fire again.
Every delivery includes an X-Lightning-Signature header: sha256=<hex hmac>, computed over the raw request body using your zone's webhook_secret as the HMAC key. Verify it before trusting the payload.
The payload shape depends on event_type. A plain zone fires flash_proximity the moment a strike lands inside it:
{
"delivery_id": 4821,
"zone_id": 123,
"zone_name": "Construction Site A",
"event_type": "flash_proximity",
"flash": {
"lat": 33.41,
"lon": -94.02,
"timestamp_utc": "2026-08-04T18:22:10",
"type": "CG",
"energy_fj": 142.35
},
"distance_km": 3.2
}A predictive zone instead fires storm_approaching ahead of a tracked storm cell's projected arrival, with no single triggering flash:
{
"delivery_id": 4822,
"zone_id": 123,
"zone_name": "Construction Site A",
"event_type": "storm_approaching",
"storm_cell": { "id": 16935, "heading_deg": 27.3, "speed_kmh": 4.9 },
"eta_minutes": 18.5
}A zone with min_dbz set fires radar_threshold when a tracked radar cell at or above that reflectivity overlaps it, whether or not the storm is producing lightning:
{
"delivery_id": 4823,
"zone_id": 123,
"zone_name": "Construction Site A",
"event_type": "radar_threshold",
"radar_cell": { "id": 48211 },
"observed_max_dbz": 52.5
}type and energy_fj on flash are only present when known (Pro plans and above for type).
import hashlib, hmac
def verify(secret: str, body: bytes, header: str) -> bool:
expected = "sha256=" + hmac.new(secret.encode(), body, hashlib.sha256).hexdigest()
return hmac.compare_digest(expected, header)1stImmediate2nd+30s3rd+2min4th+10min, then marked failedRetried on any non-2xx response or timeout. Your endpoint must respond within 5 seconds; anything slower is treated as a timeout and retried on the same schedule. Use delivery_id for idempotency if you receive the same delivery more than once during a retry window.
GET /developer/zones/{id}/alerts returns each firing's delivery_status, one of pending (queued, not yet delivered), delivered (2xx received), or failed (all 4 attempts exhausted). Useful for confirming whether your endpoint is actually receiving deliveries.
Every active storm is clustered into a tracked cell with its own position, heading, and speed, followed across its full lifecycle including merges and splits with neighboring cells. Available on Ultimate and above.
https://api.lightningapi.dev/v1/storm-cellscurl "https://api.lightningapi.dev/v1/storm-cells?since_minutes=30" \
-H "X-API-Key: YOUR_API_KEY"
{
"count": 3,
"since_utc": "2026-08-22 17:30:52.953017",
"storm_cells": [
{
"id": 16935,
"centroid_lat": 28.789954,
"centroid_lon": -84.947727,
"radius_km": 20.27,
"flash_count_window": 25,
"heading_deg": 27.28,
"speed_kmh": 4.89,
"status": "active",
"first_seen_at": "2026-08-22 18:00:13.614927",
"last_seen_at": "2026-08-22 18:30:13.576041"
}
]
}status is active or dissipated. With include_lineage=true, a dissipated cell that merged into another carries merged_into_cell_id; a cell created when a storm split off an existing one carries parent_cell_id. With include_track=true, each cell also includes a track array of { observed_at, lat, lon, flash_count } points, oldest first.
A WebSocket connection ends sometimes. We deploy, a network path changes, your own process restarts. When that happens the stream can pick up exactly where it stopped, so nothing is missed in between.
Keep the flash_id of the last flash you processed. On reconnect, pass it as resume_from_flash_id. Everything that arrived while you were away is replayed first, then the live feed continues from that point with no gap and no duplicates.
last_id = None
while True:
url = "wss://api.lightningapi.dev/v1/stream"
if last_id is not None:
url += f"?resume_from_flash_id={last_id}"
try:
async with websockets.connect(
url, additional_headers={"X-API-Key": "YOUR_API_KEY"}
) as ws:
async for raw in ws:
msg = json.loads(raw)
if msg["type"] == "flashes":
for flash in msg["flashes"]:
handle(flash)
last_id = flash["flash_id"]
except websockets.ConnectionClosed:
await asyncio.sleep(1) # then reconnect, resuming from last_idReplayed batches carry "replay": true so you can tell catch-up from live data. The catch-up is bracketed by resume_started, which reports how many flashes were missed, and resume_complete, which reports how many were sent.
Before a planned restart the server sends a notice and closes with code 1012, the standard signal for a service restart. Treat that as your cue to reconnect with resume_from_flash_id set. Recovery is typically a few seconds, and no flashes are lost across it.
Two limits worth knowing. A catch-up covers at most 200,000 flashes; beyond that the server replies resume_skipped rather than delivering part of the gap, because a partial replay you cannot identify is worse than none. Backfill with /v1/flashes in that case. And replayed flashes count toward your quota at exactly the same rate as live ones, because they are the same data delivered the same way.
Storm cells are billed at one unit per 10 cells returned, the same rate as /v1/radar-cells and ten times denser than the flash endpoints, with the usual minimum of one unit per response. Your plan's limit and since_minutes caps apply here as they do on /v1/flashes.
Predictive zones (see predictive above) are built directly on this same tracking data: a zone projects each nearby cell's heading and speed forward and fires storm_approaching once the projected path enters the zone within predictive_horizon_minutes.
Storm cells detected directly from radar reflectivity rather than from lightning density, so they cover heavy rain, hail, and squall lines that are not producing much lightning yet. Each cell carries its position, size, peak reflectivity, intensity category, and movement, refreshed every 5 minutes. Available on Pro plans and above.
This is a separate endpoint from /v1/storm-cells, not a replacement for it. The two answer different questions: lightning-derived cells track electrical activity, radar-derived cells track precipitation intensity. Many storms appear in both.
https://api.lightningapi.dev/v1/radar-cellscurl "https://api.lightningapi.dev/v1/radar-cells?since_minutes=30" \
-H "X-API-Key: YOUR_API_KEY"
{
"count": 2,
"since_utc": "2026-08-24 23:40:12.118304",
"radar_cells": [
{
"id": 48211,
"centroid_lat": 32.41883,
"centroid_lon": -95.22014,
"radius_km": 18.6,
"max_dbz": 52.5,
"category": "intense",
"heading_deg": 71.4,
"speed_kmh": 38.2,
"status": "active",
"first_seen_at": "2026-08-24 22:55:03.221844",
"last_seen_at": "2026-08-24 23:55:04.907712"
}
]
}status is active or dissipated. max_dbz is the cell's peak reflectivity, and category is the band that value falls into:
Cells below 30 dBZ are not tracked at all, so light is the weakest band returned. A zone with min_dbz set (see above) fires a radar_threshold webhook when a cell at or above that reflectivity overlaps it.
Cell endpoints are billed more densely than flashes: one unit per 10 cells returned, rather than one per 100. A tracked cell carries position, size, intensity, heading, and speed, so it is a much larger unit of information than a single flash. The same rate applies to /v1/storm-cells. As everywhere else, a response costs at least one unit, and the exact charge comes back on the X-Quota-Cost header.
Your plan's limit and since_minutes caps apply here exactly as they do on /v1/flashes. See Plan Limits for the per-plan figures.
Where a storm is turning, near the ground and higher up. Each rotating area is tracked over time and carries how strongly it is turning at both heights, which way it is turning, and where it is heading. Refreshed every 2 minutes. Available on Ultimate plans and above, covering the continental United States.
Rotation is a measured field, not a warning. It is derived from how sharply wind speed changes across a storm, it has known artifacts, and a rotating storm does not always produce a tornado. Treat it as one strong signal among several, and not as a substitute for official warnings from your national weather service.
https://api.lightningapi.dev/v1/rotation-trackscurl "https://api.lightningapi.dev/v1/rotation-tracks?since_minutes=30&direction=cyclonic" \
-H "X-API-Key: YOUR_API_KEY"
{
"count": 1,
"since_utc": "2026-09-15 13:00:00.000000",
"rotation_tracks": [
{
"id": 41,
"centroid_lat": 35.21,
"centroid_lon": -97.44,
"radius_km": 3.8,
"low_level_shear": 0.0184,
"mid_level_shear": 0.0121,
"direction": "cyclonic",
"category": "strong",
"max_dbz": 58.5,
"heading_deg": 47.0,
"speed_kmh": 62.0,
"status": "active",
"first_seen_at": "2026-09-15 12:44:00.000000",
"last_seen_at": "2026-09-15 13:00:00.000000"
}
]
}low_level_shear is how sharply the storm is turning near the ground, and mid_level_shear the same measurement higher up, both in inverse seconds. A positive value turns one way and a negative value the other, which is what direction names so you do not have to read the sign.
mid_level_shear can be null, and null is not zero. Null means the higher altitude measurement was unavailable for that update. A value at or near zero means there genuinely is no rotation up there, which is real information and worth acting on differently.
Numbers are rounded for reading: shear to four decimal places, coordinates to four (about 11 metres), radius_km to two, and max_dbz, heading_deg and speed_kmh to one. The source grid is quantised to 0.001 s^-1 and 1 km, so nothing real is lost. If you would rather round yourself, pass raw=true and every stored value comes back untouched.
max_dbz is the peak rainfall intensity over the rotating area. Rotation is only reported where there is real precipitation above it, and this is the reading that qualified it. status is active or dissipated. category is the band low_level_shear falls into, in inverse seconds, by strength and regardless of direction:
Rotation below the weak threshold is not tracked at all, so every track you receive is at or above it.
Rotation covers the continental United States only. A query for anywhere else returns an empty list rather than an error, the same way the flash endpoints behave outside their own coverage.
Rotation tracks are billed at one unit per 10 tracks returned, the same rate as /v1/radar-cells and /v1/storm-cells, with the usual minimum of one unit per response. Your plan's limit and since_minutes caps apply here as they do on /v1/flashes. Filtering with direction narrows what you are charged for, so a caller who only wants one rotation direction should set it rather than discarding half the response.
Hold one connection open and have flashes pushed to you as they are detected, instead of polling on a timer. Available on Ultimate and above. The stream is resumable: if the connection drops, you can pick up from the last flash you received and lose nothing in between.
wss://api.lightningapi.dev/v1/streamSend your key in the X-API-Key header on the handshake. Browser clients cannot set headers on a WebSocket, so they may instead send a first frame of { "api_key": "..." } within 10 seconds of connecting. The key is never accepted as a query parameter, because query strings are recorded in server and proxy logs.
import asyncio, json, websockets
URL = "wss://api.lightningapi.dev/v1/stream?cg_only=true"
async def main():
async with websockets.connect(
URL, additional_headers={"X-API-Key": "YOUR_API_KEY"}
) as ws:
async for raw in ws:
msg = json.loads(raw)
if msg["type"] == "flashes":
for flash in msg["flashes"]:
print(flash["lat"], flash["lon"], flash.get("type"))
asyncio.run(main())The first message confirms the connection and echoes the filters that were applied. Flashes then arrive in batches as they are detected, so a busy period is one message rather than hundreds.
{ "type": "connected", "tier": "ultimate", "filters": { "cg_only": true } }
{
"type": "flashes",
"count": 1,
"flashes": [
{
"flash_id": 27535,
"lat": 19.773296,
"lon": -104.554557,
"flash_timestamp_utc": "2026-08-25 01:13:59.600977",
"type": "CG",
"energy_fj": 142.35
}
]
}A rejected connection receives { "type": "error", "message": "..." } and is then closed: the key was invalid, the plan does not include streaming, or a bounding box was incomplete or inverted. type and energy_fj appear only when known, matching the REST response.
Streaming is metered at one call per 50 flashes pushed. The REST endpoints are metered at one call per 100 flashes returned, so a flash delivered over the stream costs twice what the same flash costs when you poll for it. You are paying for delivery as it happens rather than on your next request, and for a connection held open on your behalf for as long as you keep it.
The remainder carries between batches rather than being rounded up each time, so a busy second costs the same whether it arrives as one batch or ten. A partial batch still outstanding when the connection closes is charged as one call. Replayed flashes from a catch-up are metered at the streaming rate, the same as live ones.
Coverage areas
Lightning data provided as-is; not for safety-critical use. Commercial use is permitted on every current plan. Read the EULA →