The evidence API

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.

  1. 1Register plots. Send a book of plots as GeoJSON, a zipped shapefile or a CSV of points. Each plot is checked, stored with your reference, and reported back with anything a filing would refuse.
  2. 2Run evidence. Queue a read of the open satellite record over any set of plots, or every plot not yet read, in one call.
  3. 3Fetch results per plot. The finding, or the reason the record stops short and the next step, as JSON, and the evidence document as a PDF built from the same object.
  4. 4Bulk and asynchronous. A whole book per call. The call returns at once with a job id and the plots are read one after another; the register and the summary say where each stands.
  5. 5Webhooks on completion. We POST to your URL the moment each plot's evidence is stored, when a shipment's last plot is read, when a case opens and when an archive reading is filed, signed so you can check it came from us.

Authentication

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.

One call per verb

Register plots

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"

Run evidence

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"]}'

Fetch results per plot

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"

Bulk and asynchronous

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"

Webhooks on completion

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"

Webhooks

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"}}

Checking the signature

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.

Python
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"])
Node
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.

Limits

  • No per-request rate limit applies to the plot, evidence and webhook routes today; an integration is bounded by what follows.
  • Plot reads come from the plan's allowance: the Trial covers 200 in all over 30 days, Professional and Enterprise are uncapped, and the Standard plan includes none. A plot that failed costs nothing; reading a plot again costs one more.
  • One file per import, up to 25 MB and 100,000 plots.
  • A plot takes about three to five minutes to read, and a job reads its plots one after another.
  • Up to five active API keys and five webhook endpoints per workspace.
  • The alert scan takes 1 to 50 plots per call.
  • A webhook delivery is retried after 1 minute, 5, 30, 2 hours, 6 and 12, seven attempts in all, then marked failed on the delivery log. Answer 2xx at once and do the work afterwards; we wait 10 seconds and follow no redirects.

Reference