Skip to content

Quickstart - your first NDVI series in 15 minutes

Satellite-derived agronomic data for insurers, banks and agri-businesses: vegetation indices, radar events, damage assessments, risk scores, CAP compliance and parametric index products - over one HTTP API.

This page is the fastest path from zero to a real answer. No sales call, no credit card. If it takes you longer than 15 minutes, that is our bug - tell us and quote the X-Request-Id of wherever you got stuck.

What you need Where it is
Interactive reference with “Try it” /reference; the API serves the same Scalar page itself as GET /v1/reference (same host as the requests, so no CORS to think about), next to GET /v1/docs (Redoc, read-only) and GET /v1/swagger
OpenAPI 3.1 document (SDK generation) GET /v1/openapi.json
Error catalog, every error.type with a fix Error catalog
Endpoint list with copy-paste curl Endpoints
Postman collection Postman

Base URL: https://api.agrovisio.ai/v1 (the same for sandbox and live keys). Every example below uses $BASE:

Terminal window
export BASE=https://api.staging.agrovisio.ai/v1

The sandbox is free, has no quota, is never billed, and holds 20 real Lithuanian parcels with five years of history, so you get a full answer on your first call instead of waiting for a backfill.

  • In production: sign up in the portal and copy the sk_test_... key. No payment details are needed until you request live access, and live use is paid by invoice (see Going live below).

    Register in the portal
  • In a development environment: the sandbox is seeded by scripts/publicapi_sandbox_seed.py, which prints the deployment’s demo key. The key below is the one this documentation was verified with and it works against the local sandbox as written:

Terminal window
export KEY=sk_test_16e7fa3b7095c7f52f094bec33b48413

Keys are sk_test_ (sandbox) or sk_live_ (production). They are shown once; we store only a SHA-256 hash and cannot recover a lost key - rotate instead.

Check that the key works:

Terminal window
curl -s "$BASE/account" -H "Authorization: Bearer $KEY"
{
"client_id": "a022d70d-bfba-54f3-b796-ae7aaced69df",
"name": "Agrovisio Sandbox",
"country": "LT",
"plan": "sandbox",
"status": "active",
"environment": "test",
"products": ["parcels", "indices", "weather", "sar", "thermal", "yield",
"risk", "cap", "damage", "index"],
"scopes": ["parcels:read", "parcels:write", "indices:read", "sar:read",
"thermal:read", "yield:read", "risk:read", "damage:write",
"damage:read", "index:write", "cap:read", "webhooks:manage"],
"parcel_quota": 10,
"parcels_active": 0,
"parcels_in_environment": 20,
"parcels_peak": 0,
"overage_parcels": 0,
"period_start": "2026-10-01",
"period_end": "2026-10-31",
"rate_limits": {"standard_per_min": 60, "write_per_min": 10,
"expensive_per_min": 2, "burst": 10},
"payment_overdue_since": null
}

parcels_active and parcels_peak are the billing basis - billable, live parcels - and are always 0 on a sandbox key. parcels_in_environment counts the parcels your account owns in this environment: 20 for the demo key above, because the demo account is the one that owns the shared set, and 0 for a sandbox key you registered yourself until you create parcels of your own. parcel_quota is the 10 own parcels a sandbox key may add; overage_parcels is how far the peak sits above it, and payment_overdue_since stays null until an invoice is overdue. Every timestamp in the API is UTC in RFC 3339 form with a Z suffix and millisecond precision (2026-10-03T11:27:31.425Z; a value stored to the second still comes back as ...:31.000Z); dates without a time, such as period_start and event_date, are plain YYYY-MM-DD.

Parcels are the unit of everything: you monitor parcels, you are billed by the monthly peak of active parcels, and every data endpoint hangs off a parcel id.

Terminal window
curl -s "$BASE/parcels?limit=25" -H "Authorization: Bearer $KEY"

