Developer API Documentation

Reference for the Every Farm Developer API, covering authentication with API tokens, rate limits, scopes, and every endpoint for farms, blocks, block headers, datasets, folders, collectors, datapoints, and GeoJSON.

Documentation for the Every Farm Developer API. This API allows 3rd-party developers to access farm and dataset data programmatically using API tokens.


#Overview

The Developer API provides authenticated, rate-limited access to farm data, datasets, and GeoJSON files. Developers generate API tokens through the application UI, then use those tokens to make HTTP requests from their own servers or scripts.

Base URL

https://api.every.farm

#API Discovery

GET /v1

Returns a self-describing JSON manifest of the entire API: every endpoint with a one-line summary, its required scope and a docs link to its exact section on this page, plus the authentication scheme, conventions (signed-URL TTLs, rate limits, timestamp shape) and links to the OpenAPI description and the docs index. It requires no authentication and exposes no farm data.

This is the recommended first call for an AI agent: hand the agent a token and the base URL, and it can discover everything else from GET https://api.every.farm/v1.

The API is designed so an agent never loses the trail:

  • Every response carries a Link header pointing at the OpenAPI description (rel="service-desc"), this page (rel="service-doc") and the manifest (rel="describedby").
  • Every error is JSON with an error message, a documentation link to the section for the endpoint that failed, and the discovery URL. A 401 also says how to get a token and sends WWW-Authenticate: Bearer (on a 401, documentation points at API Tokens and endpointDocumentation at the endpoint's section); a 403 links the scopes and roles; a 429 links the rate limits.
  • A wrong path returns 404 with the most similar endpoints; a wrong method returns 405 with an Allow header and the methods that path accepts. One exception: a body that is not valid JSON is rejected with a plain 400 by the hosting layer before it reaches the API, so check the body if you see Bad Request with no JSON.

#OpenAPI

GET /v1/openapi.json

The whole API as an OpenAPI 3.1 document, no authentication required (also served at https://api.every.farm/openapi.json). It is generated from the same route manifest as GET /v1, so it cannot drift, and each operation is enriched from this page: a link to its section, the section's text, and the request and response examples shown here, with request fields marked required where the docs say so. Import it into Postman, an SDK generator, or an agent framework's tool loader.

#Crop Picker OpenAPI

GET /v1/crop-picker/openapi.json

An OpenAPI 3.1 document for the public Crop Picker API at https://crops.every.farm, no authentication required. It is generated from that API's documentation page, so an agent that starts at GET /v1 can load both APIs from one place. The manifest lists the same endpoints under relatedApis.cropPicker.

#Docs by endpoint

GET /v1/docs

This page, addressed by endpoint, for agents. No authentication. With no query it lists every documented endpoint with its section link; ?q=waypoints filters by text; ?endpoint=POST%20/v1/farms/:farm_id/missions returns one section as markdown with its request and response examples and field table (a concrete path such as /v1/farms/abc123/missions works too). ?api=crops lists the Crop Picker endpoints instead and ?api=all lists both; a ?q= search with no api searches both. The same index is published as a static file at https://every.farm/docs/developer-api/endpoints.json.

Response (200), one endpoint:

{
  "method": "POST",
  "path": "/v1/farms/:farm_id/missions",
  "title": "Create a Mission",
  "docs": "https://every.farm/docs/developer-api/#create-a-mission",
  "requestExamples": [ { "label": "Request body", "value": { "name": "Row scout, west rows 21-25" } } ],
  "responseExamples": [],
  "fields": { "kind": "field", "fields": [ { "name": "name", "required": true, "description": "Mission name" } ] },
  "markdown": "`POST /v1/farms/:farm_id/missions` …"
}

#Authentication

#API Tokens

All data-access endpoints require a Bearer token in the Authorization header:

Authorization: Bearer efv_abc12345_...

Tokens are generated from the API Tokens panel in the app (open your account menu → API Tokens). Each token:

  • Has a prefix efv_ for easy identification
  • Is shown once at creation — it cannot be retrieved again
  • Can be scoped to read, write, or both (a token created in the app with write always includes read)
  • Can optionally be restricted to specific farms
  • Can be set to expire after 30, 90, 180, or 365 days (or never)
  • Maximum of 10 active tokens per user (revoked and expired tokens do not count)

#Token Management Endpoints

These three endpoints use a Firebase ID token (not an API token) for authentication. They are used by the application UI to manage tokens.

Method Endpoint Description
POST /v1/tokens Generate a new API token
GET /v1/tokens List your tokens
DELETE /v1/tokens/:token_id Revoke a token

#Rate Limits

Each API token is subject to rate limiting:

Limit Default Notes
Per minute 60 requests Best-effort, per Cloud Function instance
Per day 10,000 requests Firestore-backed, authoritative

When rate-limited, the API returns a 429 Too Many Requests response:

{
  "error": "Rate limit exceeded. Try again shortly.",
  "retryAfterSeconds": 60
}

The daily limit has a different body and no retryAfterSeconds:

{
  "error": "Daily rate limit exceeded.",
  "limit": 10000,
  "resetsAt": "2026-11-11T00:00:00Z (next day UTC)"
}

You can check your current usage at any time via GET /v1/usage.


#Permissions & Scopes

#Token Scopes

Scope Access
read List and fetch farms, blocks, block headers and values, datasets (GeoJSON and rasters), folders, collectors and datapoints; satellite discovery, scene search and job polling; missions, the waypoint CSV and robot events
write Everything that changes data: blocks, block headers and values, datasets, folders, collectors and datapoints (including delete), recompile, satellite analyses, missions (create, claim, status, complete), robot events and acknowledgements, live tracks

#Farm Roles

The API respects the same role-based permissions as the application:

Role Read Write
Owner Yes Yes
Administrator Yes Yes
Editor Yes Yes
Read-only Yes No
Permission-only Yes No

If a token is restricted to specific farms, it can only access those farms regardless of the user's broader permissions.


#Endpoints

#List Farms

GET /v1/farms

Returns all farms the token owner has access to.

Response:

{
  "farms": [
    {
      "id": "farm_abc123",
      "name": "My Vineyard",
      "role": "Owner",
      "geolocation": { "latitude": 38.5, "longitude": -121.7 }
    }
  ]
}

#Get Farm Details

GET /v1/farms/:farm_id

Returns farm metadata including a signed URL for the blocks GeoJSON file (valid 15 minutes).

Response:

{
  "id": "farm_abc123",
  "name": "My Vineyard",
  "role": "Owner",
  "geolocation": { "latitude": 38.5, "longitude": -121.7 },
  "blockFields": [],
  "vizSettings": {},
  "blocksGeoJsonURL": "https://storage.googleapis.com/..."
}

#List Blocks

GET /v1/farms/:farm_id/blocks

Returns blocks (vineyard blocks, field boundaries, etc.) for a farm.

Query parameters:

Param Default Description
limit 100 Max blocks to return

Response:

{
  "blocks": [
    {
      "id": "block_xyz",
      "name": "Block A",
      "description": "",
      "area": 2.5,
      "footprint": { "type": "FeatureCollection", "features": [...] },
      "properties": {}
    }
  ]
}

#Create Blocks

POST /v1/farms/:farm_id/blocks

Creates one or more blocks (polygon boundaries with optional field values). Requires the write scope and Owner/Editor/Administrator access to the farm. After creation the farm's blocks are automatically recompiled so the new blocks appear on the map and become available to analyses within a few seconds.

Body — single block:

{
  "name": "Block A",
  "description": "optional",
  "geometry": { "type": "Polygon", "coordinates": [[[-122.1,38.5],[-122.0,38.5],[-122.0,38.6],[-122.1,38.6],[-122.1,38.5]]] },
  "fields": { "type": "Variety", "variety": "Cabernet" }
}

Body — multiple blocks: wrap an array under blocks (max 500 per request):

{ "blocks": [ { "name": "Block A", "geometry": { } }, { "name": "Block B", "geometry": { } } ] }
Field Required Description
name yes Block name
geometry yes Polygon or MultiPolygon — accepted as a raw geometry, a GeoJSON Feature, or a FeatureCollection. Geometry must be polygonal.
description no Free-text description
fields no Object of block-field values keyed by the field's machine name or label. Keys must match a field defined on the farm; unknown keys are rejected. Use these typed fields (e.g. a type Select with values Block/Variety) to drive the farm's block filters and tiering.

Response (201):

{
  "created": 2,
  "blockIds": ["block_abc", "block_def"],
  "farmId": "farm_xyz",
  "compileRequestId": "compile_123",
  "message": "2 block(s) created. The farm's blocks are recompiling..."
}

Validation is all-or-nothing: if any block in the request is invalid, nothing is created and the response is 400 with a validationErrors array identifying each failing block by index.

{
  "error": "One or more blocks failed validation. Nothing was created.",
  "validationErrors": [
    { "index": 0, "error": "geometry must be a Polygon/MultiPolygon, Feature, or FeatureCollection." }
  ]
}

#Block Headers

Typed, optionally time-aware attributes attached to blocks — e.g. Yield (bu/acre, by year), Leaf Ca (%, by year), Rootstock (Select), Year Planted (static). Use these for whole-block, non-spatial history (yield-by-year, leaf analyses, planting metadata) instead of duplicating geometry across GeoJSON datasets. Definitions are a per-farm schema registry; values are stored per block (and per period for temporal headers). Header types reuse the collector field-type enum (Text | Number | Select | Date and Time | Image).

#Create Block Header

POST /v1/farms/:farm_id/block-headers · scope write

Body:

{
  "label": "Yield",
  "type": "Number",
  "unit": "bu/acre",
  "category": "Production",
  "timeDimension": "year",
  "selectOptions": null,
  "range": { "min": 0, "max": 3000 },
  "description": "Estimated bushels per acre"
}
Field Required Description
label yes Unique per farm (case-insensitive)
type yes Text | Number | Select | Date and Time | Image
timeDimension no null (static, one value per block) | "year" | "season"
selectOptions if Select Non-empty array of allowed values
unit / category / range / description no Display + soft-validation. range violations warn, they don't reject.

Returns 201 with the header definition (includes its id).

#List Block Headers

GET /v1/farms/:farm_id/block-headers · scope read — returns { "blockHeaders": [ … ] } (trashed excluded).

#Update Block Header

PATCH /v1/block-headers/:header_id · scope write — send any subset of the definition fields. Renaming re-checks uniqueness.

#Delete Block Header

DELETE /v1/block-headers/:header_id · scope write — soft-trash. Values already stored on blocks are retained but stop being surfaced on reads.

#Upsert Values (one block)

PUT /v1/blocks/:block_id/values · scope write

{ "values": [
  { "header": "Yield", "period": "2025", "value": 1005.3 },
  { "header": "Rootstock", "value": "M.9" }
] }

header is a header id or label. period is required iff the header is temporal (YYYY for year, YYYY-Sxx for season) and must be omitted for static headers. Values are coerced to the header type. Returns per-row results + the block's updated values.

#Bulk Upsert Values (many blocks) — the spreadsheet path

POST /v1/farms/:farm_id/block-values:bulkUpsert · scope write

{
  "blockKey": "name",
  "createMissingHeaders": false,
  "values": [
    { "block": "H22", "header": "Yield", "period": "2025", "value": 1005.3 },
    { "block": "H22", "header": "Yield", "period": "2024", "value": 1172 },
    { "block": "H22", "header": "Rootstock", "value": "M.9" }
  ]
}

At most 5,000 rows per request. blockKey (name | id, default name) controls how each row's block resolves to a block on the farm (exact, trimmed, case-insensitive). createMissingHeaders: true auto-creates unknown headers (type inferred: Number if the value is numeric, else Text; year if a YYYY period is present). Every row gets a status so partial spreadsheets fail gracefully:

{
  "blocksUpdated": 2,
  "summary": { "applied": 3, "skipped": 1, "error": 1 },
  "results": [
    { "index": 0, "block": "H22", "header": "Yield", "period": "2025", "status": "applied" },
    { "index": 3, "block": "H99", "status": "skipped", "reason": "no block named \"H99\" on this farm." }
  ]
}

Compound labels (H43-53, H1,2,3) and crosswalk aliasing are a later phase — today each row resolves to a single block by exact name/id, and unmatched rows come back as skipped.

#Get Block

GET /v1/blocks/:block_id · scope read

One block with its geometry.

Response (200):

{
  "id": "block_abc123",
  "name": "H22",
  "description": "",
  "area": 12140.6,
  "footprint": { "type": "Feature", "geometry": { "type": "Polygon", "coordinates": [] }, "properties": {} },
  "properties": {},
  "farmId": "farm_abc123"
}

area is in square metres (or null). Add ?include=headerValues to attach the block's header values, as described below.

#Read values on blocks

Header values are label-keyed and attach to block reads on request:

  • GET /v1/blocks/:block_id/values — all values for one block.
  • GET /v1/blocks/:block_id?include=headerValues — a single block with values.
  • GET /v1/farms/:farm_id/blocks?include=headerValues — all blocks with values.

Add ?period=2025 to collapse temporal headers to that period (static headers are omitted when filtering by period).

"headerValues": {
  "Yield":     { "2025": 1005.3, "2024": 1172, "2023": 1878 },
  "Leaf Ca":   { "2024": 1.31 },
  "Rootstock": "M.9",
  "Year Planted": 2014
}

#List Datasets

GET /v1/farms/:farm_id/datasets

Returns datasets for a farm. Trashed datasets are excluded.

Query parameters:

Param Default Description
limit 50 Max datasets to return
folder (all) Filter by folder ID
order updatedOn Sort field
direction desc Sort direction (asc or desc)

Response:

{
  "datasets": [
    {
      "id": "dataset_123",
      "name": "Yield 2025",
      "description": "",
      "dynamic": false,
      "collectorId": null,
      "headers": ["yield", "brix", "weight"],
      "folderId": "root",
      "createdOn": "2025-10-01T...",
      "updatedOn": "2025-10-15T...",
      "hasGeoJson": true,
      "hasRaster": false
    }
  ],
  "count": 1
}

collectorId is non-null on dynamic datasets — the link to a data collector you can push datapoints into. See Data Collectors & Datapoints below.


#List Folders

GET /v1/farms/:farm_id/folders

Query parameters:

Param Default Description
folder root Parent folder ID — lists one level of folders under it
all false true returns every folder for the farm (flat), so you can map the whole tree in one request. Trash is excluded.

Response:

{
  "folders": [
    { "id": "folder_abc", "name": "2025 Season", "description": "", "folderId": "root" }
  ]
}

Each folder's folderId is its parent (root, trash, or another folder id) — folders can be nested.


#Create Folder

POST /v1/farms/:farm_id/folders

Creates a folder. Requires the write scope and Owner/Editor/Administrator access.

Body:

{
  "name": "2026 Season",
  "description": "optional",
  "parentFolderId": "folder_abc"
}
Field Required Description
name yes Folder name
description no Free-text description
parentFolderId no Parent folder id (default root). Must be an existing folder on the same farm.

Response (201):

{ "id": "folder_def", "name": "2026 Season", "folderId": "folder_abc", "farmId": "farm_xyz" }

To place a new dataset directly into a folder, pass that folder's id as folderId when creating the dataset (POST /v1/farms/:farm_id/datasets), or move an existing one with PATCH /v1/datasets/:dataset_id.


#Update / Move Folder

PATCH /v1/folders/:folder_id · scope write

Rename or re-describe a folder, and/or move it. Send any subset of the fields below.

Field Description
name New name
description New description
folderId Move the folder: a folder id, root, or trash (all on the same farm). A folder cannot be moved into itself or one of its own subfolders.

Example — move a folder into the trash:

{ "folderId": "trash" }

Response (200):

{ "id": "folder_def", "name": "2026 Season", "folderId": "root", "message": "Folder updated." }

#Delete Folder

DELETE /v1/folders/:folder_id · scope write

Hard-deletes a folder, but only when it is empty (no datasets and no subfolders). A non-empty folder returns 409; empty it first (move/trash its contents) or trash the folder itself with PATCH { "folderId": "trash" }. Cascade deletion (which also deletes dataset files) is intentionally not available over the API — use the web app for that.

Response (200):

{ "id": "folder_def", "message": "Folder deleted." }

#Get Dataset Details

GET /v1/datasets/:dataset_id

Returns dataset metadata. When the dataset has GeoJSON or a raster (or both), short-lived signed URLs are included inline so the caller can fetch the actual data without a second round-trip.

Response:

{
  "id": "dataset_123",
  "name": "NAIP — 2024-09-12",
  "description": "",
  "dynamic": false,
  "collectorId": null,
  "headers": [],
  "farmId": "farm_abc123",
  "folderId": "root",
  "createdOn": "2025-10-01T...",
  "updatedOn": "2025-10-15T...",
  "vizSettings": {},
  "hasGeoJson": false,
  "hasRaster": true,
  "rasterUrl":         "https://storage.googleapis.com/.../data.tif?Signature=...",
  "rasterPreviewUrl":  "https://storage.googleapis.com/.../preview.png?Signature=...",
  "tileUrlTemplate":   "https://titiler-.../cog/tiles/WebMercatorQuad/{z}/{x}/{y}.png?url=<signed>",
  "rasterUrlExpiresAt":"2026-05-09T16:00:00Z",
  "bbox": [-78.47, 42.66, -78.46, 42.67]
}

URL TTLs:

  • geoJsonURL — 15 min (small file, agents typically fetch immediately)
  • rasterUrl / rasterPreviewUrl — 60 min (COG bytes are larger; allow time for slow downloads)
  • tileUrlTemplate — valid until rasterUrlExpiresAt; re-fetch the dataset to refresh

If you need a download rather than a URL, use the convenience endpoint below — it 302-redirects to a fresh signed URL, ideal for curl -L -o.


#Download Raster (302 redirect)

GET /v1/datasets/:dataset_id/raster

Scope: read

Issues a 302 Found redirect to a freshly-signed GeoTIFF download URL (60 min TTL). Convenience endpoint for curl -L, wget, rasterio.open() with a redirecting client, or any tool that downloads through Location: headers without needing JSON.

curl -L -H "Authorization: Bearer $TOKEN" \
  https://api.every.farm/v1/datasets/$DATASET_ID/raster \
  -o raster.tif

Returns 404 when the dataset isn't backed by a tileable raster.


#Download Raster Preview

GET /v1/datasets/:dataset_id/raster/preview

Scope: read

302 redirect to the small PNG preview (when one exists). Useful for agents that want a fast thumbnail without pulling the full COG. Returns 404 if the dataset has no preview image.


#Get Dataset GeoJSON

GET /v1/datasets/:dataset_id/geojson

Returns the full GeoJSON data inline in the response body.

Response:

{
  "datasetId": "dataset_123",
  "datasetName": "Yield 2025",
  "featureCount": 1250,
  "geojson": {
    "type": "FeatureCollection",
    "features": [
      {
        "type": "Feature",
        "properties": { "yield": 4.2, "brix": 24.1 },
        "geometry": { "type": "Point", "coordinates": [-121.7, 38.5] }
      }
    ]
  }
}

#Push a Dataset (Create)

POST /v1/farms/:farm_id/datasets

Scope required: write

Creates a new dataset in a farm by uploading GeoJSON data. The entire request is sent as a JSON body via Content-Type: application/json. Maximum body size is 10 MB.

Request body:

{
  "name": "Yield 2026",
  "description": "Optional description",
  "folderId": "root",
  "vizSettings": {},
  "geojson": {
    "type": "FeatureCollection",
    "features": [
      {
        "type": "Feature",
        "properties": { "yield": 4.2, "brix": 24.1 },
        "geometry": { "type": "Point", "coordinates": [-121.7, 38.5] }
      }
    ]
  }
}

Validation rules:

  • name — Required. String.
  • geojson — Required. Must be a valid GeoJSON FeatureCollection with type and features properties.
  • geojson.features — Must be a non-empty array.
  • description — Optional. String.
  • folderId — Optional. Defaults to "root".
  • vizSettings — Optional. Object for visualization configuration.

Headers are the union of the properties keys across all features.

Response (201):

{
  "id": "new_dataset_id",
  "name": "Yield 2026",
  "farmId": "farm_abc123",
  "headers": ["yield", "brix"],
  "featureCount": 1,
  "folderId": "root",
  "message": "Dataset created successfully."
}

#Update Dataset GeoJSON

PUT /v1/datasets/:dataset_id/geojson

Scope required: write

Replaces the GeoJSON data of an existing dataset. Same body format and validation as the push endpoint, but only the geojson field is required.

Request body:

{
  "geojson": {
    "type": "FeatureCollection",
    "features": [ ... ]
  }
}

Response (200):

{
  "id": "dataset_123",
  "name": "Yield 2025",
  "headers": ["yield", "brix", "weight"],
  "featureCount": 1300,
  "message": "Dataset updated successfully."
}

#Move a Dataset Between Folders

PATCH /v1/datasets/:dataset_id

Scope required: write

Moves a dataset into a different folder. Mirrors the app's Move action.

Request body:

{ "folderId": "folder_abc123" }
  • folderId — Required. A folder id belonging to the dataset's farm, or the sentinels "root" (top level) or "trash".
  • Moving to "root" also restores a trashed dataset.
  • Moving to "trash" is equivalent to the Trash endpoint below.
  • For a dynamic dataset, the linked collector's trashed flag is kept in sync (set when moving to trash, cleared otherwise).

Validation: the target folder must exist and belong to the same farm (404 / 400 otherwise).

Response (200):

{
  "id": "dataset_123",
  "folderId": "folder_abc123",
  "message": "Dataset moved to folder folder_abc123."
}

#Trash a Dataset

DELETE /v1/datasets/:dataset_id

Scope required: write

Soft-deletes a dataset by moving it into the trash folder (mirrors the app's Trash action). For a dynamic dataset, the linked collector is also flagged trashed. Recoverable — restore from the UI under Datasets → Trash, or move it back with PATCH /v1/datasets/:dataset_id and { "folderId": "root" }.

There is intentionally no hard-delete over the API. Use the app's "Destroy Permanently" action from the Trash folder when you really mean it.

Response (200):

{
  "id": "dataset_123",
  "trashed": true,
  "collectorId": null,
  "message": "Dataset trashed. Restore from the UI under Datasets → Trash..."
}

If the dataset is already trashed, returns 200 with alreadyTrashed: true.


#Data Collectors & Datapoints

A data collector backs a dynamic dataset. Rather than uploading one static GeoJSON, a dynamic dataset is built from datapoints accumulated in its collector and then compiled into a fresh GeoJSON snapshot on demand. Sensors, mobile collectors, and AI agents are all natural callers for these endpoints.

Discovery flow for an agent:

  1. GET /v1/farms/:farm_id/datasets — dynamic datasets carry a collectorId field.
  2. GET /v1/collectors/:collector_id — read the field schema.
  3. POST /v1/collectors/:collector_id/datapoints — push one or many datapoints.
  4. POST /v1/collectors/:collector_id/recompile — refresh the linked dataset.
  5. GET /v1/datasets/:dataset_id/geojson — fetch the freshly-compiled output.

Autonomous-agent flow (create + configure + populate from scratch):

  1. POST /v1/farms/:farm_id/collectors with { name, fields } — creates collector + linked dynamic dataset in one call.
  2. (Optional) PATCH /v1/collectors/:collector_id to evolve the schema later.
  3. POST /v1/collectors/:collector_id/datapoints to push observations.
  4. POST /v1/collectors/:collector_id/recompile after each batch.
  5. (Optional) DELETE /v1/collectors/:collector_id to soft-trash.

#List Collectors

GET /v1/farms/:farm_id/collectors

Scope: read

Lists the farm's collectors with their public field schemas. Trashed collectors are excluded.

Response:

{
  "count": 2,
  "collectors": [
    {
      "id": "col_abc123",
      "name": "Soil samples 2026",
      "description": "",
      "farmId": "farm_xyz",
      "datasetId": "ds_qrs456",
      "fields": [
        { "label": "Brix", "machine_name": "brix", "type": "Number", "required": false, "options": [] },
        { "label": "Variety", "machine_name": "variety", "type": "Select", "required": true, "options": ["Cab","Merlot"] }
      ],
      "reCompile": false,
      "createdOn": "2026-04-01T...",
      "updatedOn": "2026-05-08T..."
    }
  ]
}

#Create Collector

POST /v1/farms/:farm_id/collectors

Scope: write

Creates a collector AND the linked dynamic dataset in one atomic operation. Returns both ids so the caller can immediately push datapoints.

Request body:

{
  "name": "Soil samples 2026",
  "description": "Spring soil pH + moisture sweep across the south block",
  "folderId": "root",
  "geometryType": "Point",
  "fields": [
    { "label": "pH", "type": "Number", "required": true, "min": 0, "max": 14, "step": 0.1 },
    { "label": "Moisture", "type": "Number", "required": false, "suffix": "%" },
    { "label": "Variety", "type": "Select", "options": ["Cab", "Merlot", "Riesling"] },
    { "label": "Notes", "type": "Text" }
  ]
}

Field validation rules:

  • name required; fields must be non-empty (1–50 fields).
  • type must be one of: Text, Number, Select, Date and Time, Image.
  • Select fields must include a non-empty options array of non-empty strings.
  • machine_name is auto-generated from label (snake_case, deduped with _N suffix on collision). Caller can pass an explicit machine_name; reserved name dataPointId is rewritten.
  • geometryType defaults to Point. Other valid values: LineString, Polygon.

Validation failure (400):

{
  "error": "One or more fields failed validation. Nothing was created.",
  "validationErrors": [
    { "index": 2, "errors": ["Select fields must include a non-empty options array"] }
  ]
}

Response (201):

{
  "collectorId": "col_new123",
  "datasetId": "ds_new456",
  "farmId": "farm_xyz",
  "name": "Soil samples 2026",
  "fields": [
    { "label": "pH", "machine_name": "p_h", "type": "Number", "required": true, "min": 0, "max": 14, "step": 0.1, ... }
  ],
  "message": "Collector + linked dynamic dataset created. Push datapoints via POST /v1/collectors/col_new123/datapoints, then call /recompile."
}

#Update Collector

PATCH /v1/collectors/:collector_id

Scope: write

Update any subset of name, description, geometryType, fields. Dataset metadata (name, description, headers) is kept in sync in the same batch.

Field-update semantics:

  • Pass an existing field's machine_name to preserve its identity — datapoints keyed by that machine_name keep their values.
  • Omit machine_name to have a new field generated.
  • Omit a field from the array to remove it from the schema. Existing datapoint values for that key are not deleted (they just become unreferenced columns).
  • To rename the display label only, keep the machine_name and change the label.

Request body (every field optional):

{
  "name": "Soil samples 2026 (revised)",
  "fields": [
    { "label": "pH",       "machine_name": "p_h",      "type": "Number", "required": true },
    { "label": "Moisture", "machine_name": "moisture", "type": "Number" },
    { "label": "Lab notes",                                "type": "Text" }
  ]
}

A field-schema change automatically sets reCompile: true on the collector. The caller should follow up with POST /v1/collectors/:collector_id/recompile to refresh the dataset's GeoJSON.

Response (200):

{
  "collectorId": "col_new123",
  "datasetId": "ds_new456",
  "updated": ["name", "fields", "reCompile"],
  "message": "Collector updated. Schema changes mark the dataset for recompile — call POST /v1/collectors/col_new123/recompile to refresh the GeoJSON."
}

#Delete (Trash) Collector

DELETE /v1/collectors/:collector_id

Scope: write

Soft-deletes the collector and its dataset (mirrors the app's Trash action). Recoverable via the UI under Datasets → Trash.

Response (200):

{
  "collectorId": "col_new123",
  "datasetId": "ds_new456",
  "trashed": true,
  "message": "Collector and linked dataset trashed. Restore from the UI under Datasets → Trash."
}

There is intentionally no hard-delete endpoint over the API. Use the app's "Destroy Permanently" action from the Trash folder when you really mean it.


#Get Collector

GET /v1/collectors/:collector_id

Scope: read

Returns the collector's full schema plus dataPointCount. Use this before pushing datapoints to know what keys + types your payload must include.

Response:

{
  "id": "col_abc123",
  "name": "Soil samples 2026",
  "description": "",
  "farmId": "farm_xyz",
  "datasetId": "ds_qrs456",
  "fields": [
    { "label": "Brix", "machine_name": "brix", "type": "Number",
      "required": false, "options": [],
      "min": 0, "max": 30, "step": 0.1, "suffix": "°Bx" },
    { "label": "Notes", "machine_name": "notes", "type": "Text", "required": false, "options": [] }
  ],
  "reCompile": false,
  "dataPointCount": 142,
  "createdOn": "2026-04-01T...",
  "updatedOn": "2026-05-08T..."
}

Field type values: Number, Text, Select, Date and Time, Image. Select fields publish their options array; others may publish min/max/step/suffix if defined.


#List Datapoints

GET /v1/collectors/:collector_id/datapoints

Scope: read

Paginated list of datapoints in a collector, newest first.

Query params:

Param Default Description
limit 50 Max datapoints per page (max 200)
start_after — Datapoint id to resume from (use nextCursor from a previous response)
resolve_images true When true, every Image-typed field includes signed download URLs.

Response:

{
  "collectorId": "col_abc",
  "count": 50,
  "nextCursor": "dp_zzz789",
  "nextPageHint": "Pass ?start_after=dp_zzz789 to fetch the next page.",
  "datapoints": [
    {
      "id": "dp_aaa111",
      "lat": 42.66,
      "lng": -77.46,
      "accuracy": 0.5,
      "createdOn": { ... },
      "updatedOn": { ... },
      "createdBy": "user_uid",
      "source": "api",
      "fields": {
        "brix": 24.1,
        "variety": "Cab",
        "sample_photo": [{
          "path": "datapointImages/...",
          "large": "datapointImages/large/...",
          "caption": "south block, row 3",
          "url":      "https://storage.googleapis.com/...&Signature=...",
          "largeUrl": "https://storage.googleapis.com/...&Signature=..."
        }]
      }
    }
  ]
}

Image URLs are signed for 15 minutes — fetch them promptly. Setting resolve_images=false returns just the raw {path, large, caption} shape (use this when you only need metadata and want to skip the per-image sign cost).


#Get Datapoint

GET /v1/collectors/:collector_id/datapoints/:datapoint_id

Scope: read

Returns a single datapoint with the same shape as the list above. Same resolve_images query knob applies. Useful when you've seen a dataPointId in a compiled GeoJSON feature and want the original record (including any images).


#Edit Datapoint

PATCH /v1/collectors/:collector_id/datapoints/:datapoint_id

Scope: write

Partial update of a single datapoint. Supply any of lat, lng, accuracy, or any collector field by machine_name; omitted keys are left untouched. Values are coerced and validated the same way the push endpoint does. A required field can be changed but not blanked, and unknown keys are rejected (typo guard).

Geolocation note: lat and lng together define the point. If you send only one, the other is taken from the existing datapoint — so you can nudge a single coordinate without re-sending both.

After editing, call /recompile to refresh the linked dataset's GeoJSON.

Request body (every field optional):

{
  "lat": 42.6620,
  "brix": 23.9,
  "variety": "Merlot"
}

Response (200): the updated datapoint, same shape as Get Datapoint (image fields resolved unless ?resolve_images=false):

{
  "collectorId": "col_abc123",
  "datapoint": {
    "id": "dp_aaa111",
    "lat": 42.662,
    "lng": -77.4651,
    "accuracy": 0.5,
    "createdOn": { ... },
    "updatedOn": { ... },
    "source": "api",
    "fields": { "brix": 23.9, "variety": "Merlot" }
  },
  "message": "Datapoint updated. Call POST /v1/collectors/col_abc123/recompile to refresh the linked dataset's GeoJSON."
}

Returns 404 if the datapoint doesn't exist, or 400 with validationErrors if a value is invalid or a required field would be cleared.


#Delete Datapoint

DELETE /v1/collectors/:collector_id/datapoints/:datapoint_id

Scope: write

Permanently removes a single datapoint from the collector. After deleting, call /recompile to refresh the linked dataset's GeoJSON.

Response (200):

{
  "collectorId": "col_abc123",
  "deleted": "dp_aaa111",
  "message": "Datapoint deleted. Call POST /v1/collectors/col_abc123/recompile to refresh the linked dataset's GeoJSON."
}

Returns 404 if the datapoint doesn't exist.


#Push Datapoints

POST /v1/collectors/:collector_id/datapoints

Scope: write

Create one or many datapoints in a single call. Capped at 500 datapoints per request (Firestore batch limit). Chunk larger imports across multiple calls.

Request body:

{
  "datapoints": [
    {
      "lat": 42.6618,
      "lng": -77.4651,
      "accuracy": 0.5,
      "brix": 24.1,
      "variety": "Cab"
    },
    {
      "lat": 42.6620,
      "lng": -77.4648,
      "brix": 23.8,
      "variety": "Merlot"
    }
  ]
}

Single-datapoint convenience: if the body has top-level lat/lng and no datapoints array, it's treated as a single datapoint:

{ "lat": 42.66, "lng": -77.46, "brix": 24.1, "variety": "Cab" }

Per-datapoint fields:

Field Required Description
lat Yes Latitude in [-90, 90]
lng Yes Longitude in [-180, 180]
accuracy No Positive number; defaults to 1
<machine_name> Required if the collector field is required Value for any header on the collector. Unknown keys are rejected (typo guard).

Validation rules:

  • Every datapoint must validate; if any fails, none are written (atomic batch).
  • Unknown keys (not present in the collector's fields) cause a 400 with validationErrors describing which key and which datapoint index failed.
  • Number fields accept numeric strings (lenient coercion).
  • Recompile is not automatic. Push datapoints, then call /recompile when done batching.

Response (201):

{
  "collectorId": "col_abc123",
  "created": 2,
  "datapointIds": ["dp_aaa111", "dp_bbb222"],
  "message": "2 datapoint(s) created. Call POST /v1/collectors/col_abc123/recompile to refresh the linked dataset's GeoJSON."
}

Validation failure (400):

{
  "error": "One or more datapoints failed validation. No datapoints were written.",
  "validationErrors": [
    { "index": 0, "errors": ["unknown field 'sugar' — collector has no header with that machine_name"] },
    { "index": 1, "errors": ["required field 'variety' is missing or empty"] }
  ]
}

#Recompile Dataset

POST /v1/collectors/:collector_id/recompile

Scope: write

Triggers a recompile of the collector's linked dataset — every datapoint is read and a fresh GeoJSON is written to Cloud Storage. (The dataset's headers come from the collector's field labels and change when you create or update the collector, not on recompile.) Returns immediately with a compile-request id; the work runs asynchronously in a Cloud Function (typically a few seconds).

Response (202):

{
  "collectorId": "col_abc123",
  "datasetId": "ds_qrs456",
  "compileRequestId": "cr_def789",
  "status": "queued",
  "pollPath": "compileRequests/cr_def789",
  "message": "Compile queued. The dataset GeoJSON will be regenerated shortly; fetch /v1/datasets/:dataset_id/geojson after a few seconds, or watch the request doc: complete:true means the job ended, failed:true means it did not succeed (see error)."
}

The simplest "is it done?" pattern is to wait a few seconds and refetch GET /v1/datasets/:dataset_id/geojson. Power users with Firestore access can watch compileRequests/{compileRequestId}: complete: true means the job has ended. Check failed as well: if it is true, error holds the reason and the dataset still points at its previous GeoJSON.


#End-to-end agent workflow (curl)

A. Push to an existing collector

# 1. Find the dynamic dataset's collectorId
curl https://api.every.farm/v1/farms/$FARM/datasets \
  -H "Authorization: Bearer $TOKEN" | jq '.datasets[] | select(.dynamic) | {id,name,collectorId}'
# 2. Read the collector schema
curl https://api.every.farm/v1/collectors/$COL -H "Authorization: Bearer $TOKEN"
# 3. Push datapoints
curl -X POST https://api.every.farm/v1/collectors/$COL/datapoints \
  -H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
  -d '{"datapoints":[{"lat":42.66,"lng":-77.46,"brix":24.1,"variety":"Cab"}]}'
# 4. Recompile
curl -X POST https://api.every.farm/v1/collectors/$COL/recompile -H "Authorization: Bearer $TOKEN"
# 5. Fetch the updated GeoJSON (wait a moment first)
sleep 3
curl https://api.every.farm/v1/datasets/$DATASET/geojson -H "Authorization: Bearer $TOKEN"

B. Autonomous agent — create from scratch and start collecting

# 1. Create the collector + linked dynamic dataset
COL=$(curl -sX POST https://api.every.farm/v1/farms/$FARM/collectors \
  -H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
  -d '{
    "name": "Soil samples 2026",
    "fields": [
      {"label":"pH","type":"Number","required":true},
      {"label":"Variety","type":"Select","options":["Cab","Merlot"]}
    ]
  }' | jq -r .collectorId)
# 2. (Optional) Evolve the schema later
curl -X PATCH https://api.every.farm/v1/collectors/$COL \
  -H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
  -d '{"fields":[
    {"label":"pH","machine_name":"p_h","type":"Number","required":true},
    {"label":"Variety","machine_name":"variety","type":"Select","options":["Cab","Merlot"]},
    {"label":"Moisture","type":"Number","suffix":"%"}
  ]}'
# 3. Push observations
curl -X POST https://api.every.farm/v1/collectors/$COL/datapoints \
  -H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
  -d '{"datapoints":[
    {"lat":42.66,"lng":-77.46,"p_h":6.4,"variety":"Cab","moisture":18},
    {"lat":42.67,"lng":-77.45,"p_h":6.7,"variety":"Merlot","moisture":22}
  ]}'
# 4. Recompile
curl -X POST https://api.every.farm/v1/collectors/$COL/recompile -H "Authorization: Bearer $TOKEN"

#Check Usage

GET /v1/usage

Returns current rate limit usage for the token.

Response:

{
  "today": {
    "requestCount": 142,
    "limit": 10000
  },
  "rateLimitPerMinute": 60,
  "lastUsedAt": "2026-04-09T..."
}

#Satellite Imagery & Analysis

The /v1/satellite/* endpoints expose Every.Farm's remote-sensing pipeline to API consumers — including AI agent systems. With these endpoints you can:

  1. Discover what imagery sources, indices, and analysis recipes are available.
  2. Search open satellite archives (Sentinel-2, Sentinel-1, Landsat, HLS, etc.) for scenes that overlap a block.
  3. Run an analysis (e.g. compute NDVI from a single scene, or aggregate a season of NDVI into a temporal mean) — vector-first by default.
  4. Poll for completion and retrieve the resulting dataset, which lands in the destination farm under datasets/{id} like any other Every.Farm dataset.

All satellite endpoints respect the same authentication, scopes, rate limits, and farm-role permissions as the rest of the v1 API. read scope is sufficient for discovery and scene search; write scope is required to submit analyses (since the result is written into a farm).


#List Imagery Collections

GET /v1/satellite/collections

Scope: read

Lists open remote-sensing collections the platform can ingest. The id returned here is what you pass as collection everywhere else.

Response:

{
  "collections": [
    {
      "id": "sentinel-2-l2a",
      "label": "Sentinel-2 L2A",
      "provider": "ESA Copernicus",
      "kind": "optical",
      "nativeResolutionM": 10,
      "cloudCover": true,
      "description": "Multispectral optical surface reflectance...",
      "stacEndpoint": "earthSearch"
    },
    {
      "id": "sentinel-1-rtc",
      "label": "Sentinel-1 RTC (SAR)",
      "provider": "ESA Copernicus / Microsoft Planetary Computer",
      "kind": "sar",
      "nativeResolutionM": 10,
      "cloudCover": false,
      "stacEndpoint": "planetaryComputer"
    }
  ]
}

Currently supported collections (12) — every collection the platform's frontend ingestion drawer offers is reachable from the API:

Collection id Kind Native res Cloud cover Analysis paths
sentinel-2-l2a optical 10 m yes single_scene, temporal_mean
sentinel-1-rtc sar 10 m n/a single_scene, temporal_mean, raster_ingest
landsat-c2-l2 optical 30 m yes single_scene, raster_ingest
hls2-l30 optical 30 m yes single_scene, raster_ingest
hls2-s30 optical 30 m yes single_scene, raster_ingest
naip optical (RGB+NIR aerial) 0.6 m n/a raster_ingest
modis-13Q1-061 optical (precomputed NDVI/EVI) 250 m yes raster_ingest
3dep-seamless terrain (DEM) 10 m n/a raster_ingest
gnatsgo-rasters soil 10 m n/a raster_ingest
daymet-daily-na climate (daily) 1 km n/a raster_ingest
io-lulc-9-class land cover (categorical) 10 m n/a raster_ingest
esa-worldcover land cover (categorical) 10 m n/a raster_ingest

Every entry's analysisPaths is also surfaced on each collection object in the API response, so an agent can introspect programmatically which recipes apply rather than relying on this table.


#List Analysis Recipes

GET /v1/satellite/recipes

Scope: read

Lists named analyses you can submit via POST /v1/satellite/analyses. Each recipe describes its expected inputs shape.

Response:

{
  "recipes": [
    {
      "id": "single_scene",
      "label": "Single-scene index extraction",
      "description": "Compute one or more indices from a single satellite scene...",
      "inputs": {
        "sceneId":    { "type": "string",   "required": true },
        "collection": { "type": "string",   "required": true },
        "indices":    { "type": "string[]", "required": true },
        "includeRasterPreview":  { "type": "boolean", "default": true },
        "includeRasterDownload": { "type": "boolean", "default": false }
      },
      "output": "One vector dataset per requested index, written to the destination farm."
    },
    {
      "id": "temporal_mean",
      "label": "Temporal aggregation",
      "description": "Aggregate a time series of scenes into a single vector grid...",
      "inputs": {
        "source":        { "type": "string", "enum": ["s2-ndvi", "s1-vh"], "required": true },
        "dateRange":     { "type": "{start,end}", "required": true },
        "aggregation":   { "type": "string", "enum": ["mean","median","min","max","stddev","count"], "required": true },
        "cloudCoverMax": { "type": "number", "default": 30 }
      }
    },
    {
      "id": "raster_ingest",
      "label": "Raster ingest (native product)",
      "description": "Ingest a single STAC scene as a raster dataset (GeoTIFF + PNG preview)...",
      "inputs": {
        "sceneId":    { "type": "string", "required": true },
        "collection": { "type": "string", "required": true },
        "kind":       { "type": "string", "enum": ["native","trueColor"], "default": "native" }
      },
      "output": "One raster dataset (GeoTIFF + PNG preview). Eligible as a custom base layer."
    }
  ]
}

Recipe / collection compatibility: check each collection's analysisPaths for the authoritative list. As a quick reference:

  • single_scene — optical + SAR (S2, S1, Landsat, HLS) that have band data for indices. temporal_mean — Sentinel-2 and Sentinel-1 only.
  • raster_ingest — every collection except S2 on Earth Search (NAIP, MODIS, DEM, soil, climate, land cover, plus the MPC-hosted optical + SAR for true-color composites). Produces a raster dataset that is also eligible as the farm's custom base layer.

Example — ingest a NAIP aerial scene as a raster dataset:

curl -X POST https://api.every.farm/v1/satellite/analyses \
  -H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
  -d '{
    "recipe": "raster_ingest",
    "farm_id": "'$FARM'",
    "block_id": "'$BLOCK'",
    "inputs": {
      "sceneId": "'$SCENE_ID'",
      "collection": "naip"
    }
  }'

Returns a job_id. Poll GET /v1/satellite/analyses/:job_id until status: complete, then the resulting result.datasetId references a raster dataset visible alongside any other dataset on the farm. Because file.raster: true is set on the dataset, it's also selectable in the UI under Farm Settings → Farm Base Map as a custom basemap.


#List Vector-Supported Indices

GET /v1/satellite/indices

Scope: read

Lists every analytical index that can be ingested as a vector grid, along with which collections support it. Use this when an agent needs to know which indices it can pass to the single_scene recipe for a given collection.

Response (excerpt):

{
  "indices": [
    {
      "id": "ndvi",
      "label": "NDVI",
      "description": "Normalized Difference Vegetation Index",
      "appliesTo": ["sentinel-2-l2a","sentinel-2-l1c","landsat-c2-l2","hls2-l30","hls2-s30"],
      "expectedRange": [-1, 1],
      "suggestedColormap": "rdylgn"
    },
    {
      "id": "ndre",
      "appliesTo": ["sentinel-2-l2a","sentinel-2-l1c","hls2-s30"],
      "expectedRange": [-1, 1]
    }
  ]
}

Currently supported index ids: ndvi, ndre, savi, evi, gndvi, ndwi, msi, ndsi (optical) and vv_db, vh_db, rvi, vv_vh_ratio (SAR).


#Search Scenes

GET /v1/satellite/scenes

Scope: read

Search open STAC catalogs (Element 84 Earth Search, Microsoft Planetary Computer) for scenes that overlap a block, in a date range, optionally filtered by cloud cover. Unknown query parameters are rejected with a 400 (so a typo like collection instead of collections fails loudly instead of being silently ignored).

Query parameters:

Param Required Description
farm_id Yes Farm the block belongs to (caller must have read access)
block_id Yes Block whose footprint defines the search bounding box
start Yes ISO date — start of search window
end Yes ISO date — end of search window
collections No Comma-separated collection ids; defaults to S2 + S1 + Landsat
cloud_cover_max No 0–100 — applies to optical collections only
limit No Max scenes per collection (default 25, max 100)

Response:

{
  "bbox": [-77.4, 42.4, -77.3, 42.5],
  "start": "2025-06-01T00:00:00Z",
  "end":   "2025-09-30T00:00:00Z",
  "results": [
    {
      "collection": "sentinel-2-l2a",
      "count": 18,
      "scenes": [
        {
          "id": "S2A_MSIL2A_20250812T154851_R068_T18TVN_20250812T215012",
          "collection": "sentinel-2-l2a",
          "datetime": "2025-08-12T15:48:51Z",
          "cloudCover": 4.2,
          "bbox": [-77.5, 42.3, -77.2, 42.6],
          "assetKeys": ["red","green","blue","nir","rededge1","swir16","..."]
        }
      ]
    }
  ]
}

The id field of each scene is what you pass as inputs.sceneId when submitting an analysis.


#Submit an Analysis

POST /v1/satellite/analyses

Scope: write

Kicks off an analysis recipe. The endpoint validates the request, persists it, and returns immediately with a job id. The actual work runs asynchronously in a background worker (4 GB / up to 540 s) — poll GET /v1/satellite/analyses/:job_id for progress and the final dataset id.

Request body:

{
  "recipe": "single_scene",
  "farm_id": "farm_abc123",
  "block_id": "block_xyz",
  "inputs": {
    "sceneId": "S2A_MSIL2A_20250812T154851_R068_T18TVN_20250812T215012",
    "collection": "sentinel-2-l2a",
    "indices": ["ndvi", "ndre"],
    "includeRasterPreview": true,
    "includeRasterDownload": false
  },
  "folder_id": "root",
  "dataset_name": "NDVI 2025-08-12",
  "dataset_description": "Submitted via developer API"
}

Body fields:

Field Required Description
recipe Yes One of the recipe ids from /v1/satellite/recipes
farm_id Yes Destination farm — must be writable by the token's user
block_id Yes Block geometry to clip / sample over
inputs Yes Recipe-specific input bag (see /v1/satellite/recipes)
cell_size No Grid cell size in meters — one of 10, 20, 30, 100 (400 on anything else); default = source native. Use this for large blocks: a 100 m grid makes the output ~100× smaller than 10 m. Vector output is capped at 250,000 cells; an oversized default grid is auto-coarsened to the next size that fits (for single_scene, result.meta.cellSize in the poll response shows the size that was used; there is no separate flag), while an explicitly requested size that exceeds the cap fails with guidance on which size to pass.
folder_id No Destination folder; default "root"
dataset_name No Override default dataset name
dataset_description No Optional description prepended to the auto-generated description

Response (202 Accepted):

{
  "job_id": "api_1717012345678_a1b2c3d4",
  "status": "queued",
  "poll_url": "/v1/satellite/analyses/api_1717012345678_a1b2c3d4"
}

Concurrency limit: each user may have at most 3 analysis jobs queued or running at once. Submitting past the cap returns 429 with retryAfterSeconds — wait for a job to finish (poll it), then resubmit.

Upstream resilience: transient upstream failures (catalog 429s / 5xx) are retried automatically inside the job (3 attempts with backoff). If the budget is exhausted the job fails with retryable: true — simply resubmit the same request.


#Poll an Analysis

GET /v1/satellite/analyses/:job_id

Scope: read

Returns the current status of an analysis job. While running, stage, stage_label, and percent reflect live progress. When complete, result contains the dataset id(s) the analysis wrote — fetch them via the regular dataset endpoints.

Response (in flight):

{
  "job_id": "api_1717012345678_a1b2c3d4",
  "recipe": "single_scene",
  "farm_id": "farm_abc123",
  "block_id": "block_xyz",
  "status": "running",
  "stage": "fetching",
  "stage_label": "Fetching NDVI",
  "detail": "Index 1 of 2",
  "percent": 45,
  "error": null,
  "result": null
}

Response (complete):

{
  "job_id": "api_1717012345678_a1b2c3d4",
  "recipe": "single_scene",
  "status": "complete",
  "stage": "complete",
  "percent": 100,
  "result": {
    "success": true,
    "datasetId": "dataset_new_a1",
    "datasetIds": ["dataset_new_a1", "dataset_new_a2"],
    "meta": {
      "indexCount": 2,
      "cellCount": 1280,
      "cellSize": 10,
      "utmZone": 18
    }
  }
}

With the dataset id in hand, the agent can fetch the resulting GeoJSON via GET /v1/datasets/:dataset_id/geojson exactly like any other Every.Farm dataset.

Failed jobs return status: "failed" with a human-readable error field, plus retryable: true when the failure was a transient upstream condition (e.g. a satellite catalog 429/5xx that survived in-job retries) — resubmitting the same request is expected to succeed.


#End-to-End Agent Workflow

A typical AI-agent loop using these endpoints looks like:

# 1. Discover what's available
curl https://api.every.farm/v1/satellite/collections -H "Authorization: Bearer $TOKEN"
curl https://api.every.farm/v1/satellite/recipes     -H "Authorization: Bearer $TOKEN"
# 2. Find scenes over a block
curl "https://api.every.farm/v1/satellite/scenes?farm_id=$FARM&block_id=$BLOCK&start=2025-06-01&end=2025-09-30&collections=sentinel-2-l2a&cloud_cover_max=20" \
  -H "Authorization: Bearer $TOKEN"
# 3. Submit an analysis (returns a job id)
curl -X POST https://api.every.farm/v1/satellite/analyses \
  -H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
  -d '{
    "recipe":"single_scene",
    "farm_id":"'$FARM'","block_id":"'$BLOCK'",
    "inputs":{"sceneId":"'$SCENE_ID'","collection":"sentinel-2-l2a","indices":["ndvi","ndre"]}
  }'
# 4. Poll until done
curl https://api.every.farm/v1/satellite/analyses/$JOB_ID -H "Authorization: Bearer $TOKEN"
# 5. Fetch the resulting vector dataset
curl https://api.every.farm/v1/datasets/$DATASET_ID/geojson -H "Authorization: Bearer $TOKEN"

#Missions (robot dispatch)

Mission Dispatch lets a robot on your farm pick up work you publish in the Missions plugin, report progress and faults, and stream its position onto the farm map's LIVE view. The robot side is a small polling client; a reference implementation for the farm-ng Amiga running the PPBv2 navigation stack lives in the repo at tools/everyfarm-bridge/.

Safety boundary. Every.Farm dispatches and reports; it never commands motion. The robot validates every path against its own limits and owns all stop behaviour. An abort from the app only asks the robot's follower to stop.

#The path contract: one mission, one line

A mission's path is a dataset on the farm. The rule is simple: one mission = one line.

  • The dataset must contain a single LineString (or a one-part MultiLineString). Vertex order is the drive order. params.direction: "reverse" flips it.
  • The robot inserts its own current position as waypoint 0 and drives to the first vertex, so Every.Farm never adds a start point.
  • Point datasets are accepted only when every point has a numeric seq (or order / waypoint) property. File order is never used, because editing features can reorder them.
  • A dataset with several line features or parts is rejected with 422. It is ambiguous: the robot would drive straight from the end of one row to the start of the next. Split it into one dataset per mission, or merge the rows into one continuous line.

The Missions plugin checks the same rules when you pick a path and previews the line with START and END markers so you can confirm the orientation before publishing.

#Mission lifecycle

Status Set by Meaning
draft person (plugin or API) Saved, not visible to robots
ready person (Publish) Robots polling this farm can claim it
claimed robot One robot won the claim; others get 409
running robot Robot reported it started; heartbeats carry progress
complete / failed robot Finished, with results or a failure reason
aborted person (Abort) Robot learns of it from the heartbeat echo and stops

Missions expire 14 days after publishing if nobody claims them, and a claimed mission with no heartbeat for 10 minutes is released back to ready.

#Robot loop

A robot's whole job, in order. Each step has its own section below.

GET  /v1/farms/:farm_id/missions?status=ready&asset=<assetId>   poll every ~30 s
POST /v1/missions/:mission_id/claim                             one robot wins; others get 409
GET  /v1/missions/:mission_id/waypoints.csv                     the path, in drive order
POST /v1/farms/:farm_id/tracks                                  once; then chunks every ~30 s
POST /v1/missions/:mission_id/status                            "running", then a heartbeat every ~30 s
POST /v1/farms/:farm_id/events                                  whenever something happens
POST /v1/missions/:mission_id/complete                          outcome complete or failed
POST /v1/tracks/:track_id/finalize                              turns the track into a coverage dataset

A reference implementation for the farm-ng Amiga / PPBv2 stack does all of this in about 500 lines of Python (everyfarm_bridge.py).

#List Missions (poll)

GET /v1/farms/:farm_id/missions · scope read

Lists the farm's missions. A robot polls with ?status=ready&asset=<assetId>; status also accepts claimed, draft, running, complete, failed, aborted or all (default ready). A mission with an assetId is only offered to that asset; one without may be claimed by any. At most 100 missions are returned. Claimed missions with no heartbeat for 10 minutes are released back to ready when listed.

Response (200):

{
  "missions": [
    {
      "id": "1buQX4DTtHVNUP2BwYXB",
      "farmId": "jrLGtc3OQpnjDesJCq4S",
      "name": "Row scout, west rows 21-25",
      "type": "follow_track",
      "status": "ready",
      "assetId": "amiga-clerel-1",
      "assetLabel": "CLEREL Amiga",
      "pathDatasetId": "c2pX8PHHYbVNKoT3rN2f",
      "pathUrl": "/v1/datasets/c2pX8PHHYbVNKoT3rN2f/geojson",
      "params": { "targetSpeedMps": 0.5, "laps": 1, "direction": "as-drawn" },
      "progress": null,
      "waypointIndex": null,
      "waypointCount": null,
      "lastPositionLngLat": null,
      "expiresOn": { "_seconds": 1791814980, "_nanoseconds": 0 }
    }
  ]
}

#Claim a Mission

POST /v1/missions/:mission_id/claim · scope write

Atomically claims a ready mission for one robot. The first claim wins; every other caller gets 409.

Request body:

{ "assetId": "amiga-clerel-1" }
Field Required Description
assetId yes The robot's id, recorded as claimedAsset. A mission created with an assetId can only be claimed by that asset; the list's ?asset= filter shows exactly the missions offered to you

Response (200):

{ "mission": { "id": "1buQX4DTtHVNUP2BwYXB", "status": "claimed", "claimedAsset": "amiga-clerel-1" } }

Already claimed (409):

{ "error": "Mission is not claimable (status: claimed)." }

Assigned to a different asset (409):

{ "error": "Mission is assigned to asset \"amiga-clerel-1\"; \"amiga-clerel-2\" may not claim it." }

#Get Mission Waypoints (CSV)

GET /v1/missions/:mission_id/waypoints.csv · scope read

The mission path as text/csv: a header row, then latitude,longitude,seq,name, already in drive order (params.direction is applied for you). The PPBv2 follower reads the first two columns and skips the header, so the file can be handed to it unchanged. Every.Farm never adds a start point; the robot inserts its own position as waypoint 0.

latitude,longitude,seq,name
42.87906016,-77.01672012,0,"west_row21_to_row25"
42.87877659,-77.01521554,1,"west_row21_to_row25"

Returns 422 with a plain-language reason when the path dataset breaks the path contract (several lines, or points without seq).

#Report Mission Status (heartbeat)

POST /v1/missions/:mission_id/status · scope write · only tokens of the user who claimed it

Marks the mission running and keeps it alive. Send running once when the robot starts, then heartbeat about every 30 seconds. waypointIndex / waypointCount becomes the mission's progress.

Request body:

{ "status": "heartbeat", "waypointIndex": 17, "waypointCount": 62, "positionLngLat": [-77.0156, 42.8788] }
Field Required Description
status yes running (first report) or heartbeat
waypointIndex no Current waypoint, 0-based; with waypointCount sets progress
waypointCount no Total waypoints in the CSV
progress no 0 to 1, if you track progress another way
positionLngLat no Current position as [longitude, latitude]

Response (200):

{ "currentStatus": "running" }

currentStatus is how a robot learns of an abort: when it comes back aborted, stop the follower and complete the mission as failed.

#Complete a Mission

POST /v1/missions/:mission_id/complete · scope write · only tokens of the user who claimed it

Request body:

{ "outcome": "complete", "results": { "summary": "Completed: waypoint 12 / 12" } }
Field Required Description
outcome yes complete or failed
failureReason no Why it failed, up to 2,000 characters
results no Object with summary (string, up to 2,000 characters, shown in the app), trackId (string) and datasetIds (array of up to 50 ids). Other keys are ignored

Response (200):

{ "mission": { "id": "1buQX4DTtHVNUP2BwYXB", "status": "complete" } }

#Create a Mission

POST /v1/farms/:farm_id/missions · scope write

Creates a mission as a draft. A person publishes it in the Missions plugin; until then no robot can see it. Only farms that have opted in (settings.autoDispatchMissions) accept "status": "ready" here, so an agent can propose work but a human still dispatches it.

Request body:

{
  "name": "Row scout, west rows 21-25",
  "pathDatasetId": "c2pX8PHHYbVNKoT3rN2f",
  "assetId": "amiga-clerel-1",
  "assetLabel": "CLEREL Amiga",
  "params": { "targetSpeedMps": 0.5, "laps": 1, "direction": "as-drawn" },
  "notes": "Scout after rain"
}
Field Required Description
name yes Mission name
pathDatasetId yes A dataset on this farm holding one line (see the path contract above)
assetId no Only this robot may claim it; omit to let any robot claim it
assetLabel no Display name for the robot
params no targetSpeedMps (advisory), laps, direction (as-drawn or reverse)
notes no Up to 4,000 characters

Response (201):

{ "mission": { "id": "4iLfPBHqqHLLeWrIbqNW", "status": "draft", "name": "Row scout, west rows 21-25" } }

#Report a Robot Event

POST /v1/farms/:farm_id/events · scope write

Tells the farm what happened. Warnings and criticals notify the farm's members in the app and by push; criticals also email. Events are rate-limited to 30 per asset per 5 minutes, and the same code within 60 seconds is folded into one event with a count, so a bouncing sensor cannot flood anyone's phone. A running mission that goes silent for more than 3 minutes raises a heartbeat_lost critical event automatically.

Request body:

{
  "assetId": "amiga-clerel-1",
  "assetLabel": "CLEREL Amiga",
  "severity": "critical",
  "code": "motor_overheat",
  "message": "Motor temperature 92 °C, stopped.",
  "missionId": "1buQX4DTtHVNUP2BwYXB",
  "positionLngLat": [-77.0156, 42.8788],
  "data": {}
}
Field Required Description
assetId yes The robot's id
severity yes info (logged), warning (in-app + push) or critical (in-app + push + email)
code no Short machine code such as rtk_lost, estop, motor_overheat; default custom
message no Human-readable text, up to 1,000 characters
missionId no The mission it relates to (must be on this farm)
positionLngLat no Where it happened, [longitude, latitude]
assetLabel no Display name for the robot
data no Free-form object

Response (201):

{ "event": { "id": "czo51UYCwqJ2ZJgXlWas", "severity": "critical", "code": "motor_overheat", "count": 1 } }

A duplicate within 60 seconds returns 200 with "deduplicated": true; the rate limit returns 429 with retryAfterSeconds.

#List Robot Events

GET /v1/farms/:farm_id/events · scope read

Newest first. Filters: ?since=<ISO time>, ?severity=info|warning|critical, ?asset=<assetId>, ?unacknowledged=true. Filters apply to the 200 most recent events.

Response (200):

{ "events": [ { "id": "czo51UYCwqJ2ZJgXlWas", "assetId": "amiga-clerel-1", "severity": "critical", "code": "motor_overheat", "acknowledgedBy": null } ], "count": 1 }

#Acknowledge a Robot Event

POST /v1/events/:event_id/ack · scope write

Marks the event acknowledged by the token's user. No body needed.

Response (200):

{ "event": { "id": "czo51UYCwqJ2ZJgXlWas", "acknowledgedBy": "uid_123" } }

#Open a Live Track

POST /v1/farms/:farm_id/tracks · scope write

Starts a live position track. While it is open the robot appears on the farm map's LIVE view and in the mobile app.

Request body:

{ "createdByLabel": "CLEREL Amiga", "activities": ["mission"], "missionId": "1buQX4DTtHVNUP2BwYXB" }

Response (201):

{ "trackId": "trk_8sd7f6" }

#Append Track Points

POST /v1/tracks/:track_id/lines · scope write

Appends one chunk of positions. Send a chunk about every 30 seconds. Chunks are idempotent by segmentIndex + chunkIndex, so a retried post never duplicates points; start a new segmentIndex after a gap.

Request body:

{
  "segmentIndex": 0,
  "chunkIndex": 3,
  "lngs": [-77.01672, -77.01668],
  "lats": [42.87906, 42.87905],
  "times": [1790605810000, 1790605811000],
  "accuracies": [0.02, 0.02],
  "speeds": [0.5, 0.5],
  "startedAt": 1790605810000,
  "endedAt": 1790605811000
}
Field Required Description
segmentIndex yes Non-negative integer; a new segment after a gap
chunkIndex yes Non-negative integer within the segment
lngs yes Longitudes; same length as lats and times, at most 2,000
lats yes Latitudes
times yes Epoch milliseconds
accuracies no Metres, one per point
speeds no Metres per second, one per point

Response (200):

{ "ok": true, "chunkId": "s0-c3", "pointCount": 2 }

#Finalize a Track

POST /v1/tracks/:track_id/finalize · scope write

Closes the track. Processing runs asynchronously and turns it into a coverage dataset on the farm.

Response (202):

{ "finalizeRequestId": "fin_123", "note": "Finalize runs asynchronously; the track doc gains datasetId when done." }

#Public Dataset Endpoint

Datasets are private by default. A dataset can be marked Public in the app (Dataset Details > Downloads tab), which exposes one read-only endpoint that needs no authentication:

GET https://api.every.farm/dataset/:dataset_id/geojson

Response (200):

{
  "datasetId": "dataset_123",
  "datasetName": "Yield 2025",
  "featureCount": 1284,
  "geojson": { "type": "FeatureCollection", "features": [] }
}

It works only for datasets that have been marked Public; any other dataset returns 403. A dataset with no GeoJSON (a raster, for example) returns 404, and a file over 50 MB returns 413. Image fields are not turned into signed URLs here.

Everything else needs a token and the /v1 API. The rest of the legacy unauthenticated API and the api.efficientvineyard.com host were removed in a security hardening pass.


#Error Responses

All errors follow a consistent format:

{
  "error": "Description of what went wrong.",
  "documentation": "https://every.farm/docs/developer-api/#create-a-mission",
  "discovery": "https://api.every.farm/v1"
}

documentation links to the section for the endpoint that failed. Some errors add fields of their own, such as similar on a 404 for an unknown path.

Status Meaning
400 Bad request — missing or invalid parameters
401 Unauthorized — missing, invalid, or expired token
403 Forbidden — token lacks scope or farm access
404 Not found — resource doesn't exist, or no such path (similar lists the nearest endpoints)
405 Wrong method for this path; the Allow header lists the methods it accepts
409 Conflict with the current state — a folder that is not empty, a duplicate header label, a mission that cannot be claimed, a track that is already finalized
413 Request body over 10 MB
422 A mission's path dataset breaks the path contract
429 Rate limited — try again later
500 Server error

#Quick Start Example

1. Generate a token in the app: account menu → API Tokens → Create New Token → Generate Token

2. List your farms:

curl https://api.every.farm/v1/farms \
  -H "Authorization: Bearer efv_your_token_here"

3. Get datasets for a farm:

curl https://api.every.farm/v1/farms/FARM_ID/datasets \
  -H "Authorization: Bearer efv_your_token_here"

4. Download GeoJSON:

curl https://api.every.farm/v1/datasets/DATASET_ID/geojson \
  -H "Authorization: Bearer efv_your_token_here"

5. Push a dataset:

curl -X POST https://api.every.farm/v1/farms/FARM_ID/datasets \
  -H "Authorization: Bearer efv_your_token_here" \
  -H "Content-Type: application/json" \
  -d '{
    "name": "My New Dataset",
    "geojson": {
      "type": "FeatureCollection",
      "features": [
        {
          "type": "Feature",
          "properties": { "value": 42 },
          "geometry": { "type": "Point", "coordinates": [-121.7, 38.5] }
        }
      ]
    }
  }'

#GeoJSON Format Reference

All dataset data uses the GeoJSON specification (RFC 7946). A valid dataset must be a FeatureCollection:

{
  "type": "FeatureCollection",
  "features": [
    {
      "type": "Feature",
      "properties": {
        "field_name": "value"
      },
      "geometry": {
        "type": "Point",
        "coordinates": [longitude, latitude]
      }
    }
  ]
}

Supported geometry types: Point, Polygon, LineString, MultiLineString, MultiPolygon

Coordinates use [longitude, latitude] order (WGS 84 / EPSG:4326).

Properties can contain any key-value pairs. The keys found across all features become the dataset's column headers in the application.

Updated

Something missing or out of date? Tell us — the docs are updated with every release.