Upload a GeoTIFF to create a grid
You are viewing in-progress documentation for v2 (Beta). Switch to the stable version for the current production release.
This guide creates a grid resource from a GeoTIFF you supply. Use it whenever you have a raster — fuels, topography, a land-cover classification, anything that fits a 2D georeferenced image — that you want to attach to a domain so the rest of the FastFuels API can operate on it.
The endpoint is POST /domains/{domain_id}/grids/upload/geotiff and the
workflow is three calls:
- Create the grid — the API returns a
201 Createdand a short-lived signed URL. - Upload the file —
PUTthe GeoTIFF to that signed URL; GCS returns200 OK. - Poll the grid —
GETit untilstatusiscompleted(orfailed).
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. All grids hang off a domain. If you do not have one yet, see the Create a domain how-to guide, then come back here. Plug your domain’s
idin once: your-domain-id. -
A GeoTIFF that meets these constraints:
- Format: single- or multi-band GeoTIFF (
.tif/.tiff). - CRS: must match the domain CRS exactly.
- Extent: must cover the domain bbox. Pixels outside the bbox (or
outside the bbox expanded by
num_buffer_cells × native_pixel_size) are clipped on ingest. - Size: ≤ 1 GB.
- Format: single- or multi-band GeoTIFF (
Step 1 — Create the grid
Section titled “Step 1 — Create the grid”Submit a POST to /domains/{{DOMAIN_ID}}/grids/upload/geotiff. The body
declares the bands of the GeoTIFF you are about to upload: bands[i] maps
1-to-1 to GeoTIFF band i + 1. Each band’s key becomes the variable name
on the resulting grid.
Two shapes cover most uploads:
A single band — categorical (a classification raster, e.g. fuel-model
codes) or continuous (e.g. an elevation surface). unit is optional and
typically omitted for categorical bands.
curl -X 'POST' \ 'https://api-v2-prod-nyvjyh5ywa-uw.a.run.app/domains/your-domain-id/grids/upload/geotiff' \ -H 'accept: application/json' \ -H 'api-key: my-api-key' \ -H 'Content-Type: application/json' \ -d '{ "bands": [ { "key": "fbfm", "type": "categorical" } ], "name": "Custom FBFM40"}'import requests
url = ( "https://api-v2-prod-nyvjyh5ywa-uw.a.run.app" "/domains/your-domain-id/grids/upload/geotiff")
headers = { "accept": "application/json", "api-key": "my-api-key", "Content-Type": "application/json",}
data = { "bands": [ {"key": "fbfm", "type": "categorical"}, ], "name": "Custom FBFM40",}
response = requests.post(url, headers=headers, json=data)response.raise_for_status()created = response.json()
grid_id = created["grid"]["id"]upload_url = created["upload"]["url"]{ "grid": { "id": "your-grid-id", "domain_id": "your-domain-id", "name": "Custom FBFM40", "description": "", "status": "pending", "progress": null, "created_on": "2026-05-19T15:44:23.296555Z", "modified_on": "2026-05-19T15:44:23.296555Z", "source": { "name": "upload", "format": "geotiff", "object_name": "grids/your-grid-id/upload.tif", "bands": [{ "key": "fbfm", "type": "categorical", "unit": null }], "num_buffer_cells": 0 }, "modifications": [], "bands": [ { "key": "fbfm", "type": "categorical", "unit": null, "index": 0 } ], "georeference": null, "error": null, "chunks": null, "tags": [] }, "upload": { "method": "PUT", "url": "https://storage.googleapis.com/silvx-fastfuels-uploads-v2/grids/your-grid-id/upload.tif?X-Goog-Algorithm=GOOG4-RSA-SHA256&X-Goog-Credential=...&X-Goog-Date=20260519T154423Z&X-Goog-Expires=3600&X-Goog-SignedHeaders=content-type%3Bhost%3Bx-goog-content-length-range&X-Goog-Signature=...", "content_type": "image/tiff", "expires_at": "2026-05-19T16:44:23.296555Z", "max_size_bytes": 1073741824 }}Multiple bands. Order in bands must match GeoTIFF band order. Each band
carries its own key, type, and unit — different bands can have
different types and units in the same upload.
curl -X 'POST' \ 'https://api-v2-prod-nyvjyh5ywa-uw.a.run.app/domains/your-domain-id/grids/upload/geotiff' \ -H 'accept: application/json' \ -H 'api-key: my-api-key' \ -H 'Content-Type: application/json' \ -d '{ "bands": [ { "key": "bulk_density.foliage", "type": "continuous", "unit": "kg/m**3" }, { "key": "bulk_density.branchwood", "type": "continuous", "unit": "kg/m**3" } ], "name": "Custom bulk density", "tags": ["lidar"]}'import requests
url = ( "https://api-v2-prod-nyvjyh5ywa-uw.a.run.app" "/domains/your-domain-id/grids/upload/geotiff")
headers = { "accept": "application/json", "api-key": "my-api-key", "Content-Type": "application/json",}
data = { "bands": [ {"key": "bulk_density.foliage", "type": "continuous", "unit": "kg/m**3"}, {"key": "bulk_density.branchwood", "type": "continuous", "unit": "kg/m**3"}, ], "name": "Custom bulk density", "tags": ["lidar"],}
response = requests.post(url, headers=headers, json=data)response.raise_for_status()created = response.json()
grid_id = created["grid"]["id"]upload_url = created["upload"]["url"]{ "grid": { "id": "your-grid-id", "domain_id": "your-domain-id", "name": "Custom bulk density", "description": "", "status": "pending", "progress": null, "created_on": "2026-05-19T15:56:49.954218Z", "modified_on": "2026-05-19T15:56:49.954218Z", "source": { "name": "upload", "format": "geotiff", "object_name": "grids/your-grid-id/upload.tif", "bands": [ { "key": "bulk_density.foliage", "type": "continuous", "unit": "kg/m**3" }, { "key": "bulk_density.branchwood", "type": "continuous", "unit": "kg/m**3" } ], "num_buffer_cells": 0 }, "modifications": [], "bands": [ { "key": "bulk_density.foliage", "type": "continuous", "unit": "kg/m**3", "index": 0 }, { "key": "bulk_density.branchwood", "type": "continuous", "unit": "kg/m**3", "index": 1 } ], "georeference": null, "error": null, "chunks": null, "tags": ["lidar"] }, "upload": { "method": "PUT", "url": "https://storage.googleapis.com/silvx-fastfuels-uploads-v2/grids/your-grid-id/upload.tif?X-Goog-Algorithm=GOOG4-RSA-SHA256&X-Goog-Credential=...&X-Goog-Date=20260519T155650Z&X-Goog-Expires=3600&X-Goog-SignedHeaders=content-type%3Bhost%3Bx-goog-content-length-range&X-Goog-Signature=...", "content_type": "image/tiff", "expires_at": "2026-05-19T16:56:49.954218Z", "max_size_bytes": 1073741824 }}Four fields on the response matter for the next steps:
grid.id— record this; you’ll need it to poll status.upload.url— the signed URL youPUTyour GeoTIFF to. Single use, valid for one hour.upload.content_type— must be sent as theContent-Typeheader on thePUT. Alwaysimage/tiff.upload.max_size_bytes— must be echoed asx-goog-content-length-range: 0,<max_size_bytes>on thePUT. GCS rejects the request with400 Bad Requestif this header is missing or doesn’t match.
Step 2 — Upload the GeoTIFF
Section titled “Step 2 — Upload the GeoTIFF”PUT the file directly to the signed URL. The request does not go through
the FastFuels API — it goes to Google Cloud Storage. Send no api-key header.
Two request headers are required and both must match the values returned in
step 1:
Content-Type: image/tiff— fromupload.content_type.x-goog-content-length-range: 0,<max_size_bytes>— fromupload.max_size_bytes.
curl -X 'PUT' \ 'paste-signed-url-from-201-response' \ -H 'Content-Type: image/tiff' \ -H 'x-goog-content-length-range: 0,1073741824' \ --data-binary '@/path/to/your.tif' \ -o /dev/null -w 'HTTP %{http_code}\n'import requests
upload_url = "paste-signed-url-from-201-response"geotiff_path = "/path/to/your.tif"max_size_bytes = 1073741824 # value from `upload.max_size_bytes` in the 201 response
with open(geotiff_path, "rb") as f: response = requests.put( upload_url, data=f, headers={ "Content-Type": "image/tiff", "x-goog-content-length-range": f"0,{max_size_bytes}", }, )
response.raise_for_status()print(response.status_code)A successful upload returns an empty body with HTTP 200 OK. The uploader
service is then triggered automatically.
Step 3 — Poll the grid until it is completed
Section titled “Step 3 — Poll the grid until it is completed”The uploader opens the GeoTIFF, validates the CRS against the domain, clips to
the domain extent, and writes the result into the grid’s storage. While that
runs, GET the grid to watch its status.
curl -X 'GET' \ 'https://api-v2-prod-nyvjyh5ywa-uw.a.run.app/domains/your-domain-id/grids/your-grid-id' \ -H 'accept: application/json' \ -H 'api-key: my-api-key'import timeimport requests
url = ( "https://api-v2-prod-nyvjyh5ywa-uw.a.run.app" "/domains/your-domain-id/grids/your-grid-id")
headers = { "accept": "application/json", "api-key": "my-api-key",}
while True: grid = requests.get(url, headers=headers).json() status = grid["status"] print(status) if status in ("completed", "failed"): break time.sleep(5)
if grid["status"] == "failed": raise RuntimeError(grid["error"])Status walks pending → running → completed (or → failed). Only the last two
are terminal — keep polling on the first two.
{ "id": "your-grid-id", "domain_id": "your-domain-id", "name": "Custom FBFM40", "description": "", "status": "pending", "progress": null, "created_on": "2026-05-19T16:32:41.567669Z", "modified_on": "2026-05-19T16:32:41.567669Z", "source": { "bands": [{ "key": "fbfm", "unit": null, "type": "categorical" }], "num_buffer_cells": 0, "format": "geotiff", "name": "upload", "object_name": "grids/your-grid-id/upload.tif" }, "modifications": [], "bands": [{ "key": "fbfm", "type": "categorical", "unit": null, "index": 0 }], "georeference": null, "error": null, "chunks": null, "tags": []}Keep polling. The status sits at pending until the uploader picks up the
file, then moves to running while it validates CRS, clips to the domain,
and writes the result. Typical end-to-end time is a few seconds for small
rasters, up to a minute or two for files near the 1 GB ceiling.
{ "id": "your-grid-id", "domain_id": "your-domain-id", "name": "Custom FBFM40", "description": "", "status": "completed", "progress": { "percent": 100, "message": "Complete" }, "created_on": "2026-05-19T16:32:41.567669Z", "modified_on": "2026-05-19T16:33:06.344580Z", "source": { "object_name": "grids/your-grid-id/upload.tif", "format": "geotiff", "name": "upload", "num_buffer_cells": 0, "bands": [{ "key": "fbfm", "type": "categorical", "unit": null }] }, "modifications": [], "bands": [{ "key": "fbfm", "type": "categorical", "unit": null, "index": 0 }], "georeference": { "crs": "EPSG:32611", "transform": [10.0, 0.0, 721055.0, 0.0, -10.0, 5190300.0], "shape": [30, 46] }, "error": null, "chunks": { "shape": [512, 512], "count": 1, "count_by_axis": { "y": 1, "x": 1 } }, "tags": []}The georeference block is now populated (CRS, affine transform, shape in
pixels) and chunks describes the on-disk layout. The grid is ready to
use as input to any downstream operation that accepts a grid.
{ "id": "your-grid-id", "domain_id": "your-domain-id", "name": "Custom FBFM40", "description": "", "status": "failed", "progress": { "percent": 100, "message": "Failed" }, "created_on": "2026-05-19T16:32:42.279428Z", "modified_on": "2026-05-19T16:33:03.723334Z", "source": { "object_name": "grids/your-grid-id/upload.tif", "format": "geotiff", "name": "upload", "num_buffer_cells": 0, "bands": [{ "key": "fbfm", "type": "categorical", "unit": null }] }, "modifications": [], "bands": [{ "key": "fbfm", "type": "categorical", "unit": null, "index": 0 }], "georeference": null, "error": { "code": "CRS_MISMATCH", "message": "GeoTIFF CRS (EPSG:4326) does not match the domain CRS (EPSG:32611). Reproject your file before uploading.", "suggestion": "Use gdalwarp -t_srs EPSG:32611 input.tif output.tif to reproject." }, "chunks": null, "tags": []}The error.message describes what went wrong; error.suggestion gives
you the next step. Fix the GeoTIFF and start over from step 1 — the grid
resource itself cannot be retried in place.
Sizing the GeoTIFF to your domain
Section titled “Sizing the GeoTIFF to your domain”By default, the uploader keeps exactly the pixels that fall inside the domain bbox. If your raster’s extent is smaller than the domain bbox, the upload will fail validation; if it is larger, the extra pixels are clipped away silently.
Two knobs control how forgiving this is:
num_buffer_cellsin the create request (default0). Setting this to e.g.2keeps two native-resolution cells of padding around the domain on each side, which is useful when downstream operations (resampling, focal filters) are edge-sensitive.- The domain extent itself. If you want FastFuels to ingest a region larger than your domain, expand the domain — domains define what “in scope” means for every grid hanging off them.
The expression that must hold is:
GeoTIFF extent ⊇ domain bbox expanded by (num_buffer_cells × native_pixel_size)Band reference
Section titled “Band reference”Each entry in the bands array describes one raster band:
| Field | Required | Description |
|---|---|---|
key | yes | Dot-notation variable name (e.g. elevation, bulk_density.foliage). Becomes the variable name on the grid. Must be unique within the upload. |
type | yes | "continuous" for numeric measurements, "categorical" for codes/IDs. |
unit | optional | UDUNITS-2 canonical ASCII with ** for exponents (e.g. kg/m**3, 1/m, %, m). Omit for categorical bands; required for continuous bands where the value carries a physical unit. |
Common pitfalls
Section titled “Common pitfalls”CRS_MISMATCHafter step 2 succeeds. ThePUTreturns200even if the uploaded file’s CRS does not match the domain. The mismatch surfaces in step 3 asstatus: "failed". Reproject withgdalwarp -t_srs <domain_crs>and re-create the upload.- Duplicate
keyin the create request. Rejected with422 Unprocessable Entity. Each band key must be unique within a single upload. - Wrong band order.
bands[i]is GeoTIFF bandi + 1. Re-check withgdalinfo your.tifbefore submitting. - Treating the upload URL as reusable. It is signed for a single
PUTwithin one hour. If the file changes, request a fresh URL.