Land-use evidence for a due-diligence file, one document per plot boundary, from the open satellite record before 31 December 2020. Everything the portal does is here as JSON, for platforms that file on behalf of many operators.
Mint a key in the portal under Settings, API access; it carries the same access as the seat that made it and nothing more. Send it on every request as Authorization: Bearer <key> to https://lisaris.axinelabs.com/api/v1.
Every refusal has one shape, {"error": {"code", "message"}}, and code is the part to branch on.
The country names the region a finding is reported against and drives the axis-order check. A feature's own country property wins over it.
curl -X POST "https://lisaris.axinelabs.com/api/v1/portal/plots/import?country=CI" \ -H "Authorization: Bearer $AXINE_KEY" \ -F "file=@plots.geojson"
Name the plots, or send {"all": true} for every valid plot not yet read. The answer carries the job id and how much of the allowance is used.
curl -X POST "https://lisaris.axinelabs.com/api/v1/portal/plots/evidence" \
-H "Authorization: Bearer $AXINE_KEY" \
-H "Content-Type: application/json" \
-d '{"plot_ids": ["3f6c2a9e-0d4b-4c1e-9a77-1b2c3d4e5f60"]}'The JSON and the PDF come from one stored object, so they cannot disagree. Before the read finishes both answer 409 no_evidence with the plot's state.
curl "https://lisaris.axinelabs.com/api/v1/portal/plots/3f6c2a9e-0d4b-4c1e-9a77-1b2c3d4e5f60/evidence" \ -H "Authorization: Bearer $AXINE_KEY" curl -o evidence.pdf \ "https://lisaris.axinelabs.com/api/v1/portal/plots/3f6c2a9e-0d4b-4c1e-9a77-1b2c3d4e5f60/evidence.pdf" \ -H "Authorization: Bearer $AXINE_KEY"
Where the whole book stands: plots by state and by result, the allowance used, and the register a page at a time.
curl "https://lisaris.axinelabs.com/api/v1/portal/plots/evidence/summary" \ -H "Authorization: Bearer $AXINE_KEY" curl "https://lisaris.axinelabs.com/api/v1/portal/plots?limit=100&offset=0" \ -H "Authorization: Bearer $AXINE_KEY"
Leave events out to receive all of them. The signing secret is in this answer and nowhere after it; the test call sends an event now and returns what your server answered.
curl -X POST "https://lisaris.axinelabs.com/api/v1/portal/webhooks" \
-H "Authorization: Bearer $AXINE_KEY" \
-H "Content-Type: application/json" \
-d '{"url": "https://example.com/axine/events",
"events": ["plot.evidence.completed", "plot.evidence.failed"]}'
curl -X POST "https://lisaris.axinelabs.com/api/v1/portal/webhooks/<endpoint id>/test" \
-H "Authorization: Bearer $AXINE_KEY"
curl "https://lisaris.axinelabs.com/api/v1/portal/webhooks/deliveries" \
-H "Authorization: Bearer $AXINE_KEY"Register a URL from the portal's API access page or with the call above. Each event is a JSON POST to it. Events:
plot.evidence.completed A plot's evidence is stored. Carries the plot id and your reference, the result code, whether it is a finding, the reason and next step where it is not, and the evidence and document URLs.plot.evidence.failed A plot could not be read. Carries the reason in words; a failed plot uses none of the allowance, and running it again retries it.batch.completed Every plot on a shipment has a finished read. Carries the shipment, its counts and the report URL.alert.opened A case opens on the alert queue.archive.reading.filed A named reader files a dated reading from a sub-metre scene against a plot.Delivery is at least once, so deduplicate on the event id. Every attempt is on the delivery log with its status, its attempts and the last code your server answered.
POST /axine/events HTTP/1.1
Content-Type: application/json
User-Agent: Axine-Webhooks/1
X-Axine-Event: plot.evidence.completed
X-Axine-Event-Id: evt_5b0f6c1e2a8d4f7c9e3b1a2d4c6e8f00
X-Axine-Delivery: 8d2f0c4e-1b3a-4e5f-9c7d-2a4b6c8e0f12
X-Axine-Timestamp: 1791014400
X-Axine-Signature: v1=6f1c...e9a2
{"id": "evt_5b0f6c1e2a8d4f7c9e3b1a2d4c6e8f00",
"type": "plot.evidence.completed",
"created": "2026-10-02T09:20:00+00:00",
"workspace_id": "93bb7c92-...",
"data": {"plot_id": "3f6c2a9e-...", "ref": "CI-0001", "commodity": "cocoa",
"area_ha": 2.4, "state": "done",
"result": "prior_management_weak", "finding": false,
"reason": "plot_below_evidence_floor",
"reasons": ["plot_below_evidence_floor"],
"next_step": {"code": "archive_imagery_review", "note": "..."},
"issued_at": "2026-10-02T09:19:58+00:00",
"evidence_url": "https://lisaris.axinelabs.com/api/v1/portal/plots/3f6c2a9e-.../evidence",
"document_url": "https://lisaris.axinelabs.com/api/v1/portal/plots/3f6c2a9e-.../evidence.pdf"}}X-Axine-Signature is v1= and the hex HMAC-SHA256, under your endpoint's secret, of the timestamp header, a full stop, and the raw body exactly as received. The timestamp is inside what is signed, so refuse anything more than five minutes old and a captured delivery cannot be replayed. Compute it over the bytes you received, before parsing the JSON.
import hashlib
import hmac
import time
def verify(secret: str, body: bytes, timestamp: str, signature: str,
tolerance: int = 300) -> bool:
"""True when the delivery is ours and was sent within five minutes."""
try:
if abs(time.time() - int(timestamp)) > tolerance:
return False
except (TypeError, ValueError):
return False
signed = timestamp.encode() + b"." + body
expected = hmac.new(secret.encode(), signed, hashlib.sha256).hexdigest()
return any(hmac.compare_digest(part.strip()[3:], expected)
for part in signature.split(",")
if part.strip().startswith("v1="))
# In your handler, with the raw body as received, before parsing it:
# ok = verify(SECRET, request.body, request.headers["X-Axine-Timestamp"],
# request.headers["X-Axine-Signature"])const crypto = require("crypto");
function verify(secret, rawBody, timestamp, signature, tolerance = 300) {
const ts = Number(timestamp);
if (!Number.isFinite(ts) || Math.abs(Date.now() / 1000 - ts) > tolerance) return false;
const expected = crypto.createHmac("sha256", secret)
.update(`${timestamp}.`).update(rawBody).digest();
return String(signature).split(",").map((s) => s.trim())
.filter((s) => s.startsWith("v1="))
.some((s) => {
const got = Buffer.from(s.slice(3), "hex");
return got.length === expected.length && crypto.timingSafeEqual(got, expected);
});
}
// Express: keep the raw body, check it, then parse it.
// app.post("/axine/events", express.raw({ type: "application/json" }), (req, res) => {
// if (!verify(SECRET, req.body, req.get("X-Axine-Timestamp"), req.get("X-Axine-Signature")))
// return res.sendStatus(400);
// res.sendStatus(204);
// const event = JSON.parse(req.body);
// });
module.exports = { verify };Rotating the secret (POST /portal/webhooks/<id>/rotate-secret) takes effect on the next attempt; the old secret stops verifying at once.