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 two paths below do the same thing, but they don’t have the same number of
steps. With the Python SDK it’s one call plus a wait —
create_tree_inventory_from_file creates the inventory and PUTs your file for
you. At the raw HTTP level it’s three calls — create the inventory (and get
a signed upload URL), PUT your file to that URL, then poll until it’s
processed. Pick the path that matches how you’re working; you don’t need both.
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,y, andheightcolumns (x/yin the domain CRS) plus whatever other 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 with
columns(SDK) or thecolumnsobject in the request body (HTTP).
With the Python SDK
Section titled “With the Python SDK”create_tree_inventory_from_file folds the create and the PUT into a single
call; inventory.wait() blocks until processing finishes. That’s the whole
flow:
import fastfuels_sdk.v2 as ff
ff.set_api_key("my-api-key")
domain = ff.Domain.from_id("your-domain-id")
# create_tree_inventory_from_file creates the inventory and PUTs your file to# the signed URL in one call. `columns` maps your file's column names onto the# canonical fields; `format` is inferred from the file extension.inventory = ff.inventories.create_tree_inventory_from_file( domain, "trees.csv", columns={ "x": "tree_x", "y": "tree_y", "dbh": "dbh_cm", "height": "ht_m", "crown_ratio": "cr", "fia_species_code": "spcd", }, name="Field-cruise tree inventory",)
# Block until processing finishes. The completed inventory carries the# canonical columns you mapped, normalized.inventory.wait()print(inventory.id, inventory.status)The columns mapping is the one thing to get right: each key is a canonical
field, and its value is the column name in your file (here tree_x → x,
dbh_cm → dbh, and so on). The completed inventory carries those canonical
columns (x, y, dbh, height, crown_ratio, fia_species_code here),
normalized. Add fia_status_code too if your file has a tree-status column.
The raw HTTP flow (curl)
Section titled “The raw HTTP flow (curl)”The same upload as three raw calls — this is what the SDK does for you above. Reach for it when you’re not in Python, or when you want to see the signed-URL handshake.
Step 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.
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", "headers": { "Content-Type": "text/csv", "x-goog-content-length-range": "0,524288000" }, "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 plusupload.headers, the exact request headers (Content-Typeandx-goog-content-length-range) to replay on the upload. Set the URL 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", "status": "completed", "source": { "format": "csv", "object_name": "inventories/your-inventory-id/upload.csv", "columns": { "height": "ht_m", "crown_ratio": "cr", "x": "tree_x", "y": "tree_y", "dbh": "dbh_cm", "fia_species_code": "spcd" }, "name": "upload" }, "name": "Field-cruise tree inventory", "description": "", "progress": { "message": "Complete", "percent": 100 }, "created_on": "2026-08-27T12:39:36.579244+00:00", "modified_on": "2026-08-27T12:39:58.750887+00:00", "checksum": "11f1c7fc86ce462289700120efbc465b", "modifications": [], "treatments": [], "columns": [ { "key": "x", "type": "continuous", "unit": "m", "summary": { "type": "continuous", "count": 6, "null_count": 0, "min": 720400.0, "max": 721100.0, "mean": 720736.8333333334, "std": 264.3218997106874 } }, { "key": "y", "type": "continuous", "unit": "m", "summary": { "type": "continuous", "count": 6, "null_count": 0, "min": 5190000.0, "max": 5190450.0, "mean": 5190186.75, "std": 167.44961928890731 } }, { "key": "fia_species_code", "type": "categorical", "unit": null, "summary": { "type": "categorical", "count": 6, "null_count": 0, "unique_count": 4 } }, { "key": "dbh", "type": "continuous", "unit": "cm", "summary": { "type": "continuous", "count": 6, "null_count": 0, "min": 12.7, "max": 52.6, "mean": 30.166666666666668, "std": 14.896666293727153 } }, { "key": "height", "type": "continuous", "unit": "m", "summary": { "type": "continuous", "count": 6, "null_count": 0, "min": 8.1, "max": 24.7, "mean": 15.883333333333333, "std": 6.279304632414855 } }, { "key": "crown_ratio", "type": "continuous", "unit": null, "summary": { "type": "continuous", "count": 6, "null_count": 0, "min": 0.35, "max": 0.65, "mean": 0.49166666666666664, "std": 0.11583033569262703 } } ], "forestry_metrics": null, "georeference": { "crs": "EPSG:32611", "bounds": [720400.0, 5190000.0, 721100.0, 5190450.0] }, "error": null, "tags": []}The processed inventory carries the canonical columns you mapped, 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. (The SDK handles this for you.) - Mismatched upload headers. GCS rejects the
PUTwith400/403ifContent-Typeorx-goog-content-length-rangedon’t match what step 1 returned. Echo them verbatim. (Again, only a concern on the raw HTTP path.)