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:
export BASE=https://api.staging.agrovisio.ai/v1Minute 0-2 - get a sandbox key
Section titled “Minute 0-2 - get a sandbox key”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
Register in the portalsk_test_...key. No payment details are needed until you request live access, and live use is paid by invoice (see Going live below). -
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:
export KEY=sk_test_16e7fa3b7095c7f52f094bec33b48413Keys 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:
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.
Minute 2-5 - find a parcel
Section titled “Minute 2-5 - find a parcel”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.
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:
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])")Minute 5-8 - your first NDVI time series
Section titled “Minute 5-8 - your first NDVI time series”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:
value: nullis an answer, not an error. When the pixels were clouded the row carriesreasoninstead of a number. A wrong number in an insurance or credit decision costs more than a missing one.anomalyis 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”.zisnullwithz_reasonwhen the historical spread is too small to divide by - the first row above is exactly that case (std_below_threshold):anomaly_pctstill says the field is 16 % above its norm, but no z-score is invented from a standard deviation of 0.016.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. Itsqualitygrades the seasons that actually fed this series (three here - 2022 to 2024, the complete seasons the five year sandbox history holds before 2025 - hencemedium), whiledata_status.baseline_qualitygrades the parcel’s history as a whole - they differ on purpose.?quality=goodkeeps only one observation class (good,partialorcloud_masked) andobservationsthen counts what is returned.method_versionandconfig_hashpin 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”AUTH="Authorization: Bearer $KEY"
# Radar events: lodging, mowing, harvest, tillage, regrowthcurl -s "$BASE/parcels/$PARCEL/sar/events?from=2025-05-01&to=2025-09-01" -H "$AUTH"
# Weather: forecast, history, agro-climate indicatorscurl -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 intervalcurl -s "$BASE/parcels/$PARCEL/yield-forecast" -H "$AUTH"
# Risk score: six components, percentile rank, driverscurl -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:
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:
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:
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.completedX-Agrovisio-Delivery: dlv_9c2e...X-Agrovisio-Timestamp: 1758200400X-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:
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:
# 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_dataThe 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”Idempotency-Keyis required on every POST - including the CAP calculations andPOST /indices/batch, which are reads. Send a UUID per logical operation and reuse it on retries. The repeat returns the original result withIdempotent-Replay: trueand is never billed twice. Concurrent requests with the same key are serialised: only one runs, the others get409 idempotency_conflictwithdetail.state: "in_progress"and aRetry-Afterheader (at most 120 s) - wait and resend, you will receive the stored result.- Branch on
error.type, never onerror.message. Types are a contract, messages are prose. The full catalog is in the error catalog, and/v1/openapi.jsondeclares, for every operation, only the error statuses it can really return, all with the sameErrorEnvelopeschema (error.typeis 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 answers201/202also declares200- the idempotent replay of rule 1. - Keep
X-Request-Id. It is on every response, success or failure. Without it we cannot trace anything. - Watch the rate-limit headers.
X-RateLimit-Limit,-Remaining,-Reset,-Pool. Three pools:standard(everyGET),write(every other method - parcels,POST /indices/batch, CAP calculations, webhook management),expensive(damage assessment, index-product backtest, portfolio risk refresh). A429tells you which pool ran out. Each pool also has a per-second burst allowance (burstin your plan): a burst429addsX-RateLimit-Burst-LimitandX-RateLimit-Burst-ResetandRetry-After: 1, whileX-RateLimit-Remainingkeeps describing the minute budget you still have. - Pagination is cursor based. Pass
next_cursorback as?cursor=untilhas_moreis false. Offsets are not supported -?offset=, like any unknown query parameter or filter value, is a400 validation_error, never a silently unfiltered list.
Sandbox vs live
Section titled “Sandbox vs live”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.
Going live
Section titled “Going live”- 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.
- 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. - Point the same code at the same base URL with the new key. Nothing else
changes: identical endpoints, identical response shapes, identical
method_versionvalues.
| 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.
Attribution and limits
Section titled “Attribution and limits”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.
How these documents stay true
Section titled “How these documents stay true”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
Section titled “Support”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.
