UFFDA Developer API
POST /v1/fields/enrich·POST /v1/aoi/aggregate
What this gives you
Two endpoints, one family. The rule for choosing is one sentence: boundaries you bring → enrich; an area you bound → aggregate.
| POST /v1/fields/enrich | POST /v1/aoi/aggregate | |
|---|---|---|
| You send | A FeatureCollection of field polygons you already have (≤25) | One area-of-interest (AOI) Polygon or MultiPolygon — an area you drew or defined |
| You get back | Per-feature enrichment — each field's own crop history, soil, drought, land cover, weather | One areal rollup — total acres, acres-by-crop, optional AOI-level soil/drought/weather |
| The question it answers | “Enrich these specific boundaries.” | “Measure this area.” |
| Field cap | 25 features per call | No cap — one histogram over the whole polygon |
Size is never the router. A 40-acre AOI and a 400,000-acre AOI both go to aggregate; a single field and a 25-field farm both go to enrich.
POST /v1/fields/enrich
Boundaries you bring. For fields you already have — enrich each one.
A two-minute first call
Paste this and run it.
curl -X POST https://uffda.ag/api/v1/fields/enrich \
-H "Content-Type: application/json" \
-d '{
"type": "Feature",
"geometry": {
"type": "Polygon",
"coordinates": [[
[-93.780, 42.300], [-93.770, 42.300],
[-93.770, 42.308], [-93.780, 42.308],
[-93.780, 42.300]
]]
}
}'The same call in Python:
import requests
resp = requests.post(
"https://uffda.ag/api/v1/fields/enrich",
json={
"type": "FeatureCollection",
"features": [
{"type": "Feature", "id": "field-001",
"geometry": {"type": "Polygon", "coordinates": [...]}}
],
"layers": ["crop_history", "soil"],
"options": {"cdl_years": [2020, 2021, 2022, 2023, 2024]}
},
timeout=35,
)
resp.raise_for_status()
data = resp.json()
for feat in data["features"]:
print(feat["id"], feat["properties"]["enrichment"]["crop_history"])And in JS:
const res = await fetch("https://uffda.ag/api/v1/fields/enrich", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
type: "FeatureCollection",
features: fields, // your GeoJSON features
layers: ["soil", "weather"],
options: { weather_window: { start: "2025-04-01", end: "2025-10-31" } },
}),
});
const data = await res.json();
console.log(data.disclaimer); // every response carries it
data.features.forEach(f => console.log(f.id, f.properties.enrichment));What you POST
A GeoJSON Feature or FeatureCollection of field polygons. Coordinates in WGS84 (EPSG:4326). Standard GeoJSON — whatever MapLibre, Leaflet, or your own pipeline already works with.
Optional fields:
- layersFilter which data layers to compute. Omit it and all run. Values:
crop_history,soil,drought,land_cover,weather,forest_loss,protected_area,irrigation. - options.cdl_yearsWhich years of crop history to pull — 1 to 10 years, each between 2008 and 2030 (years beyond the latest published CDL vintage return an honest
nodatastatus, not an error). Default: the trailing 8 years from the latest CDL vintage — currently 2018–2025. - options.weather_windowDate range for weather data. Default: trailing 12 months. Max span: 400 days (~13 months) per call — the weather layer caps each call to guard against runaway upstream queries. Need a longer history? Split into a few calls by date range and stitch the results client-side.
- options.units
"metric"(default) or"imperial". Affects display units; the envelope always names the canonical upstream unit and any conversion applied.
Limits: 25 features per call. 30-second wall-clock timeout. Both limits are sync-only constraints; a batch/async endpoint is on the roadmap.
Sending a large area polygon? If you have an area you want to measure — a county, a watershed, a drawn region — use POST /v1/aoi/aggregate instead. It uses a flat-cost histogram and has no feature cap.
What comes back
A GeoJSON FeatureCollection. Your input geometries come back unchanged, with two additions on each feature's properties:
- derived
centroid— a point chosen to sit inside your field rather than averaged from its corners, so holes and concave shapes don't push it out. Plus area in hectares and acres, and a bounding box. - enrichmentOne block per layer, each value wrapped in a provenance envelope.
The top-level response also carries:
- uffdaMetadata: API version, endpoint, request ID, computed-at timestamp, feature count, layer set, warnings, and your current rate-limit position. The last four are on successful
enrichcalls only — see Rate limits for exactly where the rate-limit fields do and do not appear. - disclaimerOn every response, success or error. (More on this below.)
If a layer fails for one field and succeeds for others, you get back the successful layers and a properties.errors note on the field that had trouble. The call returns 200. Partial failure is expected — don't assume all-or-nothing.
The provenance envelope, and why it's there
Every value in enrichment is wrapped in the same shape:
{
"value": 27.3,
"value_unit": "g/kg",
"source": {
"id": "uffda:source/soilgrids",
"name": "ISRIC SoilGrids 2.0",
"scope": "global · model estimate"
},
"license": {
"code": "CC-BY-4.0",
"attribution": "ISRIC — World Soil Information (2020). SoilGrids 2.0.",
"informational_only": true
},
"vintage": "2.0 (2020-present, stable)",
"confidence": { "kind": "interval", "q05": 18.6, "q95": 38.1 },
"units": {
"canonical": "g/kg",
"displayed": "g/kg",
"conversion": null
}
}(Full schema in the API reference.)
confidence.kind changes shape by layer, so read it before comparing numbers across layers. "interval" (soil, above) is a per-value uncertainty band on the number you got back. "class_accuracy" (crop_history) is a dataset-wide figure that describes the source, not your field. Each layer's own section below says which one it carries.
Ag data has a unit-collision problem — soil organic matter (SOM) and soil organic carbon (SOC) aren't the same number, and the errors look plausible until they aren't. The units block makes the conversion explicit, every time. The source.id pins each value to the upstream dataset. The license block carries the upstream attribution.
license block carries "informational_only": true. These are UFFDA's plain-language readings of upstream metadata — not legal advice. Verify license terms against the upstream source before relying on them for compliance or legal claims.Layers
crop_history
USDA NASS Cropland Data Layer (CDL). Annual crop classification at 30m resolution (2024 and 2025 vintages are natively 10m at the source), the contiguous United States (CONUS) only, 2008 through the latest published vintage (currently 2025). Request any 1–10 years via options.cdl_years — default is the trailing 8 years from the latest vintage. Per-year value_label reads like "Corn" or "Soybeans". Fields outside CONUS get a null value and a note in properties.errors.
How the crop is sampled — read this before you trust a year. Each year's value is a single 30 m CDL pixel, read at one representative point inside your field polygon. It is not a majority vote, an area-weighted mode, or any other summary of the whole field. For a uniform field that distinction does not matter. For a field that was split, planted in strips, or is genuinely mixed, the answer is whatever crop happens to sit at that one point — not necessarily what covers most of the field. That point is chosen to sit inside the field, including on fields with holes or concave, oddly shaped boundaries — a plain average of the corner coordinates can land outside those. The point is inside your boundary; the 30 m pixel around it may not be, so near a road or farmstead you can still get a read that isn't your crop. If you need a field-level answer on heterogeneous ground, sample several points yourself and reconcile them. The method string and the sampling block on crop_history say the same thing in machine-readable form.
What the accuracy figure does — and does not — measure. crop_history.confidence.accuracy_pct (~92% for US corn/soy, lower for minor crops) is the user's accuracy USDA publishes in the 2024 CDL metadata — one pixel, measured nationwide. It describes the CDL dataset, not your field and not the one point sampled above. Treat it as background on the source, not a confidence score for the value you just got back.
Each year in crop_history.values[] carries a status field:
okA CDL crop class was returned for this year.valueandvalue_labelare populated.nodataGenuine absence — CDL has no classification at this point for this year (unreleased year, off-coverage pixel). Not a failure; retrying will return the same answer.unavailableThe lookup didn't come back — a transient upstream issue. The year entry also carriesretryable: trueand a plain-languagenote. Retrying the call is the right move; a future call will re-fetch rather than serve the failed result from cache.
The block also carries an availability roll-up across all requested years: "complete" (all years returned a result), "partial" (some years unavailable — retry will fill gaps), or "unavailable" (no years came back). Check this field first if you need a complete history; branch on individual status values for per-year handling.
soil
SSURGO (the USDA’s detailed US soil survey) as primary for CONUS fields (survey-grade, area-weighted over map units intersecting your polygon); SoilGrids 2.0 as companion (global model estimate, read at a single representative point inside your polygon, with SoilGrids’ own Q0.05/Q0.95 uncertainty band). Outside CONUS, SoilGrids is primary — the whole soil answer rather than a second opinion.
SSURGO contributes Soil Organic Matter (SOM, % by weight) as a single 0–30 cm topsoil aggregate (key: som@0-30). SOC and per-depth carbon slices come only from the SoilGrids companion (keys: soc@0-15, soc@15-30, soc@0-30, units: g/kg). SOM and SOC are different numbers — the Van Bemmelen conversion (SOM ÷ 1.724) appears in the units block as a clearly-labeled illustrative estimate, never a measured value.
Other metrics: pH, clay, sand, silt, CEC (cation exchange capacity), nitrogen, bulk density. Both sources return per-depth slices for these (0–15, 15–30, 0–30 cm) — the single 0–30 cm key applies to soil organic matter alone, which is the one metric SSURGO genuinely publishes only as a topsoil aggregate. A depth_note field on CONUS soil blocks spells that out.
One exception worth planning for: nitrogen is a SoilGrids metric only. SSURGO does not carry it, so on a CONUS field whose SoilGrids companion has not hydrated — or has no coverage at all — nitrogen is absent from the response entirely rather than present-and-null. Read it off the companion, not the primary block.
On CONUS, the first (cold) call for a field returns SSURGO quickly (sub-2s typical) with companion_state: "pending" — the SoilGrids companion is deferred. An immediate re-hit attempts to hydrate the SoilGrids companion; ISRIC's response time varies and can run 20+ seconds, occasionally exceeding our per-layer timeout — in that case soil comes back null with a note in properties.errors for that call. That's upstream ISRIC latency, not a bug on our end; retry after a short wait rather than immediately. To know whether the companion is there and whole, read enrichment.soil.companion_state. Do not test enrichment.soil.companion !== null as a completeness check — a companion object can be present and still be missing readings. Outside CONUS, SoilGrids is computed on the first call and this two-step pattern does not apply.
The companion_state field on CONUS responses tells you whether the SoilGrids companion is still hydrating, ready, incomplete, or never coming, without inspecting the companion object itself. Four values, plus absence:
"pending"Not computed yet. Call again in a moment and it will hydrate."partial"Some readings came back and some didn't. We fetch SoilGrids in two halves, and one half can fail on its own — so you may get bulk density, cation exchange capacity, nitrogen and silt while soil organic carbon, pH, clay and sand are absent, or the reverse.companion_missing_metricslists exactly which readings are missing. We don't store a partial result, so calling again can still fill it in."no_coverage"Terminal. ISRIC answered and has no SoilGrids values at this point. This is the one that matters for your retry logic: unlike"pending", calling again will never populate the companion. Treat SSURGO as the whole answer for this field.nullThe companion is present and complete — every metric we publish, at every depth. A half-filled companion reports"partial"instead.(absent)Nocompanion_statekey at all — the field is outside CONUS, where SoilGrids is the primary source. Readprimary_statethere instead.
Outside CONUS, read primary_state instead. Everything above answers a CONUS question — is the second opinion whole? Outside CONUS there is no second opinion: SoilGrids is the entire soil answer. So those responses carry primary_state with the same three meanings — null for present and complete, "partial" when some readings are missing (named in primary_missing_metrics, not cached, so calling again can still fill it), and "no_coverage" when ISRIC has nothing at this point. There is no "pending" — outside CONUS soil is computed on the first call.
Until 19 August 2026 these responses said nothing at all when a reading was missing, so a half-filled answer looked exactly like a whole one. If you cached soil values for non-US fields before that date, re-fetch them and check primary_state.
When soil comes back null, check whether it can ever come back. soil is null in two situations that used to be indistinguishable, and properties.errors now tells them apart. If the entry says the location has no soil data mapped, that is permanent — open water and permanent ice sit outside the soil model, and retrying returns the same answer. Any other soil error is a transient upstream problem and is worth retrying after a short wait. Before 19 August 2026 both said soilgrids-unavailable, so retry loops could spin forever on a field in the middle of an ocean.
// CONUS, first call (cold) — SoilGrids not yet computed. Call again to hydrate:
{ "companion": null, "companion_state": "pending" }
// CONUS, hydrated — every expected reading is present:
{ "companion": { /* SoilGrids values */ }, "companion_state": null }
// CONUS, but only some readings came back. Not cached — call again to fill it:
{
"companion": { /* the readings that did arrive */ },
"companion_state": "partial",
"companion_missing_metrics": ["soc@0-15", "soc@15-30", "soc@0-30", "phh2o@0-15", "..."]
}
// CONUS, but ISRIC has nothing at this point. TERMINAL — retrying won't help:
{ "companion": null, "companion_state": "no_coverage" }
// Outside CONUS — SoilGrids is primary; the companion concept doesn't apply.
// Completeness is reported on the primary instead:
{ "companion": null, "primary_state": null } // present and complete
// Outside CONUS, only some readings came back. Not cached — call again:
{
"primary_state": "partial",
"primary_missing_metrics": ["soc@0-15", "soc@15-30", "soc@0-30", "phh2o@0-15", "..."]
}
// Outside CONUS, ISRIC has nothing here. TERMINAL — retrying won't help:
{ "primary_state": "no_coverage" }drought
US Drought Monitor — the latest weekly release. The vintage field carries the release date. The value_label reads like "D2 — Severe Drought". CONUS only.
Not public domain — carries a required verbatim credit. USDM permission terms require the exact NDMC/USDA/NOAA/NASA credit line returned in this layer's license.attribution field; copy it in full, don't shorten it to "US Drought Monitor."
land_cover
ESA WorldCover 2021 at 10m. Dominant class + proportions of others. The confidence.pct_in_class field tells you how much of your polygon was in the dominant class.
License: CC-BY-4.0 — attribution to ESA WorldCover is required; see this layer's license.attribution field for the citation to use.
weather
NASA POWER daily at a representative point inside your field (the same point used for derived.centroid), summarized over your requested window (default: trailing 12 months). NASA POWER's own grid is about half a degree wide, so which exact point you sample rarely changes the result. Summary metrics: growing degree days (GDD) total, precipitation total, average high and low temperature. The units block declares base and conversion for each.
forest_loss
Hansen Global Forest Change v1.12 (UMD / Google / USGS / NASA), 30 m, global, annual 2001–2024. License: CC-BY-4.0. Returns forest_loss_pct (share of the field with tree-cover loss since 2001), forest_loss_most_recent_year, and a forest_loss_detail block with per-year loss in hectares and pixels plus a total loss area.
Hansen measures tree-cover loss — harvest, fire, disease, and other disturbance — not deforestation specifically. The layer is useful for flagging loss events and tracking change over time; read the provenance envelope before drawing conclusions about cause.
protected_area
USGS Protected Areas Database of the United States (PAD-US) 3.0, 2022. License: CC0 public domain. Returns a 4-state overlap check against all US protected lands — federal, state, local, private, tribal, and marine — with true polygon intersection (not centroid proximity). US and territories only.
The response carries a state field with one of four values:
no_overlapConfirmed all-clear — the field does not intersect any protected area per PAD-US 3.0.overlapOne or more protected areas intersect the field.overlap_ac,overlap_pct, and anareasarray with per-unit name, manager type, GAP status, and acreage are included.out_of_coverageField is outside US / territory coverage — PAD-US does not apply.unavailableThe upstream PAD-US service could not be reached at query time. Try again; ano_overlapis only ever returned when the service confirmed it — never on a failed call.
A warning field appears when the PAD-US query exceeded its result-transfer limit — the overlap check may be incomplete for that field; verify against the upstream source.
irrigation
NASA LP DAAC LGRIP30 L3 V002 (Landsat-Derived Global Rainfed and Irrigated-Cropland Product), 30 m, CONUS, 2020 single-year snapshot. License: CC0 (NASA ESDIS open data policy — no copyright restriction; attribution to NASA/USGS/LP DAAC and Teluguntla et al. (2018, 2024) encouraged). Returns the dominant irrigation classification for the field and a percentage breakdown of irrigated vs. rainfed vs. non-cropland pixels.
The response carries a state field with one of five values:
irrigatedDominant class is irrigated cropland (LGRIP30 class 2). Includesirrigated_pct,rainfed_pct, andnon_cropland_pctbreakdowns.rainfedDominant class is rainfed cropland (LGRIP30 class 3). Same breakdown fields.non_croplandNo irrigated or rainfed cropland detected — field classified as non-cropland.out_of_coverageField is outside CONUS / LGRIP30 coverage — irrigation data is not available here.unavailableCOG fetch or compute failed at query time. Try again; a result is only ever returned when the data was successfully sampled — never on a failed call.
Do not use this signal on its own. That is not a hedge, it is a measured result from our own work: LGRIP30's single-epoch irrigated class, applied alone, overcounts USDA county irrigated acreage by a median of +102%, and by up to +1,123% in humid counties, where any false positive lands on a small true denominator. A single-epoch classifier cannot separate land that is irrigated from land that merely looked irrigated in one wet window. Read irrigation as "an irrigation signal in the 2019–2021 Landsat record," never as "irrigated," and corroborate it with a multi-year persistence source before you count anything. The full method and the county-level check are in the irrigated-fields write-up.
Note: LGRIP30 is a single 2020 snapshot derived from Landsat-8. Irrigation status can change year to year. Informational only. Cite: Teluguntla et al. (2018) Remote Sensing 10(12):1920, DOI: 10.3390/rs10121920; dataset: Teluguntla et al. (2024) NASA LP DAAC LGRIP30 L3 V002.
POST /v1/aoi/aggregate
Area you bound. Draw or define a region — get its total acreage, crop breakdown, and optional whole-area soil, drought, and weather. No field cap; one flat-cost histogram over the exact polygon.
What you POST
A single GeoJSON Polygon or MultiPolygon in EPSG:4326, wrapped in an aoi field.
- aoiRequired. GeoJSON
PolygonorMultiPolygon, EPSG:4326. - yearCDL year (2008–current). Defaults to 2024. Acreage values carry the requested year. The optional layers each date themselves, but not uniformly:
soilcarriessourceandvintage;droughtcarries neither and dates itself withrelease_dateinstead;weathercarries neither and dates itself withwindow. Every layer names its method in amethodstring, and the top-levelprovenancearray lists every source that contributed to the response. Read the field each layer actually has. - includeOptional array of additional whole-area layers:
"field_detail","soil","drought","weather","land_cover","protected_area","irrigation","crop_type","forest_loss". Omit for acreage-only (fastest).field_detailreturns the count and size distribution of Fields of The World (FTW) field boundaries inside the AOI (CONUS only; geodesic areas). Add"units": "hectares"to get size stats in hectares instead of acres. Theprotected_arealayer returns the same 4-state overlap result as the per-field endpoint, computed over the exact AOI polygon.land_coveris global (ESA WorldCover 2021, same as the enrich layer — see above) and runs both inside and outside CONUS.irrigationis CONUS-only (LGRIP30, 2020); outside CONUS it returnsunavailable_off_conus.crop_typeis global (ESA WorldCereal, 2021); percentages may not sum to 100% (independent binary predictions).forest_lossis accepted but not yet computed for whole areas — it returns an honest{ state: "coming_soon" }today (per-field forest-loss stats are live on enrich). See Optional layers below for full response shapes.
AOIs outside the contiguous US: CDL acreage is CONUS-only, so the acreage block comes back { state: "unavailable_off_conus" } — never a fake zero. This is a 200, not an error: global layers (weather, land_cover, crop_type) still run with real values, and US-only layers you requested come back unavailable_off_conus with a note. A top-level coverage block ({ conus, ran[], skipped[] }) summarizes what ran and what was honestly skipped, so you never have to guess from a null.
What comes back
One areal rollup, not a per-feature array. The acreage block is always present; optional layers appear alongside it if requested. When "field_detail" is in the include array, field_detail returns total_n (distinct FTW field count, de-duped across tile seams) plus a size distribution with min/max/mean/median and histogram bins. A field counts if its centroid falls inside your AOI — not if it overlaps. A field straddling the boundary is counted whole or not at all, depending on which side its centroid lands, so a count over a small AOI can differ noticeably from the acreage that visually falls inside it. The response says so in field_detail.classified_by ("centroid_point") and classified_by_note. Without it, field_detail.state is "skipped" — the acreage rollup is unaffected and no FTW tile fetch occurs.
How acreage is computed: acreage.total_ac and every by_crop[].acres_est are derived from the true geodesic (WGS84) area of the drawn polygon — the same value in aoi.area_ac — scaled by each CDL class's pixel share of the total raster. This keeps the two acres values consistent and removes the projection distortion that a raw pixel-count method would introduce at northern latitudes. Note: these acres are CDL satellite-classification estimates, not NASS farm-survey counts — the two typically differ 5–15%.
curl -X POST https://uffda.ag/api/v1/aoi/aggregate \
-H "Content-Type: application/json" \
-d '{
"aoi": {
"type": "Polygon",
"coordinates": [[
[-97.5, 41.0], [-97.0, 41.0],
[-97.0, 41.5], [-97.5, 41.5],
[-97.5, 41.0]
]]
},
"year": 2024,
"include": ["field_detail", "soil", "drought", "weather"]
}'{
"aoi": {
"area_ac": 142318.4,
"area_ha": 57581.2,
"cropland_pct": 72
},
"acreage": {
"total_ac": 140127.6,
"year": 2024,
"provenance": "Acreage estimated by applying CDL class proportions to the true geodesic area…",
"by_crop": [
{ "crop_code": "corn", "acres_est": 58153.2, "pct": 41.5 },
{ "crop_code": "soybeans", "acres_est": 43439.6, "pct": 31.0 },
{ "crop_code": "other", "acres_est": 38534.8, "pct": 27.5 }
]
},
"field_detail": {
"state": "complete",
"total_n": 1847,
"size_distribution": {
"unit": "acres",
"min": 0.1, "max": 952.4, "mean": 53.1, "median": 15.2,
"histogram": [
{ "label": "< 10 ac", "count": 742 },
{ "label": "10–40 ac", "count": 511 },
{ "label": "40–160 ac", "count": 463 },
{ "label": "160–320 ac", "count": 89 },
{ "label": "320–640 ac", "count": 38 },
{ "label": "> 640 ac", "count": 4 }
]
}
},
"provenance": ["uffda:source/cdl", "uffda:source/ftw-global"],
"disclaimer": "Informational only — not legal advice and not a warranty.",
"uffda": { "api_version": "v1", "endpoint": "aoi/aggregate", "request_id": "aoi_…" }
}Splitting large draws
The cropland histogram runs over the AOI's projected bounding box, and the upstream raster service caps a single rendered image at 4096 px per side (EPSG:3857, 30 m). Rather than rejecting AOIs that exceed that, aggregate splits the draw into sub-cells internally, fetches each in parallel, and merges the results into one histogram before computing acreage — you never see the seams:
- Compact draws pass through up to roughly 50 million acres. Long, thin strips hit the limit at lower acreage — the bounding box's extent, not the drawn area alone, sets it.
acreage.computed_in_partsreports how many sub-cells were used —1for a single call, more for a split. Acreage stays exact either way; a split adds a note to the top-levelwarningsarray.- Past that ceiling, the draw needs more sub-cells than the 30-second wall budget allows and returns
413 aoi_too_large, withdetailsquoting the realparts_neededagainst themax_partsceiling. You almost never need to split a draw manually below ~50M acres — let the endpoint do it.
Optional layers
land_cover Global · ESA WorldCover · 2021
Returns the areal share of each ESA WorldCover 2021 land-cover class within the AOI — the same 10m dataset as the per-field land_cover layer on enrich. Global coverage; runs inside and outside CONUS. License: CC-BY-4.0 — credit ESA WorldCover.
For very large AOIs the response carries a too_large state and no areal values — shrink the draw if you hit this.
irrigation CONUS only · LGRIP30 · 2020
Returns an areal breakdown of the AOI into three classes — Irrigated, Rainfed, and Non-cropland — each as a percentage, an acreage estimate, and a hectare estimate. Source: NASA LP DAAC LGRIP30 L3 V002 (Landsat-Derived Global Rainfed and Irrigated-Cropland Product), 30 m, nominal 2020 epoch. License: CC0 (NASA ESDIS open data policy; attribution to NASA/USGS/LP DAAC and Teluguntla et al. (2018, 2024) encouraged).
When the AOI centroid is outside CONUS, the response carries irrigation.state: "unavailable_off_conus" — no acreage fields are populated. LGRIP30 coverage is CONUS-only; this is not an error.
Note: LGRIP30 is a single 2020 snapshot. Irrigation status can change year to year. Values are informational only.
The same over-count caveat applies here as on enrich, and it bites harder on a whole area. Used alone, this class overcounts USDA county irrigated acreage by a median of +102%, and by up to +1,123% in humid counties. Do not present irrigation.irrigated_ac as an irrigated-acreage figure. See the enrich irrigation layer for the full caveat and the write-up behind it.
crop_type Global · ESA WorldCereal · 2021
Returns per-product areal share for four crop-type classes: Active cropland, Maize, Winter cereals, and Spring cereals — each as a percentage, an acreage estimate, and a hectare estimate. Source: ESA WorldCereal 2021 global map, 10 m, CC-BY-4.0. Attribution is required under CC-BY-4.0 — credit ESA WorldCereal and include a link to the license in any product that surfaces this data.
Important: these are independent binary predictions — each class is a separate yes/no model, not a mutually-exclusive classifier. The four percentages will not typically sum to 100% and are not expected to. Read each class on its own merits.
For very large AOIs, the response carries crop_type.state: "too_large" and no areal values — WorldCereal is a heavier read than the CDL histogram and has a lower practical size ceiling. Split or shrink the draw if you hit this.
forest_loss Coming soon
Accepted in include, but whole-area aggregation isn't built yet — the response returns an honest { state: "coming_soon" } rather than a fake value. Per-field forest-loss stats (Hansen Global Forest Change) are live today on enrich.
POST /v1/fields/enrich to enrich each boundary individually — soil, crop history, drought, and more per feature. The field_detail layer here counts boundaries you don't have yet; enrich is for ones you do.Rate limits and fair use
Default: 60 requests an hour, 200 a day, and no more than 10 in any 60-second window. A FeatureCollection counts as one request, however many features it holds.
There are no keys here, so the count is kept against a network address rather than an account. That has one consequence worth designing around: you may be sharing a budget with someone else, and you can't tell from the outside.
Everyone behind a single office or campus connection counts against the same 60 an hour. So does everyone on a mobile network that puts many customers behind one address — carrier-grade network address translation — and anything calling from a shared cloud egress address. Traffic that reaches us through a relay can be grouped the same way, depending on how much of your address survives the trip. We keep the grouping as narrow as we can see how to; we are not going to tell you it is one caller per budget, because from where you sit that is not something you can check.
So don't plan against the number 60 — plan against what comes back. X-RateLimit-Remaining is your actual position in whatever budget you are actually in, at that moment, and it is right under both arrangements. If it falls faster than your own call count explains, you are sharing with someone. That is the diagnosis, and retrying harder is not the fix — ask us to raise your limit instead. A note is all it takes.
The Supabase function URL behind the proxy is reachable, but it's an implementation detail — it can move or close without notice, and nothing about it is a supported interface, including how it counts. Stay on the documented endpoints.
Where your rate-limit position actually appears is narrower than you might expect, so branch on the headers rather than on the response body:
- The three
X-RateLimit-*headers are on every successful call to both endpoints. They are not present on error responses — a 4xx tells you nothing about your remaining quota. - The
uffda.rate_limitbody block (limit,remaining,reset_at,window) appears on successfulenrichresponses only. It is absent from everyaggregateresponse and from every error response on both endpoints. X-RateLimit-Resetis formatted differently on the two endpoints:enrichsends Unix epoch seconds,aggregatesends an ISO 8601 timestamp. The inconsistency is ours. Changing the shape now would break whoever is already parsing it, so it's documented rather than changed.
# POST /v1/fields/enrich — X-RateLimit-Reset is Unix epoch seconds
X-RateLimit-Limit: 60
X-RateLimit-Remaining: 57
X-RateLimit-Reset: 1785862800
Retry-After: 42 (only on 429)
# POST /v1/aoi/aggregate — same three headers, but Reset is an ISO 8601
# timestamp, not epoch seconds. Parse per endpoint; don't assume.
X-RateLimit-Limit: 60
X-RateLimit-Remaining: 57
X-RateLimit-Reset: 2026-08-04T17:00:00.000ZIf you're building something that'll push against these — an MRV platform, a batch enrollment workflow, anything running at scale — reach out and we'll raise your limit. No contract required, just a note so we know who's hitting the endpoint.
Errors and edge cases
The error shape is consistent whether you get a 4xx or a 5xx:
{
"error": {
"code": "feature_collection_too_large",
"message": "v1 sync enrich accepts up to 25 features per call.",
"hint": "Split your collection into batches of 25 or fewer and call again.",
"details": { "received": 87, "max": 25 }
},
"disclaimer": "Informational only — not legal advice and not a warranty.",
"uffda": { "api_version": "v1", "request_id": "req_01HXKM4N7Y2Q…" }
}Every error carries the same disclaimer and an uffda envelope with api_version, endpoint, request_id and computed_at, so your error-handling code can rely on a single shape. The success-only fields are not there: no rate_limit, no feature_count, no layer_set.
Common codes:
| Code | HTTP | What happened |
|---|---|---|
| feature_collection_too_large | 413 | Over 25 features. Split into batches. |
| geometry_too_complex | 413 | More than 10,000 coordinates total across all rings — outer ring plus any holes. Simplify upstream; vertex density doesn't improve results. |
| invalid_geometry | 422 | Missing, malformed, or not a Polygon/MultiPolygon. |
| unknown_layer | 422 | A value in layers[] isn't in the whitelist. details.unknown lists the offenders. |
| invalid_options | 422 | enrich only — a value in options failed validation (bad units, cdl_years, or weather_window). details.field names the offender. |
| missing_aoi / invalid_year | 422 | aggregate only — the aoi field is absent, or year is outside 2008–2030. |
| aoi_too_large | 413 | aggregate only — the draw needs more sub-cells than the 30s wall budget allows (past the ~50M-acre compact ceiling). details quotes the real parts_needed vs. max_parts. See Splitting large draws below. |
| upstream_error | 502 | aggregate only — the CDL histogram service didn't answer (or one sub-cell of a split failed — the whole call fails rather than returning a partial acreage total). Retry. |
| rate_limited | 429 | Check Retry-After for the wait in seconds. |
| compute_timeout | 504 | Hit the 30s wall clock. Try fewer layers or a smaller batch. |
| maintenance_mode | 503 | The endpoint is paused on purpose (kill switch), not broken. Response carries top-level maintenance_mode: true. Check back shortly. |
Partial layer failure on a field — e.g., SoilGrids unavailable for one feature — returns 200 with the successful layers and the failure noted in properties.errors for that feature.
Common workflows
MRV field enrollment
You have farmer-supplied polygons and need to qualify fields for a carbon program. POST the collection, pull crop_history and soil, and filter by crop rotation and SSURGO organic matter. The per-year CDL history and the SSURGO–SoilGrids soil comparison are both in the response without a second call.
Gap-filling during verification
Your primary data source has gaps — a field with no farmer-reported crop history for a given year, or missing soil measurements. The CDL and SoilGrids values in the envelope are labeled with source, vintage, and confidence, so you can use them as fill-ins and document exactly what you filled with.
Batch enrollment at scale
v1 is sync, 25 fields per call. For a large enrollment run, loop your collection in batches of 25 and parallelize up to your rate-limit ceiling. The request_id on each response is your audit handle if something goes wrong mid-batch.
API stability
We grow /v1 steadily — new layers and data sources get added to the same endpoint. Adding a field to the response is backward-compatible: clients that don't recognize a key just ignore it.
We won't break what you're already using without notice and a parallel window to move. Removing or renaming a field, or changing what a value means, would be a breaking change — that gets a new endpoint address (/v2), not a silent swap. No schedule for that yet.
When a layer is added, it's noted in Recently shipped and here. No version bump; same URL.
What's next
v1 has two endpoints. What's next, in rough order:
- Per-field crop records — extend the
field_detailblock (now shipping count + size stats) to add per-field dominant crop from CDL — labeled by vintage year, never implied as current ground truth. - Async/batch jobs — for collections larger than 25 or compute that needs more than 30 seconds. The sync cap and timeout are v1 constraints, not permanent ones.
- GeoParquet output — alternative to GeoJSON for callers working at scale.
No dates on any of these. The Sandbox is where things land when they're real.
If there's a workflow this doesn't cover, that's worth knowing.
Disclaimer and attribution boilerplate
Every response carries this disclaimer. If you're surfacing UFFDA-enriched data in your own product, pass it through or adapt it.
Disclaimer (paste-ready):
Informational only — not legal advice and not a warranty. License and provenance fields are UFFDA's best-effort plain-language reading of upstream metadata. Verify license terms against the upstream source before relying on them for compliance, contract, or legal claims.
Attribution:
Each layer's license.attribution field carries the suggested attribution string for that upstream source, ready to drop into a credits block. For the endpoint itself:
Data enrichment via UFFDA API (uffda.ag). Underlying datasets: USDA NASS CDL (public domain), USDA SSURGO (public domain), ISRIC SoilGrids 2.0 (CC-BY-4.0), US Drought Monitor (custom terms — verbatim NDMC/USDA/NOAA/NASA credit required; see license.attribution), ESA WorldCover 2021, NASA POWER, Hansen Global Forest Change v1.12 (CC-BY-4.0, UMD / Google / USGS / NASA), NASA LP DAAC LGRIP30 L3 V002 (CC0, Teluguntla et al. 2018, 2024), ESA WorldCereal 2021 (CC-BY-4.0, attribution required).
v1 — live in production. If you hit something unexpected, let us know.