You see the 20 shared parcels plus whatever your own key has created - other sandbox clients’ parcels are never visible to you. With the demo key the list is exactly these 20 rows (has_more: false). Filter on external_ref rather than relying on the order: the list is newest first and the ids below are the ones you need.

Each row carries a shared flag. It is true when the parcel belongs to another account than your key’s - for a sandbox key you registered yourself that is every SBX-* parcel, and a DELETE on one of them answers 404. The demo key is the account that owns the shared set, so it sees them with shared: false and could delete them; do not - the nightly reseed is what restores the set.

The sandbox set is built to exercise the interesting paths, not just the happy one:

external_ref What it demonstrates
SBX-LODGING-1 … -3 arable fields with a lodging signature in the SAR series
SBX-MOWING-1, -2 grassland with two mowing events per season
SBX-DROUGHT-1, -2 the 2023 drought: NDVI deficit, high LST, low water stress index
SBX-SMALL-1 1.7 ha - Sentinel-3 refuses it with insufficient_resolution
SBX-NEW-1 no history at all - every anomaly answers baseline_not_ready
SBX-FIELD-01 … -11 ordinary fields, five crops

Pick one and keep its id:

Terminal window
export PARCEL=$(curl -s "$BASE/parcels?limit=25" -H "Authorization: Bearer $KEY" \
| python -c "import sys,json;d=json.load(sys.stdin)['data'];print([p['parcel_id'] for p in d if p['external_ref']=='SBX-FIELD-01'][0])")
Terminal window
curl -s "$BASE/parcels/$PARCEL/indices?index=NDVI&from=2025-04-01&to=2025-09-30" \
-H "Authorization: Bearer $KEY"
{
"parcel_id": "prc_2TWVCTXEDC6Q3EMYKP2N",
"index": "NDVI",
"unit": "ratio",
"from": "2025-04-01",
"to": "2025-09-30",
"observations": 46,
"series": [
{
"date": "2025-04-02",
"value": 0.2066,
"reason": null,
"quality": "good",
"valid_pct": 86.1,
"cloud_pct": 13.9,
"satellite": "sentinel-2-l2a",
"scene_id": "SBX_S2_2025-04-02",
"min_value": 0.1466,
"max_value": 0.2666,
"anomaly": {
"baseline_season": 2025,
"baseline_mean": 0.1776,
"baseline_std": 0.0162,
"baseline_n_obs": 7,
"z": null,
"z_reason": "std_below_threshold",
"anomaly_pct": 16.35,
"pct_of_baseline": 116.35,
"reason": null
}
}
],
"baseline": {
"season": 2025, "years": 3, "years_used": [2022, 2023, 2024],
"quality": "medium", "source": "parcel",
"mean": 0.3696, "p10": 0.3118, "p90": 0.4303, "windows": 41,
"method_version": "baseline-1.0", "config_hash": "79e8c933fad8dddd",
"reason": null, "district_parcels": null
},
"processing": {"inner_buffer_m": 10.0, "applied": true, "reason": null},
"data_status": {"s2_history_months": 60, "s1_history_months": 60,
"baseline_ready": true, "baseline_source": "parcel",
"baseline_quality": "high", "baseline_years": 4},
"method_version": "indices-1.0",
"config_hash": "7dd2ff70f042c405"
}

(Abridged to the first of the 46 observations; config_hash values are the ones returned on 2026-10-03 and change whenever a method configuration is tuned - that is what they are for.)

Four things worth knowing about that response:

  1. value: null is an answer, not an error. When the pixels were clouded the row carries reason instead of a number. A wrong number in an insurance or credit decision costs more than a missing one.
  2. anomaly is per observation and compares the value with the same day-of-year window across the complete seasons before it, so it means “compared with how this field normally looks now”, not “compared with last week”. z is null with z_reason when the historical spread is too small to divide by - the first row above is exactly that case (std_below_threshold): anomaly_pct still says the field is 16 % above its norm, but no z-score is invented from a standard deviation of 0.016.
  3. baseline (top level) tells you how solid that norm is: how many seasons and DOY windows went into it, and whether it came from the parcel itself or from the district fallback. Its quality grades the seasons that actually fed this series (three here - 2022 to 2024, the complete seasons the five year sandbox history holds before 2025 - hence medium), while data_status.baseline_quality grades the parcel’s history as a whole - they differ on purpose. ?quality=good keeps only one observation class (good, partial or cloud_masked) and observations then counts what is returned.
  4. method_version and config_hash pin the algorithm and its thresholds. They never change retroactively: a recomputation with new thresholds gets a new version string.

