Sensor / partner API

Versioned at /v1. OpenAPI 3.1 spec is the contract. Endpoints land progressively per BUILD-PROGRESS.md — see the scientific plan for the full schedule.

API versions

/v1v1.0.0stable/v1/openapi.json

Live at https://api.mud2dust.io/v1/version — consumed by the dev-site at build time.

Versioning

Breaking changes get a new path prefix (/v2, /v3). Non-breaking additions land in the current version. Old versions stay live for ≥6 months after a successor ships, with a Sunset response header advertising the cutoff.

OpenAPI spec

Live spec at https://api.mud2dust.io/v1/openapi.json. An interactive explorer is queued for P1.7 iter 4; for now you can paste that URL into Swagger UI / Stoplight Elements / Scalar to render it.

Object types

Seven first-class shapes share auth, trust scoring, and privacy controls:

ShapeCoversEndpoint(s)
Observationtime-series point readingsPOST /v1/observations
Profilemulti-depth time-series (drill-and-drop probes, lysimeters, profile sensors)POST /v1/profiles
Samplediscrete lab results (gravimetric VWC, texture, OM, …)POST /v1/samples
Eventsingle drone / aircraft / PhenoCam capturePOST /v1/uploads/initiatecomplete
Collectionmulti-flight drone survey, multi-pass aircraft campaignPOST /v1/collections
Annotationgeotagged agronomist note, citizen photoPOST /v1/annotations
Boundaryfield / management zone / EC-mapped zonePOST /v1/boundaries

Auth

User accounts are live: magic-link sign-in, optional email + password, 15-min HS256 JWT in an HttpOnly; Secure; SameSite=None cookie, 30-day rotated refresh token, ORCID linking for the researcher tier. Full reference at /auth. Partner-app OAuth 2.0 + PKCE with per-shape scopes (stations:write, observations:write, …) lands in P4.23. The legacy X-Contributor-Id header is still accepted on the station/observation endpoints as a fallback for partner-app integrations not yet on OAuth.

/v1/point — point or polygon query

The flagship read endpoint. Returns every primitive raster signal mud2dust has at a point — or averaged across a polygon — plus four opinionated derived blocks (irrigation, stickiness, traction, n_stress_proxy) and a coverage report. Three modes on one URL:

ModeParameter(s)Sampling
point?lat=&lon=1 titiler sample per layer
polygon?geom= URL-encoded GeoJSON5×5 grid in bbox + point-in-polygon filter
bbox?bbox=min_lon,min_lat,max_lon,max_latshorthand — converted to a 4-vertex Polygon internally

The polygon parameter is GeoJSON

?geom= is the canonical way to ask for an averaged-over-area answer. Apps that send field boundaries, watersheds, utility service territories, drone-flown areas, or any other AOI should use this. The value is a GeoJSON Polygon or MultiPolygon object — JSON-stringified, then URL-encoded. Coordinates are [longitude, latitude] per RFC 7946 (note: lon first). Inner rings (holes) are accepted but currently treated as exterior; full hole support lands when contributors start uploading polygons with them.

Example GeoJSON Polygon for a field near Mesa, WA:

{
  "type": "Polygon",
  "coordinates": [[
    [-119.260, 46.480],
    [-119.245, 46.480],
    [-119.245, 46.495],
    [-119.260, 46.495],
    [-119.260, 46.480]
  ]]
}

JSON-stringify, URL-encode, and append:

# Pseudo-shell — actual encoding done by your HTTP lib
GEOM='{"type":"Polygon","coordinates":[[[-119.26,46.48],[-119.245,46.48],[-119.245,46.495],[-119.26,46.495],[-119.26,46.48]]]}'
curl "https://api.mud2dust.io/v1/point?geom=$(printf %s "$GEOM" | jq -sRr @uri)&use=apple_orchard"

MultiPolygon works the same way; for very large AOIs (over ~8 KB after URL-encoding) a forthcoming POST /v1/point will accept the geometry in the body.

Sample aggregation rules (polygon mode)

  • Continuous fields (VWC, σ⁰, NDVI/NDWI/NDRE, LST, ET, clay%, sand%, SOC, pH, DEM, LIA, precip): mean of in-polygon points.
  • Categorical (CDL crop class): mode (most common rounded code).
  • Soil hydraulics, forecast, irrigation, traction: derived from the aggregated inputs above.

Response shape

Same JSON shape for point and polygon mode. Polygon mode adds a top-level polygon block with rings + bbox + area + sample count + source (so callers know whether the polygon came from ?geom= or ?bbox=). Full schema in the OpenAPI spec at https://api.mud2dust.io/v1/openapi.json under PointQueryResponse.

The top-level blocks:

  • signals — every primitive at the point/polygon (VWC, σ⁰, NDVI/NDWI/NDRE, LST, precip, ET, soil priors, CDL, etc.)
  • context — crop class, soil texture, hydraulics, ET₀ with source attribution
  • coverage — geographic-coverage report (CONUS-only sources unavailable, global fallbacks used)
  • now — current VWC + category + % of field capacity
  • forecast — day-by-day VWC trajectory + days-to-target
  • irrigation — when / how much / how long; or hold if saturated. /explain/irrigation/ ↗ for the math.
  • stickiness — soil-state band (firm / tacky / sticky / slick / saturated) + score + headline. /explain/traction/ ↗ for the math.
  • traction — per-surface advice for 9 surfaces (shoes, boots, bicycle, motorbike, car, truck, orchard tractor, 200 hp tractor, combine), each with a verdict + recommended tread depth (mm) + note.
  • n_stress_proxy — directional N-stress diagnosis (likely_n_stressed / water_stress_first / heat_stress_first / canopy_too_sparse / healthy / inconclusive) — NDRE-led with water + heat + sparse-canopy rule-outs. /explain/nitrogen/ ↗ for the math.
  • notes — human-readable caveats (masked pixels, fallbacks used, etc.)

