Upload your own point cloud
You are viewing in-progress documentation for v2 (Beta). Switch to the stable version for the current production release.
POST /domains/{domain_id}/pointclouds/upload brings your own lidar into a
domain — commercial or research data that is not in
3DEP, or a
terrestrial scan, which 3DEP cannot supply at all.
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_point_cloud_from_file creates the resource and PUTs your file for you.
At the raw HTTP level it’s a two-step handshake — create the resource to get
a signed URL, then PUT your file to that URL. Either way the bytes go straight
to storage and never pass through the API. 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: your-domain-id.
-
An LAS, LAZ, or COPC file at /path/to/your.laz, no larger than 1 GiB, carrying a coordinate reference system.
With the Python SDK
Section titled “With the Python SDK”create_point_cloud_from_file folds the create and the PUT into a single
call, sending every required header for you. Declare the acquisition type —
als for an airborne scan, tls for a terrestrial one; LAS, LAZ, and COPC are
detected from the file itself.
import fastfuels_sdk.v2 as ff
ff.set_api_key("my-api-key")
domain = ff.Domain.from_id("your-domain-id")
# create_point_cloud_from_file creates the resource and PUTs your file to the# signed URL in one call, sending every required header. Use "tls" for a# terrestrial scan, "als" for an airborne one. LAS, LAZ, and COPC are detected# from the file itself.point_cloud = ff.point_clouds.create_point_cloud_from_file( domain, "/path/to/your.laz", "tls", name="Plot 14 terrestrial scan", description="Tripod scan of a single plot.", tags=["plot-14"],)Then wait for ingestion. A successful upload is not a successful ingestion —
once the bytes land, the file is validated, reprojected to the domain CRS, and
summarized in the background, and that step can still fail (a file with no CRS
is the common case). point_cloud.wait() blocks until it settles either way:
import fastfuels_sdk.v2 as ff
ff.set_api_key("my-api-key")
point_cloud = ff.get_point_cloud("your-domain-id", "your-point-cloud-id")point_cloud.wait()
if point_cloud.status == "failed": # A file with no CRS fails here, at ingestion — not at upload. error = point_cloud.to_dict()["error"] raise SystemExit(f"Ingestion failed: {error['message']}")The raw HTTP flow (curl)
Section titled “The raw HTTP flow (curl)”The same upload as the raw two-step handshake — 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 resource
Section titled “Step 1 — Create the resource”Declare the acquisition type — als for an airborne scan, tls for a
terrestrial one — plus optional metadata. There is nothing to declare about the
file format: LAS, LAZ, and COPC are detected from the file itself.
curl -X 'POST' \ 'https://api-v2-prod-nyvjyh5ywa-uw.a.run.app/domains/your-domain-id/pointclouds/upload' \ -H 'accept: application/json' \ -H 'api-key: my-api-key' \ -H 'Content-Type: application/json' \ -d '{ "type": "tls", "name": "Plot 14 terrestrial scan", "description": "Tripod scan of a single plot.", "tags": ["plot-14"]}'{ "point_cloud": { "id": "your-point-cloud-id", "domain_id": "your-domain-id", "type": "tls", "name": "Plot 14 terrestrial scan", "description": "Tripod scan of a single plot.", "status": "pending", "progress": null, "created_on": "2026-08-03T19:50:52.256220Z", "modified_on": "2026-08-03T19:50:52.256220Z", "checksum": "d2710a9b8ac043abad3adb536e9f73bd", "source": { "name": "upload", "object_name": "pointclouds/your-point-cloud-id/upload" }, "georeference": null, "summary": null, "error": null, "tags": ["plot-14"] }, "upload": { "method": "PUT", "url": "https://storage.googleapis.com/silvx-fastfuels-uploads-v2/pointclouds/your-point-cloud-id/upload?X-Goog-Algorithm=GOOG4-RSA-SHA256&X-Goog-Credential=...&X-Goog-Date=20260803T195052Z&X-Goog-Expires=3600&X-Goog-SignedHeaders=content-type%3Bhost%3Bx-goog-content-length-range&X-Goog-Signature=...", "headers": { "Content-Type": "application/octet-stream", "x-goog-content-length-range": "0,1073741824" }, "content_type": "application/octet-stream", "expires_at": "2026-08-03T20:50:52.256220Z", "max_size_bytes": 1073741824 }}Record the id: your-point-cloud-id, and the signed URL: paste-signed-url-from-201-response.
Step 2 — PUT the file
Section titled “Step 2 — PUT the file”curl -X 'PUT' \ 'paste-signed-url-from-201-response' \ -H 'Content-Type: application/octet-stream' \ -H 'x-goog-content-length-range: 0,1073741824' \ --data-binary '@/path/to/your.laz' \ -o /dev/null -w 'HTTP %{http_code}\n'The URL expires 60 minutes after it is issued (upload.expires_at), and the
file must not exceed upload.max_size_bytes — 1 GiB. If either bites, create a
new upload and use the fresh URL.
Step 3 — Poll to completed
Section titled “Step 3 — Poll to completed”The upload succeeding is not the same as the ingestion succeeding. Once the bytes land, the file is validated, reprojected to the domain CRS, and summarized in the background.
curl -X 'GET' \ 'https://api-v2-prod-nyvjyh5ywa-uw.a.run.app/domains/your-domain-id/pointclouds/your-point-cloud-id' \ -H 'accept: application/json' \ -H 'api-key: my-api-key'{ "id": "your-point-cloud-id", "domain_id": "your-domain-id", "type": "tls", "name": "Plot 14 terrestrial scan", "description": "Tripod scan of a single plot.", "status": "completed", "progress": { "percent": 100, "message": "Complete" }, "created_on": "2026-08-03T19:50:52.256220Z", "modified_on": "2026-08-03T19:51:13.653143Z", "checksum": "d2710a9b8ac043abad3adb536e9f73bd", "source": { "name": "upload", "object_name": "pointclouds/your-point-cloud-id/upload" }, "georeference": { "crs": "EPSG:32612", "bounds": [ 294200.005, 5199100.005, 1230.013, 294599.997, 5199499.981, 1259.118 ] }, "summary": { "point_count": 60000, "point_classes": [2, 5], "density": 0.3750300019503444 }, "error": null, "tags": ["plot-14"]}A cloud in a different CRS than its domain is reprojected, not rejected — reprojecting points is an exact per-point transform, so there is nothing to lose by doing it. Only horizontal coordinates are transformed; elevations are stored exactly as your file provided them and are never converted between vertical reference surfaces.
When ingestion fails
Section titled “When ingestion fails”A file without a CRS is the common case, and it fails after a perfectly
successful upload — on either path. The SDK surfaces it as point_cloud.status == "failed" (the wait snippet above raises on it); over raw HTTP it appears on
the polled resource:
{ "id": "your-point-cloud-id", "domain_id": "your-domain-id", "type": "als", "name": "Scan with no CRS", "description": "", "status": "failed", "progress": { "percent": 100, "message": "Failed" }, "created_on": "2026-08-03T19:51:16.253376Z", "modified_on": "2026-08-03T19:51:18.051700Z", "checksum": "2b0bea9318fc4a4dba360bbdf764c0d7", "source": { "name": "upload", "object_name": "pointclouds/your-point-cloud-id/upload" }, "georeference": null, "summary": null, "error": { "code": "MISSING_CRS", "message": "The point cloud has no coordinate reference system. Assign a CRS before uploading.", "suggestion": "Set the CRS in your processing software, e.g. `pdal translate in.laz out.laz --writers.las.a_srs=EPSG:<code>`." }, "tags": []}The failure appears as status: "failed" with an error on the resource — not
as a bad PUT. The error carries a code, a message, and a suggestion; here
the suggestion names the pdal translate invocation that assigns one.
Next steps
Section titled “Next steps”- Inspect the point cloud — confirm the density and classification before building on it.
- Build a CHM grid from it — airborne clouds only.
- Manage your point clouds.
Common pitfalls
Section titled “Common pitfalls”- Dropping
x-goog-content-length-range. A 400 from storage, and a point cloud that never leavespending. (Raw HTTP path only — the SDK sends it.) - Assuming a 200 on the
PUTmeans you are done. Ingestion runs afterwards and can still fail. Poll the resource — orwait()on it. - Uploading a file with no CRS. The most common ingest failure. Set it before
uploading — the error’s
suggestionfield tells you how. - Letting the URL expire. 60 minutes from issue. For a slow link on a large file, create the upload immediately before transferring. (The SDK handles the URL for you.)
- Uploading a terrestrial scan expecting a CHM. It will store, and then stop.
- Expecting to edit the points later. Content is immutable.
PATCHchanges onlyname,description, andtags, and deliberately never moves thechecksum.