In the sandbox, scene_id starts with SBX_ and satellite is the collection name: the history there is modelled, not downloaded, so that the free sandbox does not spend real satellite processing units. Shapes, thresholds and code paths are identical to live.

New parcels have no baseline yet. Then the endpoint answers 422 baseline_not_ready - try SBX-NEW-1 to see it - and you can either wait for the parcel.data_ready webhook or ask for the raw series with &baseline=false. Every 422 of this kind (baseline_not_ready, insufficient_data, insufficient_resolution, vegetation_masked) carries the X-Agrovisio-Not-Billable header: the call is never charged.

Minute 8-12 - the rest of the products, one call each

Section titled “Minute 8-12 - the rest of the products, one call each”
Terminal window
AUTH="Authorization: Bearer $KEY"
# Radar events: lodging, mowing, harvest, tillage, regrowth
curl -s "$BASE/parcels/$PARCEL/sar/events?from=2025-05-01&to=2025-09-01" -H "$AUTH"
# Weather: forecast, history, agro-climate indicators
curl -s "$BASE/parcels/$PARCEL/weather/forecast?days=7" -H "$AUTH"
# Land surface temperature and water stress (needs >= 5 ha)
curl -s "$BASE/parcels/$PARCEL/thermal?from=2025-06-01&to=2025-08-31" -H "$AUTH"
# Yield forecast with a confidence interval
curl -s "$BASE/parcels/$PARCEL/yield-forecast" -H "$AUTH"
# Risk score: six components, percentile rank, drivers
curl -s "$BASE/parcels/$PARCEL/risk-score" -H "$AUTH"
# CAP: monoculture rule check - four seasons of the same crop is a violation
# (three in a row is still allowed: limit_years is 3)
curl -s "$BASE/cap/rotation/check?country=LT&history=2022:winter_wheat,2023:winter_wheat,2024:winter_wheat,2025:winter_wheat" -H "$AUTH"

The rotation check answers compliant: false with one rotation_violation (consecutive_years: 4, limit_years: 3) for the history above; drop the 2022 season and the same call answers compliant: true - the same crop may be grown for at most three consecutive seasons.

Batch is a first-class path, not an afterthought - 500 parcels in one request. It is a read that happens to be a POST, so it needs an Idempotency-Key like every POST and answers 200 both on the first call and on a replay:

Terminal window
curl -s -X POST "$BASE/indices/batch" -H "$AUTH" \
-H 'Content-Type: application/json' \
-H "Idempotency-Key: $(uuidgen)" \
-d '{"parcel_ids":["'"$PARCEL"'"],"index":"NDVI","from":"2025-06-01","to":"2025-08-31"}'

Minute 12-15 - damage assessment, the asynchronous pattern

Section titled “Minute 12-15 - damage assessment, the asynchronous pattern”

Everything that takes longer than a request should returns 202 with a job. Keep the job_id - the next block needs it:

Terminal window
JOB=$(curl -s -X POST "$BASE/damage/assess" -H "$AUTH" \
-H 'Content-Type: application/json' \
-H "Idempotency-Key: $(uuidgen)" \
-d '{"parcel_id":"'"$PARCEL"'","event_date":"2025-07-04","peril":"storm","claim_ref":"CLM-1"}')
echo "$JOB"
export JOB_ID=$(echo "$JOB" | python -c "import sys,json;print(json.load(sys.stdin)['job_id'])")
{"job_id": "job_7A1MNPQRSTVWXYZ01234", "status": "queued",
"poll_url": "/v1/jobs/job_7A1MNPQRSTVWXYZ01234", "estimated_seconds": 90}

