Appearance
Processing API
Automate your LiDAR pipeline end-to-end. The Processing API lets you upload files, trigger processing, and download results programmatically — ideal for batch workflows, integration with your own tools, or building LiDAR processing into your product.
The API is available exclusively on the Advanced plan ($249/month). See plans.
Not a developer? You can skip this page entirely. Everything the API does is also available through the web interface.
Overview
The API is a standard REST API that communicates using JSON. You interact with it by sending HTTP requests (GET, POST, PUT, DELETE) to specific URLs.
Base URL: https://cloud-pilot.lidarvisor.com/api
All endpoint paths below are relative to this base URL (so POST /v1/projects means POST https://cloud-pilot.lidarvisor.com/api/v1/projects).
Authentication
Every API request must include your API key in the request header:
x-api-key: lvk_your_api_key_hereAPI keys are prefixed with lvk_ and are generated by the Lidarvisor team. Contact support or your account administrator to obtain one.
Important: Your API key grants full access to your account's processing capabilities. Keep it secret — do not share it in public code repositories, client-side code, or unsecured locations.
Workflow
The typical API workflow follows five steps:
1. Get your file in → creates a project
(direct upload, chunked resumable upload, or pull ingest from your bucket)
2. Wait for metadata extraction to complete
3. Start processing with your chosen options
4. Wait for processing to finish (poll, or receive webhooks)
5. Get the results out
(download links, or push delivery straight to your bucket)There are three ways in:
| Method | Best for | How |
|---|---|---|
| Direct upload | Files your HTTP connection can send in one request | POST /v1/projects as multipart/form-data |
| Chunked resumable upload | Large files (roughly 1 GB and up), unreliable connections | POST /v1/projects as JSON with fileName + fileSize, then PUT chunks |
| Pull ingest | Files already in your S3 / GCS / Azure bucket | POST /v1/projects as JSON with a source block — Lidarvisor fetches the file itself |
And two ways out:
| Method | How |
|---|---|
| Download links | GET /v1/projects/{id}/results then GET .../download for temporary URLs |
| Push delivery | Pass a deliveries array to POST /v1/projects/{id}/process — Lidarvisor PUTs each output straight to signed URLs on your own storage |
Endpoints
1. Create a Project
POST /v1/projects
Creates a new project. The same endpoint accepts three request shapes — the mode is detected automatically from the body; there is no mode field.
Two fields are always required:
name— a name for the project (must be unique in your account)epsgCode— the EPSG code of the file's coordinate reference system (e.g. 2154 for Lambert-93, 32632 for UTM zone 32N). You can find this in your LAS file's metadata or your survey equipment settings.
Optionally, callbackUrl registers a webhook URL for lifecycle events (see Webhooks).
Mode A — Direct upload (multipart)
Send the LAS/LAZ file in the file field of a multipart/form-data request. The file streams straight to cloud storage as it is received.
bash
curl -X POST https://cloud-pilot.lidarvisor.com/api/v1/projects \
-H "x-api-key: lvk_your_api_key_here" \
-F "file=@survey.laz" \
-F "name=Site Survey March 2026" \
-F "epsgCode=2154"Response (201):
json
{
"projectId": 42,
"status": "extracting_metadata",
"message": "File uploaded. Metadata extraction in progress. Poll GET /api/v1/projects/{id}/status until status is 'ready_to_process', then call POST /api/v1/projects/{id}/process."
}Only .las and .laz files are accepted. For very large files, make sure your HTTP client has no request timeout of its own (in Python requests, use timeout=None) — or use Mode B, which is immune to connection drops.
Mode B — Chunked resumable upload (JSON)
Recommended for large files or whenever you want to survive a dropped connection. Send a flat JSON body with the file's name and size; the response contains an upload session with one pre-signed PUT URL per chunk. You upload the chunks directly to cloud storage, then compose them.
bash
curl -X POST https://cloud-pilot.lidarvisor.com/api/v1/projects \
-H "x-api-key: lvk_your_api_key_here" \
-H "Content-Type: application/json" \
-d '{
"name": "Site Survey March 2026",
"epsgCode": 2154,
"fileName": "survey.laz",
"fileSize": 8589934592
}'Request fields:
fileName— must end in.lasor.lazfileSize— in bytes; subject to your plan's per-file limits (Advanced: 50 GB.las/ 10 GB.laz)chunkSize(optional) — bytes per chunk. Default 64 MB; allowed range 16 MB to 256 MB, and must be a multiple of 256 KB. A file may be split into at most 2048 chunks — for very large files, raisechunkSizeaccordingly (the error message tells you the minimum).callbackUrl(optional) — webhook formetadata.completed/metadata.failed(fires after compose)
Response (201):
json
{
"projectId": 42,
"status": "awaiting_upload",
"session": {
"sessionId": "9bd5e3a4-d0c4-4f08-bd99-2f0f6e2c1234",
"chunkSize": 67108864,
"chunkCount": 128,
"chunkUrls": {
"0": "https://storage.googleapis.com/...",
"1": "https://storage.googleapis.com/..."
},
"expiresAt": "2026-06-13T18:00:00.000Z"
},
"message": "Upload your 128 chunks to the given URLs, then call POST /api/v1/projects/42/upload-session/compose."
}The session (and its chunk URLs) is valid for 48 hours. Then, for each chunk:
PUTthe chunk's bytes to its URL (plain binary body, no API key needed — the URL is pre-signed). The last chunk may be smaller thanchunkSize.POST /v1/projects/{id}/upload-session/chunks/{index}/completeto record progress (indices are 0-based).
When every chunk is uploaded, finalize:
POST /v1/projects/{id}/upload-session/compose
The server verifies every chunk, assembles the final file, and starts metadata extraction. Returns { "projectId": 42, "status": "extracting_metadata", "message": "..." }. The call is idempotent on success: re-calling on an already-composed session returns 200 again; a concurrent compose returns 409.
Resuming after a crash:
GET /v1/projects/{id}/upload-session
json
{
"sessionId": "9bd5e3a4-d0c4-4f08-bd99-2f0f6e2c1234",
"uploadedChunks": [0, 1, 2, 5, 7],
"bytesUploaded": 335544320,
"chunkCount": 128,
"chunkSize": 67108864,
"status": "active",
"expiresAt": "2026-06-13T18:00:00.000Z"
}Re-PUT only the missing chunks, then compose. Session status is one of active, composing, completed, failed, cancelled.
Cancelling:
DELETE /v1/projects/{id}/upload-session
Cancels an in-flight session and deletes any uploaded chunks. The project itself is not deleted (use DELETE /v1/projects/{id} for that). Returns 409 if the session is already completed, failed, or cancelled.
Mode C — Pull ingest (Lidarvisor fetches from your URL)
If the file already lives in your own bucket, don't upload it at all — hand over a signed GET URL and Lidarvisor pulls the file directly.
bash
curl -X POST https://cloud-pilot.lidarvisor.com/api/v1/projects \
-H "x-api-key: lvk_your_api_key_here" \
-H "Content-Type: application/json" \
-d '{
"name": "Site Survey March 2026",
"epsgCode": 2154,
"source": {
"type": "signedUrl",
"url": "https://your-bucket.s3.eu-west-3.amazonaws.com/survey.laz?X-Amz-Signature=...",
"expectedSize": 8589934592,
"fileName": "survey.laz"
},
"callbackUrl": "https://your-server.com/webhooks/lidarvisor"
}'source fields:
type— must be"signedUrl"(this is what routes the request to pull mode)url— HTTPS only. The host must resolve to a public IP address (private, loopback, and link-local ranges are rejected). HTTP redirects are not followed — pass a URL that serves the file directly.expectedSize— size in bytes. Checked against your plan's per-file limit before any byte is fetched, and against the actual transfer (within 1% tolerance) — a mismatch fails the ingest withsize_mismatch.expectedSha256(optional) — hex SHA-256 of the file content, verified at end of stream (sha_mismatchon failure)fileName(optional) — display name ending in.las/.laz; derived from the URL path if omitted
Response (201):
json
{
"projectId": 42,
"status": "fetching",
"message": "Pulling your file from the source URL. Poll GET /api/v1/projects/42/status until status is 'ready_to_process'."
}The call returns immediately; the fetch runs in the background. Track it via the status endpoint (a fetch step with progress) or the ingest.completed / ingest.failed webhooks.
Cancel an in-flight ingest:
DELETE /v1/projects/{id}/ingest
Best-effort cancellation; the partial file is deleted. Returns 409 if the ingest already finished (delete the project instead).
Retry a failed ingest with a fresh URL:
POST /v1/projects/{id}/retry-ingest
json
{
"url": "https://your-bucket.s3.eu-west-3.amazonaws.com/survey.laz?X-Amz-Signature=NEW...",
"expectedSize": 8589934592
}Optional fields: expectedSha256, callbackUrl (overrides the one given at create time). Returns 409 if an ingest is already running or has already succeeded.
2. Check Project Status
GET /v1/projects/{id}/status
Poll this endpoint to track upload, ingest, metadata extraction, and processing.
Response:
json
{
"projectId": 42,
"name": "Site Survey March 2026",
"status": "processing",
"progress": 65,
"steps": [
{ "name": "metadata", "status": "completed", "progress": 100 },
{ "name": "classification", "status": "in_progress", "progress": 65 }
]
}Possible status values:
| Status | Meaning | Next action |
|---|---|---|
awaiting_upload | Chunked upload session open, not all chunks received | Keep PUTting chunks, then compose |
fetching | Pull ingest in progress | Wait (the fetch step shows byte progress) |
fetch_failed | Pull ingest failed | Check the fetch step's error; POST /retry-ingest with a fresh URL |
uploaded | File received, metadata extraction starting | Wait |
extracting_metadata | Reading file properties (area, point count, bounds) | Wait |
ready_to_process | Ready — you can start processing | POST /process |
processing | Processing in progress (minutes to hours) | Wait |
completed | Results are ready | GET /results |
failed | Something went wrong | Check the steps array for details |
Each entry in steps (upload, fetch, metadata, classification) has a status of pending, in_progress, completed, or failed, an optional progress (0-100), and an error message when failed.
3. Start Processing
POST /v1/projects/{id}/process
Start processing with your chosen options. The project must be ready_to_process.
Request body:
json
{
"preset": "topographic_survey",
"callbackUrl": "https://your-server.com/webhook"
}Presets select a predefined set of outputs:
| Preset | Best for | What you get |
|---|---|---|
classification_only | A first look at a new dataset | Classified, colorized point cloud — nothing built on top |
topographic_survey | Survey plans | DTM, DSM, contours, grid, breaklines, buildings, bridges, roads, rails, cadastre, water body + shoreline, power lines + towers, vegetation areas, orthophoto, printable topographic map |
forestry_inventory | Forest inventory | DTM, DSM, CHM, tree tops, tree crowns, vegetation areas, forest inventory report, carbon estimation |
powerline_inspection | Corridor compliance | DTM, DSM, CHM, power lines, towers, buffer zones, clearance and right-of-way corridors, tree fall risk, tree tops + crowns, vegetation encroachment report |
site_infrastructure | Built environment | DTM, DSM, major contours, grid, buildings (urban), bridges, roads (urban), rails, cadastre, power lines + towers, orthophoto, topographic map |
The historical presets topography, forestry, powerline, and full are still accepted and keep producing exactly what they always did, so existing integrations are unaffected. New integrations should use the presets above.
Individual options can be passed instead of, or on top of, a preset — options override preset defaults:
json
{
"preset": "topographic_survey",
"options": {
"resolutionDTM": 100,
"createContours": true,
"contoursMajor": 10,
"contoursMinor": 2,
"detectBuildings": true,
"buildingsSimplification": "URBAN"
}
}See Processing Your Data for the full list of available options.
Push delivery — to have the outputs PUT straight to your own storage, add a deliveries array (see Push Delivery below). When deliveries is present, an Idempotency-Key header is required.
Response (200):
json
{
"projectId": 42,
"status": "processing",
"classificationTaskId": "abc-123-def-456"
}4. List Results
GET /v1/projects/{id}/results
Returns all generated result files once processing is complete.
Response:
json
{
"results": [
{
"assetId": 171,
"type": "CLASSIFIED_POINT_CLOUD",
"fileName": "classified_survey.laz",
"fileSize": 524288000,
"status": "READY",
"layerType": "POINT_CLOUD"
},
{
"assetId": 172,
"type": "DTM",
"fileName": "survey_dtm_50cm.tif",
"fileSize": 12582912,
"status": "READY",
"layerType": "RASTER"
}
]
}Common type values:
| Type | Format | Description |
|---|---|---|
CLASSIFIED_POINT_CLOUD | LAS/LAZ | Point cloud with each point labeled |
DTM / DSM / CHM / SLOPE_MAP / TIN | GeoTIFF | Elevation and analysis rasters |
POWER_LINE / TOWER | GeoJSON / Shapefile / DXF | Power line cables and pylons |
CONTOUR_LINE_MAJOR / CONTOUR_LINE_MINOR | GeoJSON / Shapefile / DXF | Elevation contour lines |
BUILDINGS / BRIDGES / ROADS | GeoJSON / Shapefile / DXF | Built-environment footprints and vectors |
WATER_BODY / SHORELINE | GeoJSON / Shapefile / DXF | Water polygons and shorelines |
CADASTRE | GeoJSON / Shapefile / DXF | Cadastral parcels (where available) |
TREE_TOPS / TREE_CROWNS / VEGETATION_AREAS | GeoJSON / Shapefile / DXF | Individual trees and generalized vegetation |
BREAKLINES_MAIN / BREAKLINES_DETAILED / BREAKLINES_EXHAUSTIVE | GeoJSON / Shapefile / DXF | Terrain breaklines by density tier |
GRID | GeoJSON / Shapefile / DXF | Regular grid with statistics |
TOPOGRAPHIC_MAP | PDF / DXF | Printable topographic map |
FOREST_INVENTORY | Forest statistics report |
A vector asset with status: "EMPTY" ran successfully but found no features (for example, zero towers in the area) — there is no file to download for it.
5. Download a Result
GET /v1/projects/{id}/results/{assetId}/download?format=geojson
Returns a temporary download URL, valid for 12 hours. No API key is needed to fetch the URL itself.
Query parameters:
format(optional, vector assets) —geojson(default),shapefile(a .zip with .shp/.shx/.dbf/.prj), ordxf. Ignored for rasters, point clouds, and PDFs, with one exception: on aTOPOGRAPHIC_MAPyou may passpdfto receive the printable sheet instead of the DXF drawing.
Response:
json
{
"downloadUrl": "https://storage.googleapis.com/...",
"fileName": "survey_contours_major.geojson",
"format": "geojson",
"fileSize": 2097152,
"expiresIn": 43200
}6. List All Projects
GET /v1/projects?limit=20&offset=0
Returns a paginated list of your projects:
json
{
"projects": [
{
"projectId": 42,
"name": "Site Survey March 2026",
"status": "READY",
"createdAt": "2026-03-28T10:00:00.000Z",
"updatedAt": "2026-03-28T12:30:00.000Z"
}
],
"total": 15
}7. Delete a Project
DELETE /v1/projects/{id}
Permanently deletes a project and all its data. Returns { "success": true }. This cannot be undone.
Push Delivery
Instead of downloading results one by one, you can have Lidarvisor PUT each output directly to your own storage the moment processing completes. Push delivery is in addition to normal storage — the results also stay in your Lidarvisor account for download.
Add a deliveries array (up to 50 entries) to the /process call. Each entry names one output (by its asset token), a format, and a pre-signed HTTPS PUT URL on your bucket:
bash
curl -X POST https://cloud-pilot.lidarvisor.com/api/v1/projects/42/process \
-H "x-api-key: lvk_your_api_key_here" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: 8a7c2e10-1234-4abc-9999-deadbeef0001" \
-d '{
"preset": "topographic_survey",
"callbackUrl": "https://your-server.com/webhooks/lidarvisor",
"deliveries": [
{
"asset": "classified_pointcloud",
"format": "laz",
"url": "https://your-bucket.s3.eu-west-3.amazonaws.com/inbox/scan-classified.laz?X-Amz-Signature=..."
},
{
"asset": "dtm",
"format": "tif",
"url": "https://your-bucket.s3.eu-west-3.amazonaws.com/inbox/dtm.tif?X-Amz-Signature=..."
}
]
}'Rules, all checked before processing starts (invalid requests fail fast with a 400 and no credits are consumed):
- Each
(asset, format)pair may appear only once. - The format must be valid for the asset (see the token table below).
- The asset must actually be produced by the preset/options you chose — otherwise the call is rejected with a message containing
asset_not_produced_by_options. - URLs must be HTTPS and pass the same public-host validation as pull ingest.
- Sign your URLs to remain valid for at least 6 hours after the
/processcall — processing can take a while. If they expire anyway, use the refresh endpoint below. - An
Idempotency-Keyheader is mandatory (see Idempotency).
What the PUT looks like on your side. Deliveries run one at a time. Each PUT sends exactly two headers, so you can pre-sign without knowing anything about the payload:
Content-Length— the exact object sizeContent-Type— a MIME hint derived from the format (application/vnd.las+lazforlaz,application/geo+jsonforgeojson,image/tifffortif,application/zipforshapefile,application/dxffordxf,application/pdfforpdf)
No Content-MD5 header is sent. If you need end-to-end integrity checks, use your provider's checksum scheme on the signing side.
A single delivery is capped at 5 GB (the single-PUT limit) — larger outputs fail with entity_too_large; use the download endpoint for those.
Every delivery fires a webhook (delivery.completed / delivery.failed), followed by one aggregate event (deliveries.completed or deliveries.partial). See Webhooks.
Retrying deliveries with fresh URLs
POST /v1/projects/{id}/deliveries/refresh
If URLs expired or a transient error occurred, list just the entries to retry, with new URLs:
json
{
"deliveries": [
{ "asset": "dtm", "format": "tif", "url": "https://your-bucket...?X-Amz-Signature=NEW..." }
]
}Only the listed (asset, format) pairs are touched (1 to 50 per call). Entries that already succeeded are refused with 409; entries currently in flight must finish first. Returns 404 if the project has no deliveries at all.
Delivery asset tokens
The asset field uses stable, snake_case tokens (independent of the result type names above):
| Token | Delivers | Formats | Produced when |
|---|---|---|---|
classified_pointcloud | Classified point cloud | laz | Always |
dtm | Digital Terrain Model | tif | createDTM |
dsm | Digital Surface Model | tif | createDSM |
chm | Canopy Height Model | tif | createCHM |
slope_map | Slope analysis raster | tif | createSlopeMap |
tin | Triangulated Irregular Network | tif | createTIN |
powerlines_vector | Power line cables | geojson, shapefile, dxf | detectPowerLines |
towers_vector | Towers / pylons | geojson, shapefile, dxf | detectTowers |
contours_major | Major contour lines | geojson, shapefile, dxf | createContours |
contours_minor | Minor contour lines | geojson, shapefile, dxf | createContours |
grid | Regular grid with statistics | geojson, shapefile, dxf | createGrid |
buildings | Building footprints | geojson, shapefile, dxf | detectBuildings |
bridges | Bridge slab footprints | geojson, shapefile, dxf | detectBridges (on by default; some presets turn it off) |
roads | Road vectors | geojson, shapefile, dxf | detectRoads |
water_body | Water body polygons | geojson, shapefile, dxf | detectWaterBody |
shoreline | Shoreline lines | geojson, shapefile, dxf | detectShoreline |
cadastre | Cadastral parcels | geojson, shapefile, dxf | fetchCadastre (reference data — may be empty outside covered areas) |
tree_tops | Individual tree locations | geojson, shapefile, dxf | detectTreeTops |
tree_crowns | Tree crown polygons | geojson, shapefile, dxf | detectTreeCrowns |
vegetation_areas | Generalized vegetation groups | geojson, shapefile, dxf | vegetationAreas |
breaklines_main | Main terrain breaklines | geojson, shapefile, dxf | breaklinesMain |
breaklines_detailed | Detailed breaklines | geojson, shapefile, dxf | breaklinesDetailed |
breaklines_exhaustive | Exhaustive breaklines | geojson, shapefile, dxf | breaklinesExhaustive |
topographic_map | Printable topographic map | pdf, dxf | createTopographicMap |
forest_inventory_report | Forest statistics report | pdf | forestInventory |
"Produced when" names the individual option; the presets in the table above set these for you.
Idempotency
Network retries can accidentally repeat a POST — and repeating /process would start (and bill) a second run. To make any mutating call safely retryable, send an Idempotency-Key header:
Idempotency-Key: 8a7c2e10-1234-4abc-9999-deadbeef0001- Use a fresh, unique value per logical operation — a UUID v4 is recommended (maximum 64 characters).
- Replaying the same key with the same body within 24 hours returns the stored original response without re-executing anything.
- Replaying the same key with a different body returns 409 — the key is bound to its first payload.
Supported (optional) on: POST /v1/projects (JSON modes), POST .../upload-session/compose, POST .../retry-ingest, POST .../deliveries/refresh, and POST .../process.
Mandatory on POST /v1/projects/{id}/process when the body contains deliveries — without the header the call is rejected with a 400 whose message is idempotency_key_required. This prevents a transparent network retry from double-pushing your outputs.
Not supported on multipart uploads (Mode A) — the file body cannot be fingerprinted; the header is ignored there.
Webhooks
Instead of polling, provide a callbackUrl when creating a project or starting processing. Lidarvisor sends HTTP POST requests (JSON body) to your URL at each lifecycle event.
Every webhook request carries these headers:
X-Lidarvisor-Event— the event name (e.g.processing.completed)X-Lidarvisor-Delivery— a unique ID for this webhook attemptX-Lidarvisor-Signature— present only when signing is enabled on your API key (see below)
Each webhook is attempted up to 3 times (retrying on network errors and 502-and-above responses) with a short backoff; your endpoint must respond within 10 seconds — respond 200 quickly and process asynchronously.
Upload / ingest events
json
// metadata.completed — file analyzed, ready to process (all modes)
{ "event": "metadata.completed", "projectId": 42, "status": "ready_to_process" }
// metadata.failed
{ "event": "metadata.failed", "projectId": 42, "status": "failed", "steps": [ ... ] }
// ingest.completed — pull mode only: your file was fetched successfully
{ "event": "ingest.completed", "projectId": 42, "status": "extracting_metadata",
"bytesFetched": 8589934592, "sha256": "9f2c..." }
// ingest.failed — pull mode only
{ "event": "ingest.failed", "projectId": 42, "status": "fetch_failed",
"error": "Request failed with status code 404", "errorCode": "http_404", "retryable": false }ingest.failed error codes:
| errorCode | Meaning | Retryable |
|---|---|---|
invalid_url, scheme_not_allowed | Malformed URL, or not HTTPS | no |
host_blocked, ip_blocked, host_not_in_allowlist, ssrf_blocked | The host is not a public host, or is outside your key's allowed hosts | no |
dns_lookup_failed | The host name did not resolve | no |
http_4xx (e.g. http_403, http_404) | Your storage rejected the GET — usually an expired signature | no* |
http_5xx | Transient error on your storage side | yes |
size_mismatch | Transferred size differs from expectedSize by more than 1% | no |
sha_mismatch | Content hash differs from expectedSha256 | no |
fetch_failed | Network error mid-transfer | yes |
pilot_restart_loop | The fetch was interrupted repeatedly by restarts on our side | yes |
Retryable means a retry with the same inputs may succeed. For any failure, POST /retry-ingest with a fresh signed URL is always available — that is the fix for expired signatures (http_403) too.
Processing events
json
// processing.completed — with the same results array as GET /results
{ "event": "processing.completed", "projectId": 42, "status": "completed", "results": [ ... ] }
// processing.failed
{ "event": "processing.failed", "projectId": 42, "status": "failed", "steps": [ ... ] }Delivery events (push delivery only)
One event per delivery entry, then one aggregate event:
json
// One output landed in your bucket
{ "event": "delivery.completed", "projectId": 42,
"asset": "classified_pointcloud", "format": "laz", "bytesSent": 5234567890 }
// One output could not be delivered
{ "event": "delivery.failed", "projectId": 42, "asset": "buildings", "format": "geojson",
"error": "Asset BUILDINGS was produced but contains no features (EMPTY).",
"errorCode": "asset_empty_no_features", "retryable": false }
// Aggregate: everything succeeded
{ "event": "deliveries.completed", "projectId": 42, "count": 4 }
// Aggregate: at least one failed
{ "event": "deliveries.partial", "projectId": 42,
"succeeded": [ { "asset": "dtm", "format": "tif" } ],
"failed": [ { "asset": "buildings", "format": "geojson", "errorCode": "asset_empty_no_features" } ] }delivery.failed error codes:
| errorCode | Meaning | Retryable |
|---|---|---|
asset_unavailable | The asset was not produced by the preset/options of this run. Re-run /process with different options. | no |
asset_empty_no_features | The pipeline ran but found no features for this asset (e.g. zero towers in the area). Nothing to deliver — safe to ignore. | no |
asset_type_invalid | Internal inconsistency — contact support. | no |
entity_too_large | The output exceeds the 5 GB single-PUT cap. Use the download endpoint for this asset. | no |
ssrf_blocked, host_not_in_allowlist | The PUT URL targets a blocked host or one outside your key's allowed hosts. | no |
http_4xx (e.g. http_403) | Your bucket rejected the PUT — usually an expired signature or ACL issue. Refresh via /deliveries/refresh. | no |
http_5xx, put_failed, put_stream_error, timeout | Transient network or upstream error. Not retried automatically — call /deliveries/refresh. | yes |
When the failure came from an actual PUT attempt, the payload also carries two diagnostic fields:
json
{
"event": "delivery.failed",
"projectId": 42,
"asset": "classified_pointcloud",
"format": "laz",
"error": "Request failed with status code 403",
"errorCode": "http_403",
"retryable": false,
"upstreamResponseBody": "<?xml version=\"1.0\"?><Error><Code>SignatureDoesNotMatch</Code>...</Error>",
"debug": {
"method": "PUT",
"host": "your-bucket.s3.eu-west-3.amazonaws.com",
"path": "/inbox/scan-classified.laz",
"headersSent": {
"Content-Length": "5234567890",
"Content-Type": "application/vnd.las+laz"
}
}
}upstreamResponseBody— the first ~2 KB of your storage provider's response, verbatim (absent when there was no response at all).debug— the exact method, host, path, and headers sent, so you can reproduce the request and check your signing policy. The URL's query string (your signature) is deliberately never included.
Webhook signatures
Webhook signing can be enabled on your API key (contact support to enable it and receive your signing secret). When enabled, every webhook carries:
X-Lidarvisor-Signature: t=1718290800,v1=5257a869e7...v1 is the hex HMAC-SHA256 of the string "<t>.<raw request body>" using your signing secret. To verify (Node.js):
js
const crypto = require("crypto");
function verify(header, rawBody, secret) {
const parts = Object.fromEntries(header.split(",").map((p) => p.split("=")));
const expected = crypto
.createHmac("sha256", secret)
.update(`${parts.t}.${rawBody}`)
.digest("hex");
return crypto.timingSafeEqual(Buffer.from(expected), Buffer.from(parts.v1));
}Verify against the raw request body bytes, before any JSON parsing. Reject stale timestamps (e.g. older than 5 minutes) to prevent replays.
Example: Python Script
A complete run using direct upload:
python
import requests
import time
API_KEY = "lvk_your_api_key_here"
BASE_URL = "https://cloud-pilot.lidarvisor.com/api"
HEADERS = {"x-api-key": API_KEY}
# Step 1: Upload
with open("survey.laz", "rb") as f:
response = requests.post(
f"{BASE_URL}/v1/projects",
headers=HEADERS,
files={"file": f},
data={"name": "My Survey Site", "epsgCode": "2154"},
timeout=None, # large uploads: disable the client-side timeout
)
project_id = response.json()["projectId"]
print(f"Project created: {project_id}")
# Step 2: Wait for metadata extraction
while True:
status = requests.get(
f"{BASE_URL}/v1/projects/{project_id}/status",
headers=HEADERS
).json()
if status["status"] == "ready_to_process":
print("Ready to process")
break
elif status["status"] == "failed":
print("Metadata extraction failed:", status["steps"])
exit(1)
time.sleep(10)
# Step 3: Start processing
requests.post(
f"{BASE_URL}/v1/projects/{project_id}/process",
headers=HEADERS,
json={"preset": "topographic_survey"}
)
print("Processing started")
# Step 4: Wait for processing
while True:
status = requests.get(
f"{BASE_URL}/v1/projects/{project_id}/status",
headers=HEADERS
).json()
if status["status"] == "completed":
print("Processing complete!")
break
elif status["status"] == "failed":
print("Processing failed:", status["steps"])
exit(1)
print(f"Progress: {status.get('progress', '?')}%")
time.sleep(30)
# Step 5: Download results
results = requests.get(
f"{BASE_URL}/v1/projects/{project_id}/results",
headers=HEADERS
).json()
for result in results["results"]:
if result["status"] == "EMPTY":
continue # produced no features — nothing to download
dl = requests.get(
f"{BASE_URL}/v1/projects/{project_id}/results/{result['assetId']}/download",
headers=HEADERS
).json()
with requests.get(dl["downloadUrl"], stream=True) as r:
with open(dl["fileName"], "wb") as f:
for chunk in r.iter_content(chunk_size=8192):
f.write(chunk)
print(f"Downloaded: {dl['fileName']}")For a fully hands-off pipeline, replace Step 1 with pull ingest (Mode C), add deliveries to Step 3, and drop Steps 2, 4, and 5 in favor of webhooks.
Rate Limiting
The API allows 100 requests per minute per API key. If you exceed the limit, you receive a 429 Too Many Requests response — wait and retry after a short delay. Chunk PUTs in a resumable upload go directly to cloud storage and do not count against this limit.
Error Handling
All errors return a JSON response with a message:
json
{
"statusCode": 400,
"message": "Unsupported file type: .zip. Only .las and .laz files are accepted."
}Common error codes:
400— Bad request (invalid parameters, missing required Idempotency-Key, asset not produced by your options)401— Unauthorized (missing or invalid API key)403— Forbidden (file exceeds your plan's limits, or insufficient credits)404— Not found (project or asset does not exist)409— Conflict (Idempotency-Key reused with a different body; compose/cancel/refresh on something already finished)429— Too many requests (rate limit exceeded)500— Server error (contact support)
Getting Help
- For API issues or questions: contact@lidarvisor.com
- Python and JavaScript SDKs are in development — check lidarvisor.com for updates
Next Step
For common questions, check the FAQ.