Skip to content

Cross-sensor NDVI walkthrough

This walkthrough builds a cloud-free summer NDVI over south Minneapolis from Sentinel-2 and Landsat together: first the scenes, then a GeoTIFF, then a map layer. The area is the box from longitude -93.30 to -93.20 and latitude 44.90 to 45.00, about 87.5 square kilometres. The window is 1 June to 31 August 2026.

The full v1 contract does this in one cross-sensor collection with server-side band math. Lean v1 does it per sensor, with the ratio computed on your side. Each step says which is which.

Available now Get a key from the owner and set SDN_API_KEY. See Quickstart.

Coming later Sign up, verify your email and create a key from the dashboard.

Available now Search both collections in one request, keeping scenes under 40% cloud:

Terminal window
curl -s -X POST https://api.satellitedata.net/v1/search \
-H "X-API-Key: $SDN_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"collections": ["sentinel-2-l2a", "landsat-ot-l2"],
"bbox": [-93.3, 44.9, -93.2, 45.0],
"datetime": "2026-06-01T00:00:00Z/2026-08-31T23:59:59Z",
"limit": 50,
"query": {"eo:cloud_cover": {"lt": 40}}
}'

Read from the answer:

  • Items come back in their own collections, sentinel-2-l2a and landsat-ot-l2, with native ids such as S2C_T15TVK_20260712T171233_L2A and LC08_L2SP_027029_20260718_02_T1.
  • s2:processing_baseline on Sentinel-2 items is 04.00 or later for current scenes, so their stored values carry the −0.1 reflectance offset. Asking for reflectance (step 3) applies it for you.
  • Landsat assets point at the requester-pays usgs-landsat bucket. You do not need them: the next step renders on the server.

Coming later One search over the virtual optical-sr collection, which removes duplicate observations of one overpass, and a CQL2 filter on platform and eo:cloud_cover.

Step 3: a GeoTIFF of red and nir08, per sensor

Section titled “Step 3: a GeoTIFF of red and nir08, per sensor”

Available now Ask POST /v1/process for red and nir08 as reflectance. The common names do the crosswalk: nir08 is B8A on Sentinel-2 and SR_B5 on Landsat 8 and 9. Do not use Sentinel-2 B08 here; see the B08 trap.

Terminal window
for C in sentinel-2-l2a landsat-ot-l2; do
curl -s -L -X POST https://api.satellitedata.net/v1/process \
-H "X-API-Key: $SDN_API_KEY" \
-H "Content-Type: application/json" \
-o "$C.tif" \
-d '{
"input": {
"bounds": {"bbox": [-93.3, 44.9, -93.2, 45.0]},
"data": [{
"type": "'"$C"'",
"dataFilter": {
"timeRange": {"from": "2026-06-01T00:00:00Z", "to": "2026-08-31T23:59:59Z"},
"maxCloudCoverage": 40,
"mosaickingOrder": "leastCC"
},
"processing": {"units": "REFLECTANCE"}
}]
},
"bands": ["red", "nir08"],
"output": {"width": 512, "height": 512, "crs": "EPSG:4326"}
}'
done

Each request keeps the least cloudy scenes that together cover the box (at most 6). The file has two float32 bands, described as red and nir08. Then compute NDVI from the reflectance:

import numpy as np
import rasterio
for name in ("sentinel-2-l2a", "landsat-ot-l2"):
with rasterio.open(f"{name}.tif") as src:
red, nir = src.read(1).astype("float32"), src.read(2).astype("float32")
profile = src.profile
with np.errstate(divide="ignore", invalid="ignore"):
ndvi = (nir - red) / (nir + red)
profile.update(count=1, dtype="float32", nodata=np.nan)
with rasterio.open(f"{name}-ndvi.tif", "w", **profile) as dst:
dst.write(ndvi, 1)

Because both files hold reflectance (offset applied), the two NDVIs are comparable. REFLECTANCE is the default; it is spelled out here for clarity. With units: DN and harmonize: none you would get stored integers, and NDVI on raw Sentinel-2 Collection 1 integers is biased by the +1,000 offset; see Values, scale and offset.

Pixels are not cloud-masked here. To mask, add SCL (Sentinel-2) or QA_PIXEL (Landsat) to bands and apply it as shown in Cloud mask.

Coming later One request over optical-sr with server-side NDVI:

  • "expression": "(nir08 - red) / (nir08 + red)" (or the index id ndvi) and a float32 output,
  • "harmonize": "bandpass_adjusted", which maps Sentinel-2 onto Landsat OLI with the HLS coefficients before the ratio,
  • "cloudMask": {"exclude": ["nodata", "saturated_defective", "cloud", "cloud_shadow", "cirrus", "cloud_adjacent"], "dilate_m": 150},
  • "mosaickingOrder": "leastCC",
  • and a JSON output mode that lists which scene supplied each share of the pixels and what harmonization it received.

The design estimates that request at 1.333 units.

Available now For an interactive map, use the ndvi preset rather than process. Load one layer per sensor:

const window_ = 'datetime=2026-06-01T00:00:00Z/2026-08-31T23:59:59Z&maxCloudCoverage=40';
for (const collection of ['landsat-ot-l2', 'sentinel-2-l2a']) {
map.addSource(collection, {
type: 'raster',
url: `https://api.satellitedata.net/v1/tiles/ndvi/WebMercatorQuad/tilejson.json?collection=${collection}&${window_}`,
tileSize: 256,
});
map.addLayer({ id: collection, type: 'raster', source: collection });
}

This uses the MapLibre setup from Map tiles, which adds the key to tile requests. The Sentinel-2 layer sits on top; hide it to compare with Landsat.

The lean ndvi preset uses Sentinel-2’s broad NIR band B08 (nir), as the web map does, not nir08, and has no pixel cloud mask. Its Sentinel-2 values can therefore differ slightly from the nir08 NDVI of step 3.

Coming later One layer over ?collection=optical-sr, with the preset’s cloud mask and bandpass_adjusted, so the map and the GeoTIFF from step 3 agree pixel for pixel.

Coming later GET /v1/timeseries?lon=-93.25&lat=44.95&datetime=2026-06-01T00:00:00Z/2026-08-31T23:59:59Z&index=ndvi will return one NDVI value per observation across both sensors, with cloudy observations kept as null so you can tell “cloudy” from “no overpass”, and GET /v1/usage will show the units spent.

Today, repeat step 3 with a small box and dataFilter.ids set to each scene id from the search in step 2.

Step Lean v1 Full contract
Key from the owner POST /v1/keys
Search POST /v1/search over both collections, query filter optical-sr, CQL2 filter
GeoTIFF POST /v1/process per sensor, NDVI on your side one request with expression, bandpass_adjusted, cloudMask
Map ndvi preset per collection ndvi preset over optical-sr
Time series repeated process calls GET /v1/timeseries