The parcel above is an ordinary field, so the answer to “did the 4 July 2025 storm damage it” is no: damage_pct: 0.0, confidence 0.58, and a certificate that says so - the proof of “nothing happened” that a claims handler needs just as much as a damage figure. Point the same request at SBX-LODGING-1 with 2025-07-04 to see the storm that did lodge a field (damage_pct: 85.0), and at SBX-FIELD-05 with 2024-07-19 for a second no-damage case; the exact numbers each of them returns are in the next section.

Then either poll GET /v1/jobs/{job_id} or - better - subscribe once:

Terminal window
curl -s -X POST "$BASE/webhooks" -H "$AUTH" \
-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 - it is stored encrypted on our side and cannot be read back later. If you lose the 201, repeat the same request with the same Idempotency-Key within 24 h and you get the same response (Idempotent-Replay: true); after that, delete the endpoint and register a new one. The URL must be https:// and point at a public host: loopback, private, link-local and cloud-metadata addresses are rejected with 400 validation_error (detail.fields[0].field: "url"). Every delivery carries four headers:

X-Agrovisio-Event: damage.completed
X-Agrovisio-Delivery: dlv_9c2e...
X-Agrovisio-Timestamp: 1758200400
X-Agrovisio-Signature: v1=<hex hmac_sha256(secret, "<timestamp>.<body>")>

Verify the signature with a constant-time compare and reject anything whose timestamp is more than 300 s from your clock - that window is the replay protection. Code for both is in the webhooks guide.

The finished job carries the assessment_id in its result - that is where you get the id, and with it the stored assessment and its certificate:

Terminal window
ASSESSMENT_ID=$(curl -s "$BASE/jobs/$JOB_ID" -H "$AUTH" \
| python -c "import sys,json;print(json.load(sys.stdin)['result']['assessment_id'])")
curl -s "$BASE/damage/$ASSESSMENT_ID" -H "$AUTH"
curl -s -o certificate.pdf "$BASE/damage/$ASSESSMENT_ID/certificate.pdf" -H "$AUTH"

A certificate is not issued for every assessment. It requires confidence >= 0.50; below that the PDF route answers 422 insufficient_data with detail.reason: confidence_below_certificate_threshold, the actual confidence and the threshold, and the assessment itself still returns normally with certificate: null. Confidence is billed from 0.40, so there is a band in which an assessment is charged but not certified - branch on certificate being present, never assume the PDF.

The assessment tells you what moved the number: confidence_components lists the scene-count, baseline, optical and interval terms and the direction_factor. This bites hardest on the SAR path. When the optical series does not confirm the direction of the radar signal the assessment carries the warning direction_not_confirmed and a 0.8 multiplier - and that is exactly the SBX-LODGING-1 demo. Run fresh on 2026-10-03 (method damage-1.0, config_hash e3f43ecf61375aa8) the four sandbox cases answer:

Parcel, event damage_pct damage_pct_ci confidence Certificate
SBX-LODGING-1, 2025-07-04, storm 85.0 [50.0, 85.0] 0.405 no - PDF answers 422, confidence_below_certificate_threshold; billed on a live key (above 0.40), not certified
SBX-LODGING-3, 2024-07-03, storm 50.0 [50.0, 51.75] 0.5113 yes - by one hundredth
SBX-FIELD-05, 2024-07-19, storm 0.0 [0.0, 0.0] 0.46 no - a “nothing happened” result that is billed but not certified (warnings no_sar_baseline, direction_not_confirmed)
SBX-DROUGHT-1, 2023-07-20, drought null null null no - the job completes with status: insufficient_data, reason: no_ndvi_observations, billable: false

Three of the four sit within a few hundredths of the 0.40 and 0.50 lines on purpose: that is where code that assumes the PDF breaks. The drought case is the honest-failure path - a completed job whose result says the record did not support a number, costs nothing and has no certificate. Run it to see the shape:

