Webhooks
This content is not available in your language yet.
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.
Subscribe
Section titled “Subscribe”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.
Event catalog
Section titled “Event catalog”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.
Delivery
Section titled “Delivery”POST https://hooks.example.com/agrovisioContent-Type: application/jsonX-Agrovisio-Event: damage.completedX-Agrovisio-Delivery: dlv_9c2e...X-Agrovisio-Timestamp: 1758200400X-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.
Verify the signature
Section titled “Verify the signature”Two checks, in this order:
- Replay window. Reject the delivery if
|now - X-Agrovisio-Timestamp| > 300seconds. The signature covers the timestamp, so an attacker cannot move it. - Constant-time comparison of the expected and received hex digests.
import hashlibimport hmacimport 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 examplefrom 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}import { createHmac, timingSafeEqual } from 'node:crypto';import express from 'express';
const TOLERANCE_SECONDS = 300;const SECRET = process.env.AGROVISIO_WEBHOOK_SECRET; // returned once by POST /v1/webhooks
/** * True only when the delivery is fresh and signed with our secret. * `rawBody` must be the exact bytes received (use express.raw, not express.json). */export function verifyAgrovisioSignature(secret, headers, rawBody) { const ts = String(headers['x-agrovisio-timestamp'] ?? ''); const signature = String(headers['x-agrovisio-signature'] ?? ''); if (!/^\d+$/.test(ts)) return false; if (Math.abs(Date.now() / 1000 - Number(ts)) > TOLERANCE_SECONDS) return false; // replay window if (!signature.startsWith('v1=')) return false;
const message = Buffer.concat([Buffer.from(`${ts}.`, 'utf8'), rawBody]); const expected = 'v1=' + createHmac('sha256', secret).update(message).digest('hex'); const a = Buffer.from(expected, 'utf8'); const b = Buffer.from(signature.trim(), 'utf8'); return a.length === b.length && timingSafeEqual(a, b);}
const app = express();app.post('/agrovisio', express.raw({ type: 'application/json' }), (req, res) => { if (!verifyAgrovisioSignature(SECRET, req.headers, req.body)) { return res.status(400).send('bad signature'); } const event = req.headers['x-agrovisio-event']; const payload = JSON.parse(req.body.toString('utf8')); // enqueue payload.event_id for processing; answer fast res.json({ ok: true });});Retries and disabling
Section titled “Retries and disabling”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.
Sandbox behaviour
Section titled “Sandbox behaviour”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.
