Skip to content

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 waitcreate_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.

  1. An API key: my-api-key.

  2. A domain: your-domain-id.

  3. An LAS, LAZ, or COPC file at /path/to/your.laz, no larger than 1 GiB, carrying a coordinate reference system.

create_point_cloud_from_file folds the create and the PUT into a single call, sending every required header for you. Declare the acquisition typeals for an airborne scan, tls for a terrestrial one; LAS, LAZ, and COPC are detected from the file itself.

Create the resource and upload the file
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:

Wait for ingestion to finish
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 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.

Declare the acquisition typeals 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.

POST pointclouds/upload
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"]
}'

Record the id: your-point-cloud-id, and the signed URL: paste-signed-url-from-201-response.

PUT the file to the signed URL
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.

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.

GET the point cloud
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'

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.

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.

  • Dropping x-goog-content-length-range. A 400 from storage, and a point cloud that never leaves pending. (Raw HTTP path only — the SDK sends it.)
  • Assuming a 200 on the PUT means you are done. Ingestion runs afterwards and can still fail. Poll the resource — or wait() on it.
  • Uploading a file with no CRS. The most common ingest failure. Set it before uploading — the error’s suggestion field 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. PATCH changes only name, description, and tags, and deliberately never moves the checksum.