The map popover at mud2dust.com renders the irrigation, N-stress, and stickiness blocks as colored cards click-through to the corresponding explainer.

For worked recipes — irrigator, hiking, off-road, harvest, concrete-pour, energy/DR — see the examples page.

What's live today

# Queries (P4.x)
GET /v1/point?lat=&lon=                                  point
GET /v1/point?geom=<urlencoded GeoJSON Polygon>          polygon (canonical)
GET /v1/point?bbox=min_lon,min_lat,max_lon,max_lat       polygon shorthand

# Auth (P3.6 — production)
POST /v1/auth/request-magic-link
POST /v1/auth/verify-magic-link
POST /v1/auth/sign-in-with-password
POST /v1/auth/refresh
POST /v1/auth/sign-out

# Self
GET   /v1/me
PATCH /v1/me                      { username?, display_name? }
POST  /v1/me/password             { current_password?, new_password }
POST  /v1/me/email/request-change { new_email }
POST  /v1/me/email/confirm-change { token }
GET   /v1/me/orcid/start          (start ORCID OAuth)
POST  /v1/me/orcid/link           (callback → researcher-tier flip)

# Stations + observations (P1.5; auth-gated via JWT cookie or
# the legacy X-Contributor-Id header for partner-app integrations
# not yet on OAuth)
POST /v1/stations
  body: { name, geom_internal: {type:"Point", coordinates:[lon,lat]},
          vendor, observed_properties: [...], depths_cm?: [...], logger?: {...} }
  → 201 { id: "stn_…", geom_public, geom_public_mode, ... }

POST /v1/observations
  body: [ { station_id, observed_property, value, unit, ts, depth_cm? }, ... ]
  → 202 { accepted, rejected, rejected_details: [{index, flags}] }

# Lab samples (P4.15a — auth-required)
POST /v1/samples
  body: { geom: {type:"Point", coordinates:[lon,lat]},
          sample_method: "lab_analysis"|"gravimetric"|"field_kit",
          collected_at, analyzed_at?, sample_depth_cm?, lab_name?,
          measurements: [{ analyte, value, unit }, ...] }
  → 201 { accepted, ids[], geom_public, geom_public_mode,
          unknown_analytes? }

GET /v1/me/samples?limit=100
  → 200 { items: [{ id, analyte, value, unit, collected_at, lab_name,
                    lon, lat, ... }, ...] }

  Known analytes: clay_pct, sand_pct, silt_pct, ph_h2o, ec_water,
  soc, bulk_density, cec, nitrate_n, ammonium_n, total_n, p_olsen,
  p_bray, k_extractable, gravimetric_vwc, foliar_n, foliar_p,
  foliar_k. Unknown analytes are accepted but flagged.

  Public-facing surfaces (other contributors' dashboards, public
  STAC) see a 5 km jittered geom_public. The internal exact geom
  drives the eventual soil-prior re-fit job (P4.15a iter 2).

# Lab-sample attachments (V.6 — auth-required, two-step upload)
POST /v1/samples/{sample_id}/attachments/initiate
  body: { filename, content_type, size_bytes }
  → 200 { s3_key, upload_url, upload_method: "PUT",
          upload_expires_in: 600,
          upload_required_headers: { "Content-Type": "..." },
          max_bytes: 26214400 }

PUT  <upload_url>            (browser sends file bytes direct to S3)
  headers: include upload_required_headers as-is
  body:    file bytes
  → 200 (S3 ETag in response)

POST /v1/samples/{sample_id}/attachments/complete
  body: { s3_key }
  → 200 { ok: true, sample_id, s3_key }

  Allowed content types: application/pdf, text/csv,
  application/vnd.openxmlformats-officedocument.spreadsheetml.sheet
  (PDF/CSV/XLSX). Cap 25 MB/file. Multiple attachments per sample
  permitted; addAttachmentKey is idempotent.

  Storage: s3://mud2dust-contributions-dev/samples/{contributor}/
           {sample_id}/{rand}-{filename}. Lifecycle: Standard 90d
           → IA, then 365d → Glacier IR.

  Web form for contributors: https://mud2dust.com/contribute/sample/

Trust model

Every contribution has a sensor_class (research / professional / consumer / diy) and an operator_class (researcher / agronomist_supported / farmer / hobbyist / unknown). The product of those (× installation quality) yields a training_weight. Contributions ≥ 0.5 are eligible to retrain the public calibration model; everyone else is a correction-taker with full feature parity but excluded from retrain. Users never see their tier as a number — only contribution health indicators.

Privacy / coordinate fuzzing

Every shape stores both geom_internal (exact, used for model training) and geom_public (jittered or aggregated). Default mode is 5km jitter; opt-in to 10km, county- aggregated, exact, or fully private (excluded from public surfaces). Jitter is deterministic per (contributor, external_id) so coords stay stable across requests.