Upload your own tree inventory
You are viewing in-progress documentation for v2 (Beta). Switch to the stable version for the current production release.
Already have a tree list — a field cruise, a plot inventory, output from another model? Upload it directly instead of generating one. The flow is three calls: create the inventory (and get a signed upload URL), PUT your file to that URL, then poll until it’s processed.
Prerequisites
Section titled “Prerequisites”-
An API key: my-api-key.
-
A domain in a projected CRS: your-domain-id. Your tree coordinates must be in this domain’s CRS (meters).
-
A tree file — CSV, GeoJSON, or GeoPackage. A CSV needs at least
x/ycolumns (in the domain CRS) plus whatever attributes you have. Here’s the example used below:trees.csv tree_x,tree_y,dbh_cm,ht_m,cr,spcd720400.0,5190000.0,25.4,14.2,0.45,122720520.5,5190120.0,30.1,16.8,0.50,202720650.0,5190050.5,12.7,8.1,0.35,122720800.0,5190300.0,41.9,21.0,0.60,93720950.5,5190450.0,18.3,10.5,0.40,15721100.0,5190200.0,52.6,24.7,0.65,202The column names are arbitrary — you map them to the canonical fields in step 1.
The whole flow in one script:
import time
import requests
API_KEY = "my-api-key"DOMAIN_ID = "your-domain-id"CSV_PATH = "trees.csv"BASE = "https://api-v2-prod-nyvjyh5ywa-uw.a.run.app"HEADERS = {"api-key": API_KEY}
# 1. Create the inventory + get a signed upload URL. `columns` maps your# file's column names onto the canonical fields.created = requests.post( f"{BASE}/domains/{DOMAIN_ID}/inventories/tree/upload", headers=HEADERS, json={ "name": "Field-cruise tree inventory", "format": "csv", "columns": { "x": "tree_x", "y": "tree_y", "dbh": "dbh_cm", "height": "ht_m", "crown_ratio": "cr", "fia_species_code": "spcd", }, },).json()inventory_id = created["inventory"]["id"]upload = created["upload"]
# 2. PUT the file to the signed URL (no api-key — this goes to GCS).with open(CSV_PATH, "rb") as f: put = requests.put( upload["url"], data=f, headers={ "Content-Type": upload["content_type"], "x-goog-content-length-range": f"0,{upload['max_size_bytes']}", }, )put.raise_for_status()
# 3. Poll the inventory until it is processed.while True: inv = requests.get( f"{BASE}/domains/{DOMAIN_ID}/inventories/{inventory_id}", headers=HEADERS ).json() if inv["status"] in ("completed", "failed"): break time.sleep(5)print(inventory_id, inv["status"]) # -> <inventory id> completedStep 1 — Create the upload and map columns
Section titled “Step 1 — Create the upload and map columns”POST to /inventories/tree/upload with the format and a columns
mapping. Each key is a canonical field; its value is the column name in your
file (here tree_x → x, dbh_cm → dbh, and so on).
curl -X 'POST' \ 'https://api-v2-prod-nyvjyh5ywa-uw.a.run.app/domains/your-domain-id/inventories/tree/upload' \ -H 'accept: application/json' \ -H 'api-key: my-api-key' \ -H 'Content-Type: application/json' \ -d '{ "name": "Field-cruise tree inventory", "format": "csv", "columns": { "x": "tree_x", "y": "tree_y", "dbh": "dbh_cm", "height": "ht_m", "crown_ratio": "cr", "fia_species_code": "spcd" }}'{ "inventory": { "id": "your-inventory-id", "domain_id": "your-domain-id", "type": "tree", "name": "Field-cruise tree inventory", "description": "", "status": "pending", "progress": null, "created_on": "2026-05-25T19:02:45.777653Z", "modified_on": "2026-05-25T19:02:45.777653Z", "source": { "name": "upload", "format": "csv", "object_name": "inventories/your-inventory-id/upload.csv", "columns": { "x": "tree_x", "y": "tree_y", "height": "ht_m", "fia_species_code": "spcd", "dbh": "dbh_cm", "crown_ratio": "cr" } }, "modifications": [], "columns": [ { "key": "x", "type": "continuous", "unit": "m" }, { "key": "y", "type": "continuous", "unit": "m" }, { "key": "fia_species_code", "type": "categorical", "unit": null }, { "key": "fia_status_code", "type": "categorical", "unit": null }, { "key": "dbh", "type": "continuous", "unit": "cm" }, { "key": "height", "type": "continuous", "unit": "m" }, { "key": "crown_ratio", "type": "continuous", "unit": null } ], "georeference": null, "error": null, "tags": [] }, "upload": { "method": "PUT", "url": "paste-signed-url-from-201-response", "content_type": "text/csv", "expires_at": "2026-05-25T20:02:45.777653Z", "max_size_bytes": 524288000 }}The response nests two objects:
inventory— the new resource (status: "pending"). Record its id: your-inventory-id.upload— the signedPUTURL plus thecontent_typeandmax_size_bytesyou must echo on the upload. Set it here: paste-signed-url-from-201-response.
Step 2 — PUT the file to the signed URL
Section titled “Step 2 — PUT the file to the signed URL”Upload the file directly to GCS — not through the FastFuels API, so send
no api-key. The Content-Type and x-goog-content-length-range headers
must match the upload block from step 1.
# PUT the CSV directly to the signed URL returned in step 1.# This goes to Google Cloud Storage, not the FastFuels API — send no api-key.# Both headers must match the values from the create response.curl -X 'PUT' \ 'paste-signed-url-from-201-response' \ -H 'Content-Type: text/csv' \ -H 'x-goog-content-length-range: 0,524288000' \ --data-binary @trees.csvA successful upload returns 200 OK with an empty body and triggers
processing.
Step 3 — Poll until completed
Section titled “Step 3 — Poll until completed”curl -X 'GET' \ 'https://api-v2-prod-nyvjyh5ywa-uw.a.run.app/domains/your-domain-id/inventories/your-inventory-id' \ -H 'accept: application/json' \ -H 'api-key: my-api-key'{ "id": "your-inventory-id", "domain_id": "your-domain-id", "type": "tree", "name": "Field-cruise tree inventory", "description": "", "status": "completed", "progress": { "percent": 100, "message": "Complete" }, "created_on": "2026-05-25T19:02:45.777653Z", "modified_on": "2026-05-25T19:03:20.272300Z", "source": { "name": "upload", "columns": { "dbh": "dbh_cm", "fia_species_code": "spcd", "y": "tree_y", "x": "tree_x", "height": "ht_m", "crown_ratio": "cr" }, "format": "csv", "object_name": "inventories/your-inventory-id/upload.csv" }, "modifications": [], "columns": [ { "key": "x", "type": "continuous", "unit": "m" }, { "key": "y", "type": "continuous", "unit": "m" }, { "key": "fia_species_code", "type": "categorical", "unit": null }, { "key": "fia_status_code", "type": "categorical", "unit": null }, { "key": "dbh", "type": "continuous", "unit": "cm" }, { "key": "height", "type": "continuous", "unit": "m" }, { "key": "crown_ratio", "type": "continuous", "unit": null } ], "georeference": { "crs": "EPSG:32611", "bounds": [720400.0, 5190000.0, 721100.0, 5190450.0] }, "error": null, "tags": []}The processed inventory carries the canonical columns (x, y, dbh,
height, crown_ratio, fia_species_code, fia_status_code) — your mapped
fields, normalized.
Common pitfalls
Section titled “Common pitfalls”- Coordinates in the wrong CRS.
x/ymust be in the domain’s projected CRS (meters). Lat/lon or a different projection places trees outside the domain. Reproject before uploading. - Unmapped columns. Only the fields you list in
columnsare read. A typo in a source column name (e.g.dbh_cmvsdbh_CM) silently drops that attribute — check the completed inventory’s columns. - Signed URL is single-use and expires.
expires_atis one hour out. If thePUTfails or the URL expires, start over from step 1 for a fresh URL. - Mismatched upload headers. GCS rejects the
PUTwith400/403ifContent-Typeorx-goog-content-length-rangedon’t match what step 1 returned. Echo them verbatim.