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.
Step 1: a key
Section titled “Step 1: a key”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.
Step 2: search both sensors
Section titled “Step 2: search both sensors”Available now Search both collections in one request, keeping scenes under 40% cloud:
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-l2aandlandsat-ot-l2, with native ids such asS2C_T15TVK_20260712T171233_L2AandLC08_L2SP_027029_20260718_02_T1. s2:processing_baselineon 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-landsatbucket. 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.
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"} }'doneEach 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 npimport 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 idndvi) 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.
Step 4: the same NDVI on a web map
Section titled “Step 4: the same NDVI on a web map”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.
Step 5: one pixel over time
Section titled “Step 5: one pixel over time”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.
What this walkthrough needs
Section titled “What this walkthrough needs”| 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 |