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 and required scope, the authentication scheme, conventions (signed-URL TTLs, rate limits, timestamp shape), and a link back to this documentation. 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.


#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
  • 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

#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
}

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


#Permissions & Scopes

#Token Scopes

Scope Access
read List farms, datasets, blocks, folders; fetch GeoJSON data
write Push new datasets; update, move, and trash datasets; create blocks; edit and delete datapoints; manage block headers and their values; create and organize folders

#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" }
  ]
}

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.

#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

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

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,
  "geoJsonURL": "https://storage.googleapis.com/...",
  "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 automatically extracted from the first feature's properties keys.

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. 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, a fresh GeoJSON is written to Cloud Storage, and the dataset's headers field is regenerated. 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; poll the request doc for completion or fetch /v1/datasets/:dataset_id/geojson after a few seconds."
}

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 subscribe to compileRequests/{compileRequestId}.complete === true.


#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 n/a 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 / temporal_mean — optical + SAR (S2, S1, Landsat, HLS) that have band data for indices.
  • 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 (the poll response then carries cellSizeAutoCoarsened: true and the actual cellSize), 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"

#Public Dataset Endpoint

Datasets can be marked as Public in the app (Dataset Details > Downloads tab). It previously exposed the following read-only endpoint without authentication (now retired — see the note below):

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

This is the legacy endpoint shown in the app. It returns:

{
  "geojson": { ... },
  "datasetId": "dataset_123",
  "datasetName": "Yield 2025"
}

Datasets are private by default.

Update — this endpoint has been retired. As part of a security hardening pass, the entire legacy unauthenticated API (including GET /dataset/:dataset_id/geojson and the api.efficientvineyard.com host) was removed. Those URLs now return 404. All dataset access — public or private — now goes through the token-authenticated /v1 API. Read a dataset with GET /v1/datasets/:dataset_id/geojson and a Bearer token (see the Endpoints section above); the token's farm-role permissions govern what it can read.


#Error Responses

All errors follow a consistent format:

{ "error": "Description of what went wrong." }
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
429 Rate limited — try again later
500 Server error

#Quick Start Example

1. Generate a token in the app: account menu → API Tokens → New 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 from the first feature 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.