Upload a GeoJSON to create a layerset
You are viewing in-progress documentation for v2 (Beta). Switch to the stable version for the current production release.
This guide creates a Layerset feature resource from a flat GeoJSON
FeatureCollection of fuelbed polygons. Use it whenever you want to attach a
custom set of surface-fuel inputs to a domain — each Feature’s properties
block carries one fuelbed’s row of inputs for downstream rasterization.
The endpoint is POST /domains/{domain_id}/features/layerset/geojson, and
unlike the GeoTIFF and
netCDF upload workflows
this one is a single synchronous call:
- Create the layerset — the API validates the GeoJSON, uploads it to
storage, computes the union bounds, and returns
201 Createdwithstatus: "completed"immediately. There is no signed URL, noPUTstep, and no polling.
The body is the GeoJSON itself. Most of what’s interesting on this page is
the per-feature properties contract — what each Feature must declare and
what’s optional — plus the CRS requirement.
Prerequisites
Section titled “Prerequisites”-
An API key. Create one in the FastFuels web app under your account settings. Every request below sends it in the
api-keyheader — set it once here: my-api-key. It will propagate to every code block on the page. -
A domain in a projected CRS. All features hang off a domain. The domain’s CRS effectively anchors any feature you upload to it, so pick one whose CRS matches the coordinate units of your GeoJSON. If you do not have a domain yet, see the Create a domain how-to guide, then come back here. Plug your domain’s
idin once: your-domain-id. -
A flat GeoJSON FeatureCollection in your domain’s CRS. The FeatureCollection must:
- Declare its CRS on the top-level
crsblock. Both"EPSG:32612"and the URN form"urn:ogc:def:crs:EPSG::32612"are accepted. If you omitcrs, the server falls back toEPSG:4326per the GeoJSON spec, which is geographic and is therefore rejected. - Contain at least one Feature. Empty FeatureCollections are rejected
with
422because there’s nothing to rasterize. - Use
PolygonorMultiPolygongeometries. Standard tooling (QGIS, GDAL, geopandas) emits the right shape automatically. - Carry the per-feature properties on every Feature.
geopandas.GeoDataFrame.to_file("layerset.geojson", driver="GeoJSON")is the conventional way to author one of these in Python — make sure your GeoDataFrame’s.crsis set to your domain’s projected CRS first. - Declare its CRS on the top-level
Step 1 — Create the layerset
Section titled “Step 1 — Create the layerset”POST the body to /domains/{{DOMAIN_ID}}/features/layerset/geojson. The
body is the GeoJSON FeatureCollection — type is "FeatureCollection",
the CRS goes on the top-level crs block, and the fuelbeds go in features.
Optional resource-level metadata sits alongside them at the top level:
name,description,tags— optional metadata that shows up on the returned Feature.
Two shapes cover most cases:
The smallest payload that exercises the contract — two fuelbeds, each
with only the required properties columns, no resource-level metadata.
curl -X 'POST' \ 'https://api-v2-prod-nyvjyh5ywa-uw.a.run.app/domains/your-domain-id/features/layerset/geojson' \ -H 'accept: application/json' \ -H 'api-key: my-api-key' \ -H 'Content-Type: application/json' \ -d '{ "type": "FeatureCollection", "crs": { "type": "name", "properties": { "name": "urn:ogc:def:crs:EPSG::32612" } }, "features": [ { "type": "Feature", "properties": { "fuel_type": "shrub", "fuel_loading": 1.2, "fuel_height": 0.6, "percent_cover": 35, "distribution": "homogeneous" }, "geometry": { "type": "Polygon", "coordinates": [[ [294120.0, 5199000.0], [294520.0, 5199000.0], [294520.0, 5199400.0], [294120.0, 5199400.0], [294120.0, 5199000.0] ]] } }, { "type": "Feature", "properties": { "fuel_type": "herb", "fuel_loading": 0.4, "fuel_height": 0.3, "percent_cover": 60, "distribution": "uniform_random" }, "geometry": { "type": "Polygon", "coordinates": [[ [294520.0, 5199000.0], [294760.0, 5199000.0], [294760.0, 5199400.0], [294520.0, 5199400.0], [294520.0, 5199000.0] ]] } } ]}'import requests
url = ( "https://api-v2-prod-nyvjyh5ywa-uw.a.run.app" "/domains/your-domain-id/features/layerset/geojson")
headers = { "accept": "application/json", "api-key": "my-api-key", "Content-Type": "application/json",}
data = { "type": "FeatureCollection", "crs": { "type": "name", "properties": {"name": "urn:ogc:def:crs:EPSG::32612"}, }, "features": [ { "type": "Feature", "properties": { "fuel_type": "shrub", "fuel_loading": 1.2, "fuel_height": 0.6, "percent_cover": 35, "distribution": "homogeneous", }, "geometry": { "type": "Polygon", "coordinates": [[ [294120.0, 5199000.0], [294520.0, 5199000.0], [294520.0, 5199400.0], [294120.0, 5199400.0], [294120.0, 5199000.0] ]], }, }, { "type": "Feature", "properties": { "fuel_type": "herb", "fuel_loading": 0.4, "fuel_height": 0.3, "percent_cover": 60, "distribution": "uniform_random", }, "geometry": { "type": "Polygon", "coordinates": [[ [294520.0, 5199000.0], [294760.0, 5199000.0], [294760.0, 5199400.0], [294520.0, 5199400.0], [294520.0, 5199000.0] ]], }, }, ],}
response = requests.post(url, headers=headers, json=data)response.raise_for_status()feature = response.json()feature_id = feature["id"]{ "id": "your-feature-id", "domain_id": "your-domain-id", "type": "layerset", "name": "", "description": "", "status": "completed", "progress": null, "created_on": "2026-05-19T18:13:58.998841Z", "modified_on": "2026-05-19T18:13:58.998841Z", "source": { "product": "Upload", "description": "User-uploaded layerset" }, "georeference": { "crs": "EPSG:32612", "bounds": [294120.0, 5199000.0, 294760.0, 5199400.0] }, "error": null, "tags": []}Adds resource-level name/description/tags, plus per-feature optional
columns (live_fuel_moisture, dead_fuel_moisture, heat_of_combustion,
patch_std_dev) and a random_clusters distribution that requires
patch_size.
curl -X 'POST' \ 'https://api-v2-prod-nyvjyh5ywa-uw.a.run.app/domains/your-domain-id/features/layerset/geojson' \ -H 'accept: application/json' \ -H 'api-key: my-api-key' \ -H 'Content-Type: application/json' \ -d '{ "type": "FeatureCollection", "name": "Blackfoot surface fuels — custom", "description": "Two-strata surface fuel scenario derived from field plots.", "tags": ["surface-fuels", "blackfoot", "custom"], "crs": { "type": "name", "properties": { "name": "urn:ogc:def:crs:EPSG::32612" } }, "features": [ { "type": "Feature", "properties": { "strata_fb": "Shrub_clumped", "fuel_type": "shrub", "fuel_loading": 1.8, "fuel_height": 0.7, "percent_cover": 25, "distribution": "random_clusters", "patch_size": 6.0, "patch_std_dev": 1.5, "live_fuel_moisture": 90.0, "dead_fuel_moisture": 8.0, "heat_of_combustion": 18608.0 }, "geometry": { "type": "Polygon", "coordinates": [[ [294120.0, 5199000.0], [294520.0, 5199000.0], [294520.0, 5199400.0], [294120.0, 5199400.0], [294120.0, 5199000.0] ]] } }, { "type": "Feature", "properties": { "strata_fb": "Herb_continuous", "fuel_type": "herb", "fuel_loading": 0.5, "fuel_height": 0.3, "percent_cover": 70, "distribution": "uniform_random", "live_fuel_moisture": 110.0, "dead_fuel_moisture": 6.0 }, "geometry": { "type": "Polygon", "coordinates": [[ [294520.0, 5199000.0], [294760.0, 5199000.0], [294760.0, 5199400.0], [294520.0, 5199400.0], [294520.0, 5199000.0] ]] } } ]}'import requests
url = ( "https://api-v2-prod-nyvjyh5ywa-uw.a.run.app" "/domains/your-domain-id/features/layerset/geojson")
headers = { "accept": "application/json", "api-key": "my-api-key", "Content-Type": "application/json",}
data = { "type": "FeatureCollection", "name": "Blackfoot surface fuels — custom", "description": "Two-strata surface fuel scenario derived from field plots.", "tags": ["surface-fuels", "blackfoot", "custom"], "crs": { "type": "name", "properties": {"name": "urn:ogc:def:crs:EPSG::32612"}, }, "features": [ { "type": "Feature", "properties": { "strata_fb": "Shrub_clumped", "fuel_type": "shrub", "fuel_loading": 1.8, "fuel_height": 0.7, "percent_cover": 25, "distribution": "random_clusters", "patch_size": 6.0, "patch_std_dev": 1.5, "live_fuel_moisture": 90.0, "dead_fuel_moisture": 8.0, "heat_of_combustion": 18608.0, }, "geometry": { "type": "Polygon", "coordinates": [[ [294120.0, 5199000.0], [294520.0, 5199000.0], [294520.0, 5199400.0], [294120.0, 5199400.0], [294120.0, 5199000.0] ]], }, }, { "type": "Feature", "properties": { "strata_fb": "Herb_continuous", "fuel_type": "herb", "fuel_loading": 0.5, "fuel_height": 0.3, "percent_cover": 70, "distribution": "uniform_random", "live_fuel_moisture": 110.0, "dead_fuel_moisture": 6.0, }, "geometry": { "type": "Polygon", "coordinates": [[ [294520.0, 5199000.0], [294760.0, 5199000.0], [294760.0, 5199400.0], [294520.0, 5199400.0], [294520.0, 5199000.0] ]], }, }, ],}
response = requests.post(url, headers=headers, json=data)response.raise_for_status()feature = response.json()feature_id = feature["id"]{ "id": "your-feature-id", "domain_id": "your-domain-id", "type": "layerset", "name": "Blackfoot surface fuels — custom", "description": "Two-strata surface fuel scenario derived from field plots.", "status": "completed", "progress": null, "created_on": "2026-05-19T18:14:37.818387Z", "modified_on": "2026-05-19T18:14:37.818387Z", "source": { "product": "Upload", "description": "User-uploaded layerset" }, "georeference": { "crs": "EPSG:32612", "bounds": [294120.0, 5199000.0, 294760.0, 5199400.0] }, "error": null, "tags": ["surface-fuels", "blackfoot", "custom"]}Three fields on the response matter for downstream calls:
id— the new feature’s identifier. Record it: your-feature-id. You’ll use it for any subsequentGET/PATCH/DELETEagainst this layerset.status— already"completed"on the 201. There is no separate polling step.georeference.bounds— the union bounding box across every Feature geometry, expressed in the GeoJSON’s own CRS.
Per-feature properties
Section titled “Per-feature properties”Every Feature in the FeatureCollection must carry a properties block. The
required fields mirror the rasterizer’s input columns:
| Field | Type | Required | Description |
|---|---|---|---|
fuel_type | string | ✅ | Free-form category label (e.g. "shrub", "herb", "litter"). |
fuel_loading | float | ✅ | Fuel loading for the fuelbed. |
fuel_height | float | ✅ | Fuel height for the fuelbed. |
percent_cover | float | ✅ | Fractional cover in percent — must be in [0, 100]. |
distribution | enum | ✅ | One of homogeneous, uniform_random, random_clusters. |
patch_size | float | ⚠️ | Required when distribution == "random_clusters"; ignored otherwise. |
strata_fb | string | optional | Traceability label for your own records (e.g. "Shrub1_52"). |
live_fuel_moisture | float | optional | Live fuel moisture; produces a NaN output band when omitted. |
dead_fuel_moisture | float | optional | Dead fuel moisture; produces a NaN output band when omitted. |
heat_of_combustion | float | optional | Heat of combustion; produces a NaN output band when omitted. |
patch_std_dev | float | optional | Cluster-size spread; only meaningful for random_clusters. |
Geometries must be Polygon or MultiPolygon. Other geometry types
(Point, LineString, GeometryCollection) are rejected by the GeoJSON
validator.
CRS requirements
Section titled “CRS requirements”The server reads CRS from the FeatureCollection’s optional top-level crs
block:
{ "type": "FeatureCollection", "crs": { "type": "name", "properties": { "name": "urn:ogc:def:crs:EPSG::32612" } }, "features": [ ... ]}Both forms are accepted: bare "EPSG:32612" and the URN form
"urn:ogc:def:crs:EPSG::32612" (the form geopandas.to_file emits).
If the crs block is missing, the server falls back to EPSG:4326 per the
GeoJSON spec. Because EPSG:4326 is geographic, that fallback is then
rejected with 422:
{ "detail": "Layerset CRS is geographic (EPSG:4326). Rasterization requires a projected CRS so cell sizes are in meters. Reproject the GeoJSON to a UTM (or other projected) CRS and declare it on the FeatureCollection's `crs` block."}The fix is to reproject the GeoJSON to a projected CRS — typically the UTM
zone covering your domain — and declare it on the crs block before
uploading:
gdf = gdf.to_crs("EPSG:32612")gdf.to_file("layerset.geojson", driver="GeoJSON")Common pitfalls
Section titled “Common pitfalls”The upload endpoint validates synchronously, so most failures surface as
422 on the POST itself with a detail string explaining what went
wrong. The cases worth knowing about:
- Geographic CRS — the
crsblock resolves to a geographic CRS (e.g.EPSG:4326, the GeoJSON default whencrsis omitted). Reproject to a projected CRS and declare it explicitly. Captured response shown above. - Unparseable CRS — the
crs.properties.namestring isn’t recognised bypyproj. Use the bareEPSG:<code>form or the URN formurn:ogc:def:crs:EPSG::<code>. - Missing
patch_sizeforrandom_clusters— a Feature has"distribution": "random_clusters"but nopatch_size. The error surfaces as a pydantic validation error pointing at the offending Feature. Either setpatch_size, or switch the distribution tohomogeneousoruniform_random. - Empty FeatureCollection —
featuresis[]. The validator rejects this because there’s nothing to rasterize. Include at least one Feature. - Wrong geometry type — a Feature carries a
Point,LineString, orGeometryCollection. Convert toPolygon/MultiPolygonbefore uploading. percent_coverout of range — values must be in[0, 100], not[0, 1]. The validator rejects values outside that range.
When a 422 returns, no resource is created — there’s nothing to clean up
and no need to retry against a fresh URL. Fix the body and re-POST.