Terminal window
# a minute after the previous assessment (2 per minute in the sandbox):
DROUGHT=$(curl -s "$BASE/parcels?limit=25" -H "$AUTH" \
| python -c "import sys,json;d=json.load(sys.stdin)['data'];print([p['parcel_id'] for p in d if p['external_ref']=='SBX-DROUGHT-1'][0])")
curl -s -X POST "$BASE/damage/assess" -H "$AUTH" \
-H 'Content-Type: application/json' -H "Idempotency-Key: $(uuidgen)" \
-d '{"parcel_id":"'"$DROUGHT"'","event_date":"2023-07-20","peril":"drought","claim_ref":"CLM-2"}'
# then poll the job exactly as above; GET /damage/{id} returns status: insufficient_data

The numbers above come from fresh assessments. The five pre-seeded records (SBX-CLAIM-...) are stored results from the last reseed and can carry an older config_hash and a different confidence - at the time of writing the stored SBX-CLAIM-2024-003 (LODGING-3) and SBX-CLAIM-2023-005 (DROUGHT-1) differ from a fresh run. One more reason to branch on certificate, not on a number you remember.


The five rules that save you a support ticket

Section titled “The five rules that save you a support ticket”
  1. Idempotency-Key is required on every POST - including the CAP calculations and POST /indices/batch, which are reads. Send a UUID per logical operation and reuse it on retries. The repeat returns the original result with Idempotent-Replay: true and is never billed twice. Concurrent requests with the same key are serialised: only one runs, the others get 409 idempotency_conflict with detail.state: "in_progress" and a Retry-After header (at most 120 s) - wait and resend, you will receive the stored result.
  2. Branch on error.type, never on error.message. Types are a contract, messages are prose. The full catalog is in the error catalog, and /v1/openapi.json declares, for every operation, only the error statuses it can really return, all with the same ErrorEnvelope schema (error.type is an enum of the catalog, and each status description lists the types possible there), so a generated client parses errors correctly and sees which types to branch on per call. A POST that answers 201/202 also declares 200 - the idempotent replay of rule 1.
  3. Keep X-Request-Id. It is on every response, success or failure. Without it we cannot trace anything.
  4. Watch the rate-limit headers. X-RateLimit-Limit, -Remaining, -Reset, -Pool. Three pools: standard (every GET), write (every other method - parcels, POST /indices/batch, CAP calculations, webhook management), expensive (damage assessment, index-product backtest, portfolio risk refresh). A 429 tells you which pool ran out. Each pool also has a per-second burst allowance (burst in your plan): a burst 429 adds X-RateLimit-Burst-Limit and X-RateLimit-Burst-Reset and Retry-After: 1, while X-RateLimit-Remaining keeps describing the minute budget you still have.
  5. Pagination is cursor based. Pass next_cursor back as ?cursor= until has_more is false. Offsets are not supported - ?offset=, like any unknown query parameter or filter value, is a 400 validation_error, never a silently unfiltered list.
Sandbox (sk_test_) Live (sk_live_)
Data 20 shared LT parcels, 5 years of history your parcels
Your own parcels up to 10 extra, 24 months of history plan quota
Copernicus calls none - history is pre-seeded real, queued and backfilled
Billing usage_event written, billable: false always billed
Rate limits 60 standard / 10 write / 2 expensive per minute per plan
Webhooks delivered for real, so you can test your endpoint delivered
Nightly parcels are rebuilt; your own sandbox parcels are cleared untouched

Test and live are separate worlds: a live key cannot see sandbox parcels and a test key cannot see live ones. A test key sees the 20 shared parcels plus its own: the shared ones come back with shared: true (unless your key is the demo account that owns them), are read-only for everyone else (DELETE answers 404) and are rebuilt every night; your own carry shared: false and count towards the sandbox quota of 10. Every parcel carries environment: "test" or "live" so you always know which one you are looking at.

