TerrAlert sends HMAC-SHA256 signed HTTP POST requests to your registered endpoints when an alert fires. This guide covers registration, payload format, signature verification, and retry behaviour.
---
Webhook alerts are included on Starter and above. You need a publicly reachable URL; use HTTPS.
Endpoints belong to the workspace, not to the person who adds them: each one receives the alerts of every area in the workspace that pass its filters, whoever registered it. Every member of the team can see the list; the workspace owner or an admin adds, changes, tests and removes endpoints.
The simplest way is the Account page in the dashboard: add the URL, and TerrAlert creates a signing secret and shows it once. On Pro and Enterprise you can also register an endpoint through the API (an API key needs the webhooks:write scope, and acts with the role of the person who created it):
webhooks:write
curl -X POST https://terralert.io/api/webhooks \ -H "Authorization: Bearer $TOKEN" \ -H "Content-Type: application/json" \ -d '{"name": "Production Server", "url": "https://your-server.com/hooks/terralert"}'
To use your own signing secret, add "secret": "..." (at least 16 characters). Otherwise TerrAlert generates one.
"secret": "..."
To receive only some alerts, add any of these filters (left out, every alert is sent):
aoi_ids
change_types
deforestation
construction
flooding
fire
vegetation_loss
urban_expansion
change
min_score
Response (the secret is shown this once):
{ "id": "3fa85f64-5717-4562-b3fc-2c963f66afa6", "name": "Production Server", "url": "https://your-server.com/…lert", "is_active": true, "aoi_ids": null, "change_types": null, "min_score": null, "created_by_email": "you@example.com", "created_at": "2026-06-27T10:00:00Z", "signing_secret": "9f2c4a8e..." }
Save signing_secret immediately: it is displayed only once. You can always rotate it by deleting and re-creating the webhook.
signing_secret
The URL is stored encrypted and is never shown in full again, in the response above or anywhere else: a Slack or Teams webhook URL is a credential. Lists show its scheme, host and last four characters.
A Slack or Microsoft Teams incoming-webhook URL is recognised automatically and receives a readable chat message instead of the signed JSON below.
TerrAlert posts JSON with Content-Type: application/json. One request carries one notification: every change one pass found in one area (an alert group), not one request per polygon. The current payload is schema version 1.4 (also in the X-TerrAlert-Schema header); its JSON Schema is published in docs/schemas/webhook-detection-alert-1.4.json. The 1.1, 1.2 and 1.3 schemas stay published next to it. 1.4 only adds the outcome value of kind and each detection's outcome key, and changes nothing else, so a receiver that reads fields by name keeps working. A receiver that validates each request against a schema file must switch to webhook-detection-alert-1.4.json, or stop checking schema_version as a fixed value: each older file fixes schema_version to its own version, so a 1.3 validator rejects every 1.4 payload.
Content-Type: application/json
X-TerrAlert-Schema
docs/schemas/webhook-detection-alert-1.4.json
outcome
kind
webhook-detection-alert-1.4.json
schema_version
{ "event": "detection.alert", "alert_id": "8c1d2e3f-...", "detection_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6", "aoi_name": "Amazon Basin Watch", "change_type": "deforestation", "confidence": 0.94, "area_km2": 3.2, "near_area": false, "source": "model", "attribution": "Contains modified Copernicus Sentinel data 2026", "timestamp": "2026-06-27T14:38:05+00:00", "schema_version": "1.4", "delivery_id": "8c1d2e3f-...", "aoi_id": "6f1c1a52-0a4e-4d8e-9a39-1f0b7c0a0d11", "alert_group_id": "0d7e4b2a-...", "kind": "alert", "sequence": 0, "trigger": "new", "change_event": { "event_id": "1b9d6bcd-...", "public_id": "TL-DOOWBPGTAJEJR", "status": "new", "event_type": "deforestation", "first_observed": "2026-06-26T00:00:00+00:00", "last_observed": "2026-06-26T00:00:00+00:00", "observation_count": 1, "acknowledged": false }, "centroid": {"lat": -3.465305, "lon": -62.215881}, "bbox": [-62.2301, -3.4801, -62.2011, -3.4502], "observed_at": "2026-06-26", "observed_at_precision": "day", "imagery": {"before_date": "2026-06-21", "after_date": "2026-06-26", "satellite": "sentinel-2"}, "model_score": 0.94, "model_score_note": "The model's mean output over the outline (or the feed's own confidence class), from 0 to 1. Not calibrated: it is not a probability.", "verification": {"state": "unverified", "verdict": null}, "evidence_url": "https://terralert.io/detections?detection=3fa85f64-5717-4562-b3fc-2c963f66afa6", "detections": [ {"id": "3fa85f64-...", "change_type": "deforestation", "confidence": 0.94, "area_km2": 3.2, "source": "model", "trigger": "new", "event_id": "1b9d6bcd-...", "observed_at": "2026-06-26", "centroid": {"lat": -3.465305, "lon": -62.215881}, "geometry": {"type": "MultiPolygon", "coordinates": []}} ], "detections_total": 1, "detections_truncated": false, "geometries_omitted": false, "severity": { "level": "medium", "method": "terralert-severity-v1", "basis": "Points from a fixed table over measured facts: affected area, distance to your nearest monitored asset, that asset's criticality, the change type and the verification state. A ranking aid, not a probability or a risk assessment.", "points": 3, "factors": [ {"factor": "affected_area", "value": 3.2, "unit": "km2", "points": 3, "explanation": "3.2000 km2 changed (1 km2 or more): 3 points"}, {"factor": "asset_proximity", "value": 2400.0, "unit": "m", "points": 0, "asset_id": "5a0c...", "external_id": "CONCESSION-12", "explanation": "asset CONCESSION-12 is 2.4 km away: 0 points"}, {"factor": "asset_criticality", "value": 4, "unit": null, "points": 0, "explanation": "criticality 4 counts only within 1,000 m: 0 points"}, {"factor": "change_type", "value": "deforestation", "unit": null, "points": 0, "explanation": "deforestation does not spread on its own: 0 points"}, {"factor": "verification", "value": "unverified", "unit": null, "points": 0, "explanation": "unverified: 0 points"} ], "not_considered": ["the model's score (not calibrated, so not a probability)", "population exposure (TerrAlert holds no population data)", "infrastructure that is not one of your monitored assets", "an asset's value or insured amount"], "computed_at": "2026-06-27T14:38:04+00:00", "capped": null }, "confirmation": {"early_alert": true, "state": "pending"}, "observed_age_days": 1 }
The first eleven keys are the 1.0 payload, unchanged in name, type and meaning: a receiver written for 1.0 keeps working. They describe the lead change, the one with the highest model score that the endpoint's filters let through. Within 1.x, fields are only ever added; a breaking change would be a new major version.
change_type is one of deforestation, construction, flooding, fire, vegetation_loss, urban_expansion or change. The event name is also sent in the X-TerrAlert-Event header.
change_type
X-TerrAlert-Event
source is model for TerrAlert's own Sentinel-2 detector, or the signal feed or feeds that raised the detection (firms, glad, radd, or glad,radd). attribution is the credit line the data's licence requires; a system that displays the alert should show it.
source
model
firms
glad
radd
glad,radd
attribution
near_area is true when the area is smaller than 10,000 m² or narrower than 100 m and is watched through a box around it, and the detection comes from the model; the change then touches the area or lies within 10 m of its edge. area_km2 is rounded to 4 decimal places.
near_area
true
area_km2
The 1.1 additions:
delivery_id
X-TerrAlert-Delivery
aoi_id
alert_group_id
sequence
alert
update
trigger
new
expanding
reactivated
verified
change_event
ongoing
shrinking
stale
resolved
rejected
GET /v1/events/{event_id}
centroid
bbox
observed_at
observed_at_precision
day
second
timestamp
imagery
model_score
model_score_note
verification
evidence_url
detections
detections_total
detections_truncated
geometries_omitted
The 1.2 addition:
severity
level
low
medium
high
critical
method
points
not_considered
null
The factors are the affected area (under 0.01 km2: 0 points, to 0.1: 1, to 1: 2, 1 km2 or more: 3), the distance to your nearest monitored asset (within 100 m: 2, within 1 km: 1), that asset's criticality (5: 2, 4: 1; counted only within 1 km), the change type (fire and flooding: 1) and verification (verified: +1, contradicted: -1). 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 an event a reviewer rejected is always low. It is a ranking aid, never a probability, and no delivery is held or routed by it.
The 1.3 additions:
confirmation
early_alert
state
pending
confirmed
not_persistent
expired
not_required
GET /v1/detections
observed_age_days
The 1.4 additions:
detections[].outcome
Event types:
detection.alert
ping
POST /webhooks/{id}/test
Every request includes an X-TerrAlert-Sig header:
X-TerrAlert-Sig
X-TerrAlert-Sig: t=1719499085,v1=5257a869e7ecebeda32affa62cdca3fa51cad7e77a0e56ff536d0ce8e108d8bd
t is the Unix time the request was signed, and v1 is computed as:
t
v1
HMAC-SHA256(key=signing_secret, message="{t}.{raw_request_body}")
Always verify the signature before processing the payload to prevent spoofed requests. Compare against the raw body exactly as received, and reject requests whose t is too old (five minutes is a common limit) to stop replays.
import hashlib import hmac import time from flask import Flask, request, abort app = Flask(__name__) SIGNING_SECRET = b"9f2c4a8e..." # from webhook registration @app.route("/hooks/terralert", methods=["POST"]) def webhook(): header = request.headers.get("X-TerrAlert-Sig", "") parts = dict(p.split("=", 1) for p in header.split(",") if "=" in p) if "t" not in parts or "v1" not in parts: abort(400, "Missing signature") if abs(time.time() - int(parts["t"])) > 300: abort(400, "Stale signature") message = parts["t"].encode() + b"." + request.get_data() expected = hmac.new(SIGNING_SECRET, message, hashlib.sha256).hexdigest() if not hmac.compare_digest(expected, parts["v1"]): abort(401, "Invalid signature") payload = request.get_json() print(f"Detection: {payload['change_type']}, {payload['area_km2']} km²") return "", 200
const express = require('express'); const crypto = require('crypto'); const app = express(); const SIGNING_SECRET = '9f2c4a8e...'; app.post('/hooks/terralert', express.raw({ type: 'application/json' }), (req, res) => { const header = req.headers['x-terralert-sig'] ?? ''; const parts = Object.fromEntries(header.split(',').map(p => p.split('=', 2))); if (!parts.t || !parts.v1) return res.status(400).json({ error: 'Missing signature' }); if (Math.abs(Date.now() / 1000 - Number(parts.t)) > 300) { return res.status(400).json({ error: 'Stale signature' }); } const expected = crypto .createHmac('sha256', SIGNING_SECRET) .update(`${parts.t}.`) .update(req.body) .digest('hex'); const a = Buffer.from(expected); const b = Buffer.from(parts.v1); if (a.length !== b.length || !crypto.timingSafeEqual(a, b)) { return res.status(401).json({ error: 'Invalid signature' }); } const payload = JSON.parse(req.body); console.log('Detection:', payload.change_type, payload.area_km2, 'km²'); res.sendStatus(200); });
package main import ( "crypto/hmac" "crypto/sha256" "encoding/hex" "fmt" "io" "math" "net/http" "strconv" "strings" "time" ) var signingSecret = []byte("9f2c4a8e...") func webhook(w http.ResponseWriter, r *http.Request) { body, _ := io.ReadAll(r.Body) parts := map[string]string{} for _, p := range strings.Split(r.Header.Get("X-TerrAlert-Sig"), ",") { if k, v, ok := strings.Cut(p, "="); ok { parts[k] = v } } ts, err := strconv.ParseInt(parts["t"], 10, 64) if err != nil || parts["v1"] == "" { http.Error(w, "Missing signature", http.StatusBadRequest) return } if math.Abs(float64(time.Now().Unix()-ts)) > 300 { http.Error(w, "Stale signature", http.StatusBadRequest) return } mac := hmac.New(sha256.New, signingSecret) mac.Write([]byte(parts["t"] + ".")) mac.Write(body) expected := hex.EncodeToString(mac.Sum(nil)) if !hmac.Equal([]byte(expected), []byte(parts["v1"])) { http.Error(w, "Invalid signature", http.StatusUnauthorized) return } fmt.Println("Verified webhook:", string(body)) w.WriteHeader(http.StatusOK) }
TerrAlert waits up to 10 seconds for a response. If your endpoint does not return a 2xx status in that time, the delivery is retried with exponential backoff: at least 1 minute after the first failure, then 2, 4 and 8 minutes, up to 5 attempts in total. Retries are picked up every 5 minutes, so the actual gaps can be a few minutes longer.
2xx
After 5 failed attempts the alert is marked failed and no further retries are made. You can view delivery history on the Alerts page or via GET /alerts.
failed
GET /alerts
Delivery is at least once: a retry, or a request that reached you but whose response did not reach TerrAlert, arrives again. Every signed request carries two headers for this:
alert_id
X-TerrAlert-Attempt
1
Store the delivery ids you have processed and ignore a request whose id you have already seen. Answer it with a 2xx all the same, so it is not retried again. Slack and Microsoft Teams messages do not carry these headers.
Use the Test button next to the endpoint on the Account page, or the API:
curl -X POST "https://terralert.io/api/webhooks/{id}/test" \ -H "Authorization: Bearer $TOKEN"
TerrAlert sends a signed ping event and reports the HTTP status code your server returned. The body of your server's response is not shown.
Webhook requests come from TerrAlert's servers. TerrAlert does not publish a fixed list of source IP addresses, so verify the signature rather than allowlisting by IP.
Each delivery looks up your host name once, refuses private and internal addresses, and connects to the address it checked. The request still carries your host name (in Host and in the TLS server name), so certificates and virtual hosts work as usual. Redirects are not followed.
Host
# List webhooks curl https://terralert.io/api/webhooks -H "Authorization: Bearer $TOKEN" # Change its filters, rename it, or pause it ("is_active": false) and resume it. # A filter sent as null is cleared; one left out is unchanged. curl -X PATCH "https://terralert.io/api/webhooks/{id}" \ -H "Authorization: Bearer $TOKEN" \ -H "Content-Type: application/json" \ -d '{"change_types": ["fire", "flooding"], "min_score": 0.6}' # Delete a webhook curl -X DELETE "https://terralert.io/api/webhooks/{id}" \ -H "Authorization: Bearer $TOKEN"
The URL and the signing secret cannot be changed: register a new endpoint and remove the old one.
All documentation