Skip to content

Webhooks

Everything that takes longer than a request should - damage assessments, bulk imports, portfolio refreshes, baseline backfills - returns 202 with a job_id. You can poll GET /v1/jobs/{job_id}, but a webhook subscription is cheaper for both sides and arrives within 30 seconds of the job finishing.

Terminal window
curl -s -X POST "$BASE/webhooks" -H "Authorization: Bearer $KEY" \
-H "Content-Type: application/json" -H "Idempotency-Key: $(uuidgen)" \
-d '{"url":"https://hooks.example.com/agrovisio",
"events":["damage.completed","parcel.data_ready","job.failed"]}'

The response carries the signing secret once. Store it next to the endpoint’s id - a bare UUID such as 48d0c2a6-cfc8-4b85-bbf2-314060bcfff4, the one resource in this API without a prc_ / job_ style prefix - because the secret is stored encrypted on our side and is not returned again. If you lose the 201, repeat the same request with the same Idempotency-Key within 24 hours: the replay returns the same response, secret included. After that, delete the endpoint and register a new one. GET /v1/webhooks lists your endpoints (with status and consecutive_failures, secret: null), DELETE /v1/webhooks/{endpoint_id} (that id) removes one together with its delivery history; pending retries are dropped.

URL policy. The URL must be https://, at most 2048 characters, without user:pass@ credentials, and must point at a public host: loopback, private (RFC 1918), link-local, carrier-grade NAT, cloud-metadata addresses and internal names (.local, .internal, a bare docker service name) are rejected with 400 validation_error and detail.fields[0].field: "url". A name that does not resolve yet is accepted - DNS may be published later - but every address it resolves to must be public at delivery time, because the check is repeated before every attempt. Redirects are not followed; a 3xx counts as a failed delivery. The sandbox delivers for real, so you can test your receiver before going live.

GET /v1/webhooks/events returns the authoritative list. At v1 launch:

Event Emitted when
job.completed any asynchronous job finished successfully (bulk import, portfolio refresh, …)
job.failed a job ended in error; the payload carries the error.type
parcel.data_ready the historical backfill of a parcel finished and data_status.baseline_ready became true
sar.event_detected a new lodging / mowing / harvest / tillage / regrowth event was detected on a monitored parcel
risk.score_changed a parcel’s risk class (A-E) moved after a recomputation
risk.portfolio_alert not available yet: new subscriptions are rejected with validation_error (reason: "unavailable") and the event is not listed by GET /v1/webhooks/events; use risk.score_changed
damage.completed a damage assessment finished, with assessment_id, damage_pct, confidence and whether a certificate exists
index.triggered a published parametric index triggered for a closed window
quota.threshold your parcels peak first reaches 80 % or 100 % of the period parcel_quota; sent once per billing period and threshold (sandbox and unlimited plans never send it). data: threshold_percent, parcels_peak, parcel_quota, plan, overage_allowed, period_start, period_end
webhook.disabled one of your endpoints was disabled after repeated failures (sent to your other active endpoints)
webhook.test a test delivery triggered from the Send test event button in the developer portal

Every payload carries an event_id. Deliveries can repeat (see retries), so deduplicate on it.

POST https://hooks.example.com/agrovisio
Content-Type: application/json
X-Agrovisio-Event: damage.completed
X-Agrovisio-Delivery: dlv_9c2e...
X-Agrovisio-Timestamp: 1758200400
X-Agrovisio-Signature: v1=<hex hmac_sha256(secret, "<timestamp>.<body>")>

The signature is computed over the raw request body bytes prefixed by the timestamp and a dot. Do not re-serialise the JSON before verifying - key order or whitespace changes would break the signature.

A delivery counts as successful when your endpoint answers any 2xx within 10 seconds. Do the work after responding, not before.

Two checks, in this order:

  1. Replay window. Reject the delivery if |now - X-Agrovisio-Timestamp| > 300 seconds. The signature covers the timestamp, so an attacker cannot move it.
  2. Constant-time comparison of the expected and received hex digests.
import hashlib
import hmac
import time
TOLERANCE_SECONDS = 300
def verify_agrovisio_signature(secret: str, headers: dict, body: bytes) -> bool:
"""True only when the delivery is fresh and signed with our secret.
`body` must be the raw bytes as received - never a re-serialised dict.
"""
ts = headers.get("X-Agrovisio-Timestamp", "")
signature = headers.get("X-Agrovisio-Signature", "")
if not ts.isdigit():
return False
if abs(time.time() - int(ts)) > TOLERANCE_SECONDS:
return False # outside the replay window
if not signature.startswith("v1="):
return False
message = f"{ts}.".encode("utf-8") + body
expected = hmac.new(secret.encode("utf-8"), message, hashlib.sha256).hexdigest()
return hmac.compare_digest("v1=" + expected, signature.strip())
# FastAPI example
from fastapi import FastAPI, Header, HTTPException, Request
app = FastAPI()
SECRET = "whsec_..." # returned once by POST /v1/webhooks
@app.post("/agrovisio")
async def agrovisio_webhook(request: Request):
body = await request.body()
if not verify_agrovisio_signature(SECRET, dict(request.headers), body):
raise HTTPException(status_code=400, detail="bad signature")
event = request.headers.get("X-Agrovisio-Event")
payload = await request.json()
# enqueue payload["event_id"] for processing; answer fast
return {"ok": True}

A delivery succeeds when your endpoint answers 2xx within 10 seconds; redirects are not followed and count as a failure. Otherwise the same delivery (same X-Agrovisio-Delivery, same event_id) is retried after 1 min, 5 min, 30 min, 2 h and 12 h - six attempts in about 15 hours. Deliveries are attempted in parallel, so one slow endpoint does not delay other events, and your URL is re-checked against the public-host rules before every attempt.

Disabling is decided per endpoint, but conservatively. Every failed attempt increments the endpoint’s consecutive_failures and any 2xx resets it to 0; the endpoint is switched to status: disabled only when one delivery has exhausted all six attempts and the endpoint had no successful delivery in the meantime. A burst of events during a short outage is therefore retried, not fatal. When an endpoint is disabled its pending retries are closed with error: "endpoint disabled", and a webhook.disabled event is sent to your other active endpoints (there is no point sending it to the one that is down). GET /v1/webhooks shows status: "disabled" and the counter. A disabled endpoint is not re-enabled through the API: register a new endpoint (a new secret) to resume. Deliveries missed while disabled are not replayed, so reconcile with GET /v1/jobs/{job_id} for the jobs you were waiting on.

Because of retries the same event_id can arrive more than once. Make your handler idempotent on it.

Webhooks are delivered for real from the sandbox, including the signature and the retry schedule, so a sandbox key is enough to certify your receiver. Damage jobs in the sandbox finish within a few seconds, which makes the damage.completed round trip a convenient smoke test. The URL policy applies in the sandbox too: a receiver on localhost or on a private address cannot be registered - use a tunnel with a public HTTPS hostname while developing.