Base URL: https://terralert.io/api/v1
https://terralert.io/api/v1
This guide is the public reference for the API. The machine-readable contract is the OpenAPI document at https://terralert.io/api/openapi/v1.json, which lists every /v1 endpoint, field and error code. The interactive OpenAPI pages (/docs, /redoc, /openapi.json) are turned off in production.
/v1
/docs
/redoc
/openapi.json
API access, meaning programmatic use of the API with an API key, is included on the Pro and Enterprise plans. The web app itself uses the same API on every plan.
---
Every documented path starts with /v1, and every /v1 response carries the header API-Version: 1. Inside v1, changes are additive only: new endpoints, new optional parameters, new response fields and new error codes. Write clients that ignore fields they do not know. A breaking change gets /v2, and /v1 keeps working for at least six months after /v2 ships.
API-Version: 1
/v2
The same endpoints also answer without the /v1 prefix. Those paths belong to the web app, are not part of the contract, and may change without notice, so integrations should always use /v1.
Alert deliveries are at /v1/deliveries (the web app calls them /alerts).
/v1/deliveries
/alerts
Create an API key on the API keys page of the web app, then send it in every request:
curl https://terralert.io/api/v1/aoi \ -H "Authorization: Bearer ta_live_..."
ta_live_Ab3dE6gH_
webhooks:read
webhooks:write
403 workspace_role_insufficient
403 api_access_not_in_plan
Each key has one or more scopes. A GET needs the :read scope of its resource; POST, PUT and DELETE need :write. A request outside the key's scopes gets 403 scope_missing.
GET
:read
POST
PUT
DELETE
:write
403 scope_missing
aoi:read
GET /v1/aoi...
GET /v1/jobs/{task_id}
aoi:write
POST /v1/jobs/...
detections:read
/v1/events
/v1/tiles/...
/v1/compare/...
detections:write
export:read
GET /v1/detections/export
alerts:read
GET /v1/alerts
GET /v1/deliveries
alerts:write
assets:read
GET /v1/assets...
assets:write
API keys cannot be used for billing, the team, data export and account deletion, support tickets, editing the profile, or managing API keys themselves: those answer 403 session_required. A leaked key therefore cannot create more keys or erase the account. GET /v1/auth/me works with a key.
403 session_required
GET /v1/auth/me
The web app authenticates with the Supabase session of the person signed in, and the API accepts that token on every plan. It expires with the session, so it is not suitable for scripts: use an API key.
The API keys page is the way to manage keys. The same operations are in the API, for the web app's session only:
GET /v1/api-keys
id
name
prefix
scopes
status
active
revoked
expired
created_at
created_by_email
expires_at
last_used_at
revoked_at
POST /v1/api-keys
{"name", "scopes", "expires_in_days"}
key
DELETE /v1/api-keys/{key_id}
Each API key has its own budget of requests a minute:
Every response to a request made with a key carries the budget as it stands after that request:
X-RateLimit-Limit
X-RateLimit-Remaining
X-RateLimit-Reset
Scene tile images (GET /v1/tiles/scene/{scene_id}/{z}/{x}/{y}.png) do not count against the budget, because a map view fetches hundreds of them. The web app's session is limited to 200 requests a minute per address instead.
GET /v1/tiles/scene/{scene_id}/{z}/{x}/{y}.png
The actions below also have their own limit, which applies on top. Area creation is counted per workspace. The others are counted per key for a request made with a key, and per address for the web app.
POST /v1/aoi
DELETE /v1/aoi/{aoi_id}
POST /v1/detections/{detection_id}/share
GET /v1/detections/{detection_id}/chip/{side}.{fmt}
POST /v1/webhooks
PATCH /v1/webhooks/{webhook_id}
DELETE /v1/webhooks/{webhook_id}
PUT /auth/me
POST /team/invite
POST /team/accept
POST /billing/checkout
POST /billing/portal
POST /billing/cancel
POST /enterprise/checkout
POST /enterprise/inquiry
POST /support/tickets
POST /support/tickets/{ticket_id}/messages
GET /gdpr/export
POST /content/contact
POST /content/events
POST /email/unsubscribe/{token}
Counters are shared by every API server, so the figures are exact rather than per process.
When a limit is exceeded the API returns 429 Too Many Requests with the error code rate_limited and a Retry-After header: the number of seconds to wait before trying again. Wait at least that long; retrying sooner is refused again.
429 Too Many Requests
rate_limited
Retry-After
import time, httpx def get(url, headers): while True: r = httpx.get(url, headers=headers) if r.status_code != 429: return r time.sleep(int(r.headers["Retry-After"]))
Every 4xx and 5xx response has the same body:
{ "detail": "Your pro plan allows 20 active AOI(s). Upgrade your plan or deactivate an existing AOI.", "error": { "code": "aoi_quota_exceeded", "message": "Your pro plan allows 20 active AOI(s). Upgrade your plan or deactivate an existing AOI.", "request_id": "6f1c1a52-0a4e-4d8e-9a39-1f0b7c0a0d11" } }
Branch on error.code: codes are stable and only ever added. detail and message are for people and may be reworded. For a validation error (422 validation_failed), detail is the list of field errors and message names the first one.
error.code
detail
message
422 validation_failed
bad_request
webhook_url_unsafe
idempotency_key_invalid
not_authenticated
invalid_token
api_key_revoked
api_key_expired
forbidden
scope_missing
workspace_role_insufficient
session_required
api_access_not_in_plan
plan_feature_unavailable
aoi_quota_exceeded
aoi_area_exceeded
scan_frequency_not_in_plan
asset_quota_exceeded
not_found
method_not_allowed
conflict
event_already_acknowledged
event_already_resolved
event_resolved
event_open
event_merged
webhook_url_exists
api_key_limit_reached
idempotency_in_progress
asset_exists
payload_too_large
unsupported_media_type
validation_failed
invalid_geometry
invalid_bbox
aoi_area_too_small
idempotency_key_reused
invalid_import
import_too_many_rows
unavailable_for_legal_reasons
internal_error
request_id
upstream_error
service_unavailable
upstream_timeout
The OpenAPI document lists every code with its meaning.
Every response carries an X-Request-ID header, and every error body repeats it in error.request_id. Quote it when you contact support and the matching server log can be found at once. To use your own id (from a gateway, or a retry loop), send it as X-Request-ID: 8 to 128 characters from A-Z a-z 0-9 . _ : -. Anything else is replaced with a new id.
X-Request-ID
error.request_id
A-Z a-z 0-9 . _ : -
A create that times out may or may not have gone through. To retry it safely, send an Idempotency-Key header with a value unique to that create (a UUID is ideal, 1 to 255 printable ASCII characters):
Idempotency-Key
curl -X POST https://terralert.io/api/v1/aoi \ -H "Authorization: Bearer $TERRALERT_API_KEY" \ -H "Idempotency-Key: 6f1c1a52-0a4e-4d8e-9a39-1f0b7c0a0d11" \ -H "Content-Type: application/json" \ -d @area.json
Idempotent-Replayed: true
422 idempotency_key_reused
409 idempotency_in_progress
"secret_redacted": true
List endpoints take limit and offset, and answer with the APIResponse<T> wrapper:
limit
offset
APIResponse<T>
{ "data": [ ... ], "total": 142, "limit": 100, "offset": 0 }
There is a next page while offset + limit < total.
offset + limit < total
GET /v1/aoi
GET /v1/detections
GET /v1/aoi/{aoi_id}/detections
GET /v1/aoi/{aoi_id}/scenes
GET /v1/assets
GET /v1/assets/{asset_id}/events
GET /v1/aoi/activity
GET /v1/aoi lists newest first, with the id breaking ties, so pages never overlap or skip an area. GET /v1/detections also returns page, which is offset divided by limit.
page
curl "https://terralert.io/api/v1/aoi?limit=100&offset=0" \ -H "Authorization: Bearer $TERRALERT_API_KEY"
active_only (default true) leaves out deactivated areas; send active_only=false to list them too.
active_only
true
active_only=false
curl -X POST https://terralert.io/api/v1/aoi \ -H "Authorization: Bearer $TERRALERT_API_KEY" \ -H "Idempotency-Key: $(uuidgen)" \ -H "Content-Type: application/json" \ -d '{ "name": "Amazon Basin Watch", "geometry": { "type": "Polygon", "coordinates": [[[-63,-2],[-61,-2],[-61,-4],[-63,-4],[-63,-2]]] }, "scan_frequency": "daily", "detect_types": ["deforestation", "fire"], "min_confidence": 0.80, "alert_channels": ["email", "webhook"], "use_policy_acknowledged": true }'
Fields:
geometry
use_policy_acknowledged
scan_frequency
daily
weekly
monthly
realtime
detect_types
deforestation
construction
flooding
fire
vegetation_loss
urban_expansion
min_confidence
alert_channels
email
webhook
alert_policy
confidence
corroborated
monitoring_mode
imagery
signals_only
alert_scope
all
assets_only
alert_scope_radius_m
Plan limits: Free=1 AOI, Starter=3, Growth=8, Pro=20, Enterprise=30 or more, as configured at purchase. Signals-only areas have their own allowance and do not count toward these. They are part of Enterprise: 100 areas of up to 150,000 km² each unless the contract sets other limits. Free, Starter, Growth and Pro do not include them yet. A signals-only area answers 403 coverage_area_quota_exceeded when the allowance is used up and 403 coverage_area_not_in_plan on a plan without one. PUT /v1/aoi/{aoi_id} can switch an area's monitoring_mode; it then meets the other mode's allowance and size limit.
403 coverage_area_quota_exceeded
403 coverage_area_not_in_plan
PUT /v1/aoi/{aoi_id}
Every endpoint that returns an area (GET /v1/aoi, GET /v1/aoi/{aoi_id}, POST /v1/aoi, PUT /v1/aoi/{aoi_id}) includes these fields about its imagery:
GET /v1/aoi/{aoi_id}
latest_usable_scene_date
null
latest_scene_date
imagery_status
reason
image_date
percent
next_pass_estimate
watch_window
watch_window_size_m
[int, int]
imagery_status.reason is one of:
imagery_status.reason
no_new_pass
too_cloudy
partly_imaged
unreadable
search_failed
Cloud and coverage are measured over the area itself, or over its box when it is watched through one.
import os import httpx BASE = "https://terralert.io/api/v1" AUTH = {"Authorization": f"Bearer {os.environ['TERRALERT_API_KEY']}"} def list_aois() -> list[dict]: areas, offset = [], 0 while True: r = httpx.get(f"{BASE}/aoi", headers=AUTH, params={"limit": 100, "offset": offset}) r.raise_for_status() page = r.json() areas += page["data"] offset += page["limit"] if offset >= page["total"]: return areas for aoi in list_aois(): print(aoi["name"], aoi["detection_count"])
const BASE = 'https://terralert.io/api/v1'; const auth = { Authorization: `Bearer ${process.env.TERRALERT_API_KEY}` }; async function listAois() { const res = await fetch(`${BASE}/aoi?limit=100`, { headers: auth }); if (!res.ok) { const { error } = await res.json(); throw new Error(`${error.code}: ${error.message} (request ${error.request_id})`); } const { data } = await res.json(); return data; }
curl "https://terralert.io/api/v1/detections?limit=50&min_confidence=0.85&sort=score" \ -H "Authorization: Bearer $TERRALERT_API_KEY"
Query parameters:
aoi_id
change_type
date_from
YYYY-MM-DD
date_to
min_area_km2
reviewed
false
bbox
minLon,minLat,maxLon,maxLat
sort
newest
oldest
area
score
bbox needs west < east within -180 to 180 and south < north within -90 to 90; anything else is 422 invalid_bbox. A box across the antimeridian is two requests. GET /v1/detections/export takes the same filters (including bbox) and format=csv (default) or format=geojson.
422 invalid_bbox
format=csv
format=geojson
{ "id": "3fa85f64-5717-4562-b3fc-2c963f66afa6", "aoi_id": "...", "aoi_name": "Amazon Basin Watch", "scene_id": "...", "change_type": "deforestation", "confidence": 0.94, "area_km2": 3.2, "geometry": { "type": "MultiPolygon", "coordinates": [...] }, "capture_date": "2026-06-27", "satellite": "Sentinel-2", "alerted_at": "2026-06-27T14:38:11Z", "reviewed_at": null, "verdict": "real", "verdict_at": "2026-06-27T15:02:40Z", "verdict_with_imagery": true, "created_at": "2026-06-27T14:38:05Z", "centroid_lat": -3.46521, "centroid_lon": -62.21587, "label_source": "watch_list", "before_scene_id": "...", "before_capture_date": "2026-06-20" }
centroid_lat and centroid_lon name a point that always lies inside the outline, even for an L-shaped or multi-part one. label_source says where change_type came from: watch_list means it is the category you chose to watch for that area (the model detects that something changed, not what kind of change it is), and signal:<feed>, for example signal:firms, means it is the feed's own label. before_scene_id and before_capture_date name the earlier image the change was found against; they are null for a signal detection, which has no image pair. CSV and GeoJSON exports carry the same facts as lat, lon, label_source and before_capture_date.
centroid_lat
centroid_lon
label_source
watch_list
signal:<feed>
signal:firms
before_scene_id
before_capture_date
lat
lon
confidence is the detection's model score: the model's own 0 to 1 score for a Sentinel-2 detection, or TerrAlert's mapping of the feed's confidence class for a NASA FIRMS, GLAD or RADD detection. It is not a calibrated probability, so a score of 0.9 does not mean a 90% chance that the change is real.
alerted_at is when the alert was first delivered on any channel; it stays null while an alert is queued or retrying, and for a detection that never alerted. Detections written since October 2026 also carry their provenance: observed_at (the later image's capture date, or the feed event's newest point), model_id and model_version (the model that found it), input_version and policy_version (the version of the alert rules it was written under).
alerted_at
observed_at
model_id
model_version
input_version
policy_version
GET /v1/detections/{detection_id} adds a why object (it is null in lists):
GET /v1/detections/{detection_id}
why
"why": { "recorded": true, "gate": "corroborated", "gate_label": "Independent verification", "gate_result": "released", "reason": "independently_verified", "explanation": "Alert released because an independent source that can see this kind of change confirmed it (see Verification).", "policy_version": "2026.10.1", "decided_at": "2026-06-27T14:38:05Z", "alerted_at": "2026-06-27T14:38:11Z", "model": {"model_id": "change-siamese-unet-s2", "model_version": "1.0.0", "checkpoint_sha256": "...", "input_version": "..."}, "before_capture_date": "2026-06-20", "after_capture_date": "2026-06-27", "confirmation": null, "verification": { "state": "verified", "label": "Verified", "summary": "Confirmed by NASA FIRMS fire detection 300 m away, between the two images.", "by_reviewer": false, "evidence": [{"kind": "signal", "source": "firms", "source_label": "NASA FIRMS fire detection", "consistent_with": "fire", "distance_m": 301.2, "observed_at": "2026-06-26T13:00:00Z", "days_after": 0, "quality": "high", "overlaps": true}], "sources": ["firms"], "window_closes_at": "2026-07-01T00:00:00Z", "checked_at": "2026-06-27T14:38:05Z", "policy_version": "2026.10.1" }, "nearby_signals": [{"source": "firms", "count": 3, "nearest_m": 301.2, "latest": "2026-06-26T13:00:00Z", "type_matched": true, "independent": true, "quality": "high", "overlaps": true, "consistent_with": "fire"}], "nearby_signals_note": "Only sources that can see this kind of change count, from another instrument, near the outline and dated around its images.", "caveats": ["The model score is uncalibrated: a score from 0 to 1, not a probability that the change is real."] }
gate is the rule that decided: corroborated (an independent source that can see this kind of change verified it), confidence (the change was seen again on a later clear image, compared with an earlier baseline), signal (a NASA FIRMS, GLAD or RADD event) or manual (someone in your workspace marked it real in Triage). gate_result is released or held, and reason is a stable machine code for the sentence in explanation. recorded is false for a detection decided before decision records existed: its reason was not stored and none is invented. model is null for a signal detection, and confirmation is null when the change was never checked again.
gate
signal
manual
gate_result
released
held
explanation
recorded
model
confirmation
why.judgment is a trial evidence check of a Sentinel-2 detection (null for a signal detection and for detections written before it existed). mode is always shadow and provisional is true: it is recorded with provisional thresholds and never changes whether the detection alerts. judgment is likely_valid, likely_invalid or uncertain, rule_ids (with reasons, one sentence each) name the rules that produced it, and the evidence is grouped as supporting, contradicting, context and unavailable, each item with its kind (for example polygon_cloud_shadow, water_context, index_change, spatial_persistence), a plain summary and the measured value. The fields may change while it is a trial.
why.judgment
mode
shadow
provisional
judgment
likely_valid
likely_invalid
uncertain
rule_ids
reasons
supporting
contradicting
context
unavailable
kind
polygon_cloud_shadow
water_context
index_change
spatial_persistence
summary
value
Every detection also carries verification_state and verification_summary (in lists too), and the detail's why.verification adds the evidence behind them. A source counts only when it can see the detection's kind of change, was made by another instrument, lies near the outline and is dated between the earlier image and a few days after the later one (never the time the detection was written):
verification_state
verification_summary
why.verification
not_verifiable
A NASA FIRMS, GLAD or RADD detection can also be confirmed by a TerrAlert Sentinel-2 detection over it, and a GLAD detection by RADD (but never a feed by its own points).
verified
partially_verified
unverified
why.verification.window_closes_at
contradicted
verified and partially_verified release the alert under either alert policy; under corroborated nothing else does, apart from a reviewer marking the detection real. GET /v1/detections/{detection_id}/corroboration returns the same evidence by source (sources, each with type_matched, independent, quality, overlaps and consistent_with), the state and its sentence; radius_m and window_days are now the widest distance and the longest number of days after the later image of the sources that can verify the detection (0 when none can).
GET /v1/detections/{detection_id}/corroboration
sources
type_matched
independent
quality
overlaps
consistent_with
radius_m
window_days
0
A NASA FIRMS, GLAD or RADD detection alerts when its newest point is recent and its feed confidence is at least the area's min_confidence; until something independent confirms it, it says "Reported by <feed>, not verified by TerrAlert".
PUT /v1/detections/{detection_id}/verdict records a person's judgment, real, false or skip, and can be changed later. Send verdict_with_imagery: true only when the person had both evidence images (below) on screen; left out, the verdict is recorded as given without them. The detection object returns it as verdict_with_imagery: true, false, or null for a verdict given before October 2026 or no verdict at all. TerrAlert leaves verdicts given without the images out when it measures the model.
PUT /v1/detections/{detection_id}/verdict
real
skip
verdict_with_imagery: true
verdict_with_imagery
curl -X PUT https://terralert.io/api/v1/detections/{detection_id}/verdict -H "Authorization: Bearer $TERRALERT_API_KEY" -H "Content-Type: application/json" -d '{"verdict": "real", "verdict_with_imagery": true}'
Every model detection has two still images, the earlier and the later Sentinel-2 scene of the pair it was found in, cut to the same square around it. They are on every plan; the interactive split view (Before/After Comparison below) is the Pro and Enterprise feature.
curl https://terralert.io/api/v1/detections/{detection_id}/chips -H "Authorization: Bearer $TERRALERT_API_KEY"
{ "detection_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6", "bbox": [-62.2201, -3.4697, -62.2116, -3.4612], "width": 512, "height": 512, "geometry": { "type": "MultiPolygon", "coordinates": [...] }, "before": { "scene_id": "...", "capture_date": "2026-06-20", "satellite": "Sentinel-2", "url": "/v1/detections/3fa85f64-.../chip/before.webp" }, "after": { "scene_id": "...", "capture_date": "2026-06-27", "satellite": "Sentinel-2", "url": "/v1/detections/3fa85f64-.../chip/after.webp" }, "attribution": "Contains modified Copernicus Sentinel data 2026" }
Both images cover bbox (west, south, east, north in degrees) exactly, 512 x 512 px, north up, so you can draw geometry over them from the box alone. The box is the detection's outline made square on the ground and padded on every side by 200 m or half its size, whichever is larger. Fetch an image with the same header; url is relative to https://terralert.io/api and ends in .webp, and .png works too. before is null when no earlier image is recorded. An image answers 204 No Content when the box falls outside the stored scene, and carries an ETag and Cache-Control: private, max-age=86400, so send If-None-Match to get 304 Not Modified for one you already have. A detection raised by a public feed has no image pair: both routes answer 404 with the error code no_optical_pair. Show the attribution line wherever you show the images. Image requests have their own limit of 180 a minute.
url
https://terralert.io/api
.webp
.png
before
204 No Content
ETag
Cache-Control: private, max-age=86400
If-None-Match
304 Not Modified
404
no_optical_pair
attribution
Returns aggregate counts for the workspace:
curl https://terralert.io/api/v1/detections/meta/stats \ -H "Authorization: Bearer $TERRALERT_API_KEY"
{ "total_aois": 3, "active_aois": 3, "total_scenes": 48, "total_detections": 12, "unreviewed": 7, "pending_alerts": 1, "last_scan_at": "2026-06-27T14:38:11Z" }
A change event is one physical change, however many times it is observed: a fire FIRMS sees every two hours, or a building site every Sentinel-2 pass outlines again, is one event with several observations (detections). Each detection carries its event_id.
event_id
curl "https://terralert.io/api/v1/events?status=ongoing" \ -H "Authorization: Bearer $TERRALERT_API_KEY"
Query parameters: aoi_id, status, event_type, severity (low, medium, high or critical), sort (recent, the default: newest observation first; severity: critical first, events without a severity yet last), include_merged (default false), limit (max 200), offset.
event_type
severity
low
medium
high
critical
recent
include_merged
new
ongoing
expanding
shrinking
stale
resolved
reactivated
rejected
GET /v1/events/{id} adds the outline, every observation and the full history: each status change, merge and action with its reason, the observation that caused it and the person who acted. When one observation joins two events, they are merged into the older one (merged_into on the other).
GET /v1/events/{id}
merged_into
POST /v1/events/{id}/acknowledge, /resolve and /reopen (a member or above; an API key needs detections:write) take an optional body {"note": "..."}, which is kept in the history. An action the event's state does not allow answers 409 with event_already_acknowledged, event_already_resolved, event_resolved (acknowledge a resolved event), event_open (reopen an open one), event_merged or event_rejected (every observation is marked false: change a verdict instead).
POST /v1/events/{id}/acknowledge
/resolve
/reopen
{"note": "..."}
409
event_rejected
Each event carries severity_level and severity: how much it matters, from measured facts only, with the working shown. It is a ranking aid, never a probability, and no alert is held or routed by it. severity lists every factor with its value and points, the method (the rule table's name and version, terralert-severity-v1), the total points and not_considered (what it deliberately leaves out, such as the model's score).
severity_level
method
terralert-severity-v1
points
not_considered
criticality
A total of 0 to 1 is low, 2 to 3 medium, 4 to 5 high and 6 or more critical. critical needs a monitored asset within 1 km, and a rejected event is always low. Of several assets in range, the one scoring the most points is used. The severity is computed when the event changes and again daily, so a new asset counts within a day. It is null until first computed.
Only some observations alert: the first one of an event, the one that reactivated it or made it expanding, and the first one independently verified. A repeat observation of an event that already alerted is stored and shown, but not sent again (alert_state: "repeat" on the detection).
alert_state: "repeat"
The changes one pass finds in one area are one notification, sent once to each destination (each person's email, each webhook). If more changes arrive in that area within its cooldown (alert_cooldown_minutes on the area, default 360, 0 to send every pass), they join the notification and are sent as one update when the cooldown ends.
alert_cooldown_minutes
curl "https://terralert.io/api/v1/alerts?unread=true" \ -H "Authorization: Bearer $TERRALERT_API_KEY"
Each notification lists its area, how many changes and their total area, what released it, its change events, and read_at: read state is per person, so one teammate reading it does not mark it read for the others. GET /v1/alerts/{id} adds every change it announced and every delivery; POST /v1/alerts/{id}/read marks it read for you.
read_at
GET /v1/alerts/{id}
POST /v1/alerts/{id}/read
One record per notification per destination (email or webhook), with its delivery status:
curl "https://terralert.io/api/v1/deliveries?status=failed" \ -H "Authorization: Bearer $TERRALERT_API_KEY"
Query parameters: status (pending/retrying/sent/failed), channel (email/webhook), limit (max 200), offset. group_id names the notification, kind is alert, update or outcome (the follow-up that says whether an early alert was confirmed). PUT /v1/deliveries/{id}/read sets read_at and never changes the status: a failed delivery stays failed.
pending
retrying
sent
failed
channel
group_id
alert
update
outcome
PUT /v1/deliveries/{id}/read
GET /v1/latency?days=30 reports how long a change takes from the satellite to an alert, over your own areas: the median (p50_s) and 90th percentile (p90_s) in seconds for each stage, from the image being taken, through publication, download and processing, to the change being detected and the first alert delivered, plus feed points from observation to stored. A stage with fewer than 20 samples in the window says insufficient_data and gives no figure. status is ok when the end-to-end stage (alert) has enough samples, partial when only some stages do. The time from a change on the ground to the next clear image of it (2 to 5 days or more) is not included. An API key needs detections:read.
GET /v1/latency?days=30
p50_s
p90_s
insufficient_data
ok
partial
Every tile route needs the same Authorization: Bearer header as the rest of the API (an API key needs detections:read).
Authorization: Bearer
GET /v1/tiles/scene/{scene_id}/tilejson
GET /v1/tiles/aoi/{aoi_id}/tilejson
GET /v1/tiles/detections/{z}/{x}/{y}.mvt
detections
GET /v1/tiles/detections/tilejson
To use a scene in MapLibre GL JS, attach the key to tile requests. Do this only in a server-side or internal tool: a key in a public web page can be read by anyone who opens it.
const map = new maplibregl.Map({ container: 'map', transformRequest: (url) => url.startsWith('https://terralert.io/api/') ? { url, headers: { Authorization: `Bearer ${API_KEY}` } } : { url }, }); map.addSource('terralert-scene', { type: 'raster', tiles: [`https://terralert.io/api/v1/tiles/scene/${SCENE_ID}/{z}/{x}/{y}.png`], tileSize: 256, }); map.addLayer({ id: 'scene-layer', type: 'raster', source: 'terralert-scene' });
# The two most recent processed scenes of an area curl https://terralert.io/api/v1/compare/aoi/{aoi_id}/latest-pair \ -H "Authorization: Bearer $TERRALERT_API_KEY" # Tile URLs and detections for a chosen pair of scenes curl https://terralert.io/api/v1/compare/{before_scene_id}/{after_scene_id} \ -H "Authorization: Bearer $TERRALERT_API_KEY"
/v1/compare/{before_scene_id}/{after_scene_id} returns tile URL templates for both scenes, relative to the API base and under /v1, and the detections between them as GeoJSON.
/v1/compare/{before_scene_id}/{after_scene_id}
An asset is something you own, insure or operate: a property (a Point), a power line (a LineString), a mine boundary (a Polygon), or their Multi forms, in EPSG:4326. Assets are not imaged themselves. TerrAlert matches them by distance against the detections and signals of your watched areas, so only events inside one of your areas can be matched. Assets belong to the workspace: every member sees the same register, and a viewer can read it but not change it.
Point
LineString
Polygon
Multi
Each asset has your own external_id (a policy number, a pylon id; 1 to 200 characters, unique in the workspace), and optionally a name, up to 20 tags, a criticality from 1 to 5 and properties, your own attributes as a JSON object of at most 4 KB.
external_id
tags
properties
Over the limit, a create or an import answers 403 asset_quota_exceeded and writes nothing.
403 asset_quota_exceeded
# Create (Idempotency-Key accepted) curl -X POST https://terralert.io/api/v1/assets \ -H "Authorization: Bearer $TERRALERT_API_KEY" \ -H "Content-Type: application/json" \ -d '{"external_id": "POL-0042", "name": "Riverside depot", "tags": ["insured"], "criticality": 4, "geometry": {"type": "Point", "coordinates": [-0.1276, 51.5072]}}' # Create or replace by your own id: 201 when created, 200 when replaced curl -X PUT https://terralert.io/api/v1/assets/by-external-id/POL-0042 \ -H "Authorization: Bearer $TERRALERT_API_KEY" \ -H "Content-Type: application/json" \ -d '{"name": "Riverside depot", "geometry": {"type": "Point", "coordinates": [-0.1276, 51.5072]}}'
tag
q
include_geometry=false
POST /v1/assets
409 asset_exists
PUT /v1/assets/by-external-id/{external_id}
/
GET /v1/assets/{asset_id}
PUT /v1/assets/{asset_id}
DELETE /v1/assets/{asset_id}
A polygon whose edges cross, or a line without two distinct positions, is refused with 422 invalid_geometry.
422 invalid_geometry
POST /v1/assets/import creates or updates up to 5,000 assets in one call, keyed on external_id, and answers once they are written: 10,000 assets are two calls. Send either a GeoJSON FeatureCollection (Content-Type: application/geo+json) with external_id in each feature's properties (or as the feature id), or a CSV file (Content-Type: text/csv) with a header row naming external_id, lat and lon, and optionally name, tags (separated by ;) and criticality. Other properties and columns are kept as the asset's properties. The file may be up to 8 MB.
POST /v1/assets/import
Content-Type: application/geo+json
Content-Type: text/csv
;
curl -X POST "https://terralert.io/api/v1/assets/import" \ -H "Authorization: Bearer $TERRALERT_API_KEY" \ -H "Content-Type: text/csv" \ --data-binary @portfolio.csv
{ "total": 5000, "created": 4987, "updated": 10, "rejected": 3, "dry_run": false, "rejects": [ { "row": 214, "external_id": "POL-7731", "reason": "lat and lon are both needed" } ] }
A row that cannot be read is rejected with its row number (the CSV line number, or the feature's position in the file) and the rest are written. ?dry_run=true checks the file and counts what would change without writing anything. A file with more than 5,000 rows is refused with 422 import_too_many_rows, and one that cannot be read at all with 422 invalid_import.
?dry_run=true
422 import_too_many_rows
422 invalid_import
An event is a detection or a signal (a NASA FIRMS fire hotspot, a GLAD or RADD forest-loss alert, a GIBS flood point) in one of your areas. Distance is the shortest distance on the WGS84 ellipsoid between the asset and the event, in metres, and 0 where they touch.
GET /v1/assets/exposure/detections/{detection_id}
GET /v1/assets/exposure/signals/{signal_id}
GET /v1/assets/exposure
Parameters: radius_m (default 1,000, at most 50,000), since and until (dates the event was observed, inclusive), kind (all, detection or signal), source (comma-separated, for example firms), change_type and min_score (detections only), include_false (include detections reviewed as false), tag (only assets with this tag), limit (up to 1,000) and offset.
since
until
detection
source
firms
min_score
include_false
curl "https://terralert.io/api/v1/assets/exposure?radius_m=2000&kind=signal&source=firms&since=2026-10-01" \ -H "Authorization: Bearer $TERRALERT_API_KEY"
Each item names the asset (asset_id, external_id, asset_name, criticality), the event (kind, event_id, source, change_type, model_score for a detection, signal_confidence for a signal, verdict, observed_on, area_km2, aoi_id, aoi_name, event_lat, event_lon, recorded_at), distance_m, and for a detection detail_path, the API path with its evidence. verification_state (detections only) is the event's verification with a reviewer's verdict applied, and severity is the severity of this event for this asset (see "Severity" under Change events), computed from the pair: the event's area, distance_m, the asset's criticality, the change type and the verification.
asset_id
asset_name
model_score
signal_confidence
verdict
observed_on
area_km2
aoi_name
event_lat
event_lon
recorded_at
distance_m
detail_path
POST /v1/aoi/from-asset makes a watched area from an asset plus a buffer, in one call: a mine boundary and the 2 km around it, or a corridor along a line.
POST /v1/aoi/from-asset
curl -X POST https://terralert.io/api/v1/aoi/from-asset \ -H "Authorization: Bearer $TERRALERT_API_KEY" \ -H "Content-Type: application/json" \ -d '{"asset_id": "...", "buffer_m": 2000, "use_policy_acknowledged": true}'
buffer_m is 0 to 20,000 (0 keeps a polygon's own boundary). The other fields are those of POST /v1/aoi, and the area is held to the same plan limits and to 5,000 km², the most one satellite tile reliably covers. A buffer that leaves the asset in separate pieces is refused with 422 invalid_geometry: use a larger buffer, or one area per piece. An API key needs aoi:write and assets:read.
buffer_m
Watching thousands of properties with imagery is not possible (one Sentinel-2 download per area per pass). POST /v1/assets/coverage-areas instead proposes, or creates, a few large signals-only areas around your assets, watched through the fire, forest-loss and flood feeds, alerting only when an asset is within alert_scope_radius_m of a change.
POST /v1/assets/coverage-areas
curl -X POST https://terralert.io/api/v1/assets/coverage-areas \ -H "Authorization: Bearer $TERRALERT_API_KEY" \ -H "Content-Type: application/json" \ -d '{"tag": "wildfire-book", "buffer_m": 5000, "dry_run": true}'
The proposal is the convex hull of the assets (all of them, or those with tag) plus buffer_m (100 to 50,000, default 5,000). When that is larger than your plan's signals-only area, the assets are grouped on a grid of 4, 2, 1, 0.5 or 0.25 degrees, the coarsest that fits, one area per cell. The answer lists each area with its asset count, size and outline, your allowance and whether everything fits (fits_plan). dry_run: true (the default) changes nothing; dry_run: false with use_policy_acknowledged: true creates the areas (detect_types default ["fire"], alert_scope default assets_only, alert_scope_radius_m default 1,000), all or none. Creating needs a member role and, for an API key, assets:write and aoi:write.
fits_plan
dry_run: true
dry_run: false
use_policy_acknowledged: true
["fire"]
Flood alerts reach assets only once the flood feed writes detections; until then a coverage area reports fire and forest loss.
GET /v1/assets/{asset_id}/activity?since=&until=&radius_m=&min_area_km2=
radius_m=0
GET /v1/aoi/activity?since=&until=&min_area_km2=
A detection counts as meaningful when its changed area is at least min_area_km2 and nobody has reviewed it as false. There is no hidden score. since defaults to 30 days before until, and until to today (UTC).
See the Webhook Integration Guide for payload format, signature verification, and retry policy.
Register a webhook:
curl -X POST https://terralert.io/api/v1/webhooks \ -H "Authorization: Bearer $TERRALERT_API_KEY" \ -H "Idempotency-Key: $(uuidgen)" \ -H "Content-Type: application/json" \ -d '{"name": "My Server", "url": "https://my-server.com/terralert-hook"}'
The response includes a one-time signing_secret. Store it securely: it is not shown again, not even when the same request is replayed with its Idempotency-Key. The URL is stored encrypted and every response shows it masked.
signing_secret
Endpoints belong to the workspace: each receives the alerts of all its areas, whoever registered it. Optional filters narrow that down: aoi_ids (areas of the workspace), change_types and min_score (model score, 0 to 1). PATCH /v1/webhooks/{webhook_id} changes the filters, the name, or is_active (pause and resume); a filter sent as null is cleared.
aoi_ids
change_types
is_active
Webhooks are not available on the Free plan: POST /v1/webhooks returns 403 for a Free account, and alerts are not delivered to webhooks while an account is on the Free plan. A failed delivery is retried with exponential backoff, up to 5 attempts in total.
403
Detections and scenes older than your plan's retention window are automatically deleted:
If a paid subscription ends, the account continues on the Free plan, and its full history is kept for 60 days so you can switch provider or retrieve it with the data export below. After those 60 days the Free plan's 30-day window applies.
These two actions belong to the person signed in, so they take the web app's session and refuse API keys (403 session_required). They are in the Your data section of the Account page.
# Export your data: one JSON file (area and detection geometry as GeoJSON inside it) curl https://terralert.io/api/gdpr/export \ -H "Authorization: Bearer $SESSION_TOKEN" # Delete your account. This is immediate and cannot be undone. curl -X POST https://terralert.io/api/gdpr/delete \ -H "Authorization: Bearer $SESSION_TOKEN"
The export includes your profile (with your email preferences), areas (with their alert settings: alert_policy, min_confidence, detect_types, scan_frequency), detections (with their geometry), alerts (including alert emails sent to you as a team member), webhooks (your workspace's, with their URLs, and any you registered in another workspace, with the URL masked), API keys (name, prefix, scopes and dates, never the key or its hash), billing fields, support tickets with their messages, team memberships, contact messages, data-rights requests, and the metadata of your sign-in account. It is limited to 5 requests an hour.
Deleting the account deletes its workspace's API keys, so they stop working at once.
Deletion responds with:
erasure_complete
pending_steps
step
description
retained
anonymized_at
If the database step fails, the request returns 500 and nothing in TerrAlert's database is changed.
500
All documentation