UFFDA
◆ API · v1 · in flight

UFFDA Developer API

POST /v1/fields/enrich


What this gives you

You already have field polygons. We have the data layers. This endpoint connects them.

POST a GeoJSON Feature or FeatureCollection — your field boundaries, however you sourced them — and you get each polygon back enriched with crop history, soils, drought, land cover, and weather. Every value arrives with a provenance envelope: source, license, units, confidence, and vintage, right on the value. No guessing where a number came from.


A two-minute first call

Paste this and run it.

bash
curl -X POST https://uffda.ag/api/v1/fields/enrich \
  -H "Content-Type: application/json" \
  -d '{
    "type": "Feature",
    "geometry": {
      "type": "Polygon",
      "coordinates": [[
        [-93.620, 41.560], [-93.605, 41.560],
        [-93.605, 41.575], [-93.620, 41.575],
        [-93.620, 41.560]
      ]]
    }
  }'

The same call in Python:

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:

javascript
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 five run. Values: crop_history, soil, drought, land_cover, weather.
  • options.cdl_yearsWhich years of crop history to pull. Default: the trailing 8 years from the latest CDL vintage.
  • options.weather_windowDate range for weather data. Default: trailing 12 months. Max span: 5 years.
  • 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.


What comes back

A GeoJSON FeatureCollection. Your input geometries come back unchanged, with two additions on each feature's properties:

  • derivedCentroid, area in hectares and acres, bounding box.
  • enrichmentOne block per layer, each value wrapped in a provenance envelope.

The top-level response also carries:

  • uffdaMetadata: API version, request ID, layer set, warnings, and your current rate-limit position.
  • 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:

json
{
  "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.)

Why this shape exists: ag data has a unit-collision problem. Soil organic matter and soil organic carbon are not the same number — the conversion is SOM ÷ 1.724. Mix them up in a carbon model and the output is wrong in a way that looks plausible. 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 fields are informational only. Every 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

8 years of USDA NASS Cropland Data Layer (CDL). Annual crop classification at 30m resolution, CONUS only. Per-year value_label (e.g., "Corn", "Soybeans") plus a class-accuracy figure (~92% for US corn/soy from CDL metadata; lower for minor crops). Fields outside CONUS get a null value and a note in properties.errors.

soil

SSURGO as primary for CONUS fields (survey-grade, area-weighted over map units intersecting your polygon); SoilGrids 2.0 as companion (global model estimate, 16-point grid sample, native Q0.05/Q0.95 uncertainty band). Outside CONUS, SoilGrids is primary. Metrics: organic carbon, pH, clay, sand, silt, CEC, nitrogen, bulk density — all at multiple depths. The units block explicitly carries any SOM↔SOC conversion applied.

Latency note (CONUS): the first call for a field returns SSURGO in ~1–3 s with soilgrids absent — the SoilGrids companion is computed on demand and cached. A second call for the same field triggers that compute (~5–15 s) and returns the full companion; subsequent calls are instant from cache. Check stats.soilgrids directly to know whether the companion is present — do not rely on the fromCache flag for this (it reflects the SSURGO portion only). Outside CONUS, SoilGrids is computed on the first call and this two-step pattern does not apply.

companion_state — reading hydration status: the soil block carries a companion_state field on CONUS responses so you can tell apart "still hydrating" from "ready" without inspecting the companion object itself.

json
// CONUS, first call (cold) — SoilGrids not yet computed:
{ "companion": null, "companion_state": "pending" }

// CONUS, hydrated — companion is fully populated:
{ "companion": { /* SoilGrids values */ }, "companion_state": null }

// Outside CONUS — SoilGrids is primary; companion concept doesn't apply:
{ /* no companion_state key */ }

drought

US Drought Monitor, current week. The value_label reads like "D2 — Severe Drought". CONUS only.

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.

weather

NASA POWER daily at the polygon centroid, summarized over your requested window (default: trailing 12 months). Summary metrics: GDD total, precipitation total, average high and low temperature. The units block declares base and conversion for each.


Rate limits and fair use

Default: 60 requests / hour per IP, 200 / day per IP. Burst: 10 in any 60-second window. A FeatureCollection counts as one request regardless of feature count.

Rate-limit position is in the uffda.rate_limit block on every response, and in standard headers:

text
X-RateLimit-Limit:     60
X-RateLimit-Remaining: 57
X-RateLimit-Reset:     1748462400
Retry-After:           42   (only on 429)

If 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:

json
{
  "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 uffda envelope as a success response. Your error-handling code can rely on a single shape.

Common codes:

CodeHTTPWhat happened
feature_collection_too_large413Over 25 features. Split into batches.
geometry_too_complex413A single feature has >10,000 coordinates in its outer ring. Simplify upstream.
invalid_geometry422Missing, malformed, or not a Polygon/MultiPolygon.
unknown_layer422A value in layers[] isn't in the whitelist. details.unknown lists the offenders.
rate_limited429Check Retry-After for the wait in seconds.
compute_timeout504Hit the 30s wall clock. Try fewer layers or a smaller batch.

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.


What's next

v1 is one endpoint: enrich fields you bring. What's next, in rough order:

  • AOI discovery (POST /v1/aoi/fields) — send a drawn polygon, get back field boundaries inside it from UFFDA's layers. This is the “find me the fields here” path that v1 doesn't have.
  • 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. Hit reply on any email from us, or write to hello@uffda.ag.


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, ESA WorldCover 2021, NASA POWER.

v1 — in flight. This endpoint is shipping alongside this page. If you hit something unexpected, let us know.