One thing to expect when you add your own parcel in the sandbox: it only has history if its geometry is one we already hold (parcels are cached by geometry hash, so a re-drawn version of a shared parcel inherits its data). A brand new field answers baseline_not_ready and empty series, because the sandbox deliberately never triggers satellite processing. Live keys do trigger it, and the parcel becomes usable as the backfill completes - watch data_status or subscribe to parcel.data_ready.

  1. Confirm your email, then in the portal open Billing and press Activate live: pick a plan and billing cycle and fill in the company and invoicing details. This files an activation request, and nothing is charged when you send it. Live use is paid by invoice; there is no card form in the portal.
  2. Agrovisio operations review the request within one business day, switch the plan and issue the first sk_live_ key through a secure channel (keys are never emailed). From then on you create further live keys yourself in Keys. Your sandbox key keeps working - most teams run their CI against it forever.
  3. Point the same code at the same base URL with the new key. Nothing else changes: identical endpoints, identical response shapes, identical method_version values.
Plan Parcels Products Standard / write / expensive per min
Sandbox 10 own all 60 / 10 / 2
Starter 1 000 parcels, indices, weather 600 / 60 / 5
Growth 10 000 + sar, thermal, yield, damage, index 2 000 / 200 / 20
Scale 50 000 + risk, cap 6 000 / 600 / 60
Enterprise unlimited all 20 000 / 2 000 / 200

Billing is by the monthly peak of active parcels, not by request count - querying the same parcel a thousand times costs nothing extra. Damage assessments and index-product publications are the only per-unit charges, and a damage assessment that ends as insufficient_data is not charged at all. Details in the billing guide.

Responses built on satellite data carry X-Data-Attribution: Contains modified Copernicus Sentinel data. You must keep that attribution when you redistribute derived values. National cadastral data (LT Registru centras, LV Kadastrs, PL GUGiK) is used under the respective open data licences.

Every /v1 response also carries X-Content-Type-Options: nosniff, and the production host adds Strict-Transport-Security (the API is HTTPS only). The HTML documentation pages the API serves itself (/v1/docs, /v1/swagger, /v1/reference) refuse to be embedded in an <iframe> - open them directly. /v1 answers CORS preflights from any origin without credentials and exposes X-Request-Id, the X-RateLimit-* family, Retry-After, Idempotent-Replay, X-Agrovisio-Not-Billable, X-Agrovisio-Warning, X-Data-Attribution, Content-Disposition and the Deprecation / Sunset / Link lifecycle headers to fetch(). Only ever ship a sk_test_ key to a browser; a sk_live_ key in page source is a leaked key, so keep live calls on your server.

Our numbers are derived satellite estimates, not ground measurements. A damage certificate is a technical report produced by a documented methodology, not an expert’s legal opinion. You remain responsible for having a lawful basis to monitor the parcels you send us (loan agreement, insurance policy); a parcel geometry with a cadastral number can identify a natural person, so never put a person’s name in external_ref. More in data attribution.

The error catalog, the endpoint list and the Postman collection are generated from the running API, not written by hand, and this site re-imports them on every build. Two checks keep the promises on this page honest, and both are run before a release:

  • scripts/qa/int_15min.py - walks exactly the path above with nothing but this page and the sandbox key, and fails if any step breaks;
  • scripts/qa/int_postman_check.py - fires every request in the Postman collection against the sandbox and fails on the first non-2xx.

Every curl block on this page was last run verbatim against the sandbox on 2026-10-03; the numbers quoted here are what it answered that day. The seed script prints the parcel ids and the five prepared damage assessments (SBX-CLAIM-2025-001 … SBX-CLAIM-2023-005); they are stored results and, as noted above, can lag behind a fresh run of the same request.

support@agrovisio.ai - always include X-Request-Id, the endpoint and the UTC timestamp. Status page: /status and status.agrovisio.ai. SLA: 99.5 % monthly availability, p95 under 400 ms for standard reads.