Skip to content

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_here

API 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:

MethodBest forHow
Direct uploadFiles your HTTP connection can send in one requestPOST /v1/projects as multipart/form-data
Chunked resumable uploadLarge files (roughly 1 GB and up), unreliable connectionsPOST /v1/projects as JSON with fileName + fileSize, then PUT chunks
Pull ingestFiles already in your S3 / GCS / Azure bucketPOST /v1/projects as JSON with a source block — Lidarvisor fetches the file itself

And two ways out:

MethodHow
Download linksGET /v1/projects/{id}/results then GET .../download for temporary URLs
Push deliveryPass 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 .las or .laz
  • fileSize — 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, raise chunkSize accordingly (the error message tells you the minimum).
  • callbackUrl (optional) — webhook for metadata.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:

  1. PUT the chunk's bytes to its URL (plain binary body, no API key needed — the URL is pre-signed). The last chunk may be smaller than chunkSize.
  2. POST /v1/projects/{id}/upload-session/chunks/{index}/complete to 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 with size_mismatch.
  • expectedSha256 (optional) — hex SHA-256 of the file content, verified at end of stream (sha_mismatch on 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:

StatusMeaningNext action
awaiting_uploadChunked upload session open, not all chunks receivedKeep PUTting chunks, then compose
fetchingPull ingest in progressWait (the fetch step shows byte progress)
fetch_failedPull ingest failedCheck the fetch step's error; POST /retry-ingest with a fresh URL
uploadedFile received, metadata extraction startingWait
extracting_metadataReading file properties (area, point count, bounds)Wait
ready_to_processReady — you can start processingPOST /process
processingProcessing in progress (minutes to hours)Wait
completedResults are readyGET /results
failedSomething went wrongCheck 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:

PresetBest forWhat you get
classification_onlyA first look at a new datasetClassified, colorized point cloud — nothing built on top
topographic_surveySurvey plansDTM, DSM, contours, grid, breaklines, buildings, bridges, roads, rails, cadastre, water body + shoreline, power lines + towers, vegetation areas, orthophoto, printable topographic map
forestry_inventoryForest inventoryDTM, DSM, CHM, tree tops, tree crowns, vegetation areas, forest inventory report, carbon estimation
powerline_inspectionCorridor complianceDTM, DSM, CHM, power lines, towers, buffer zones, clearance and right-of-way corridors, tree fall risk, tree tops + crowns, vegetation encroachment report
site_infrastructureBuilt environmentDTM, 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:

TypeFormatDescription
CLASSIFIED_POINT_CLOUDLAS/LAZPoint cloud with each point labeled
DTM / DSM / CHM / SLOPE_MAP / TINGeoTIFFElevation and analysis rasters
POWER_LINE / TOWERGeoJSON / Shapefile / DXFPower line cables and pylons
CONTOUR_LINE_MAJOR / CONTOUR_LINE_MINORGeoJSON / Shapefile / DXFElevation contour lines
BUILDINGS / BRIDGES / ROADSGeoJSON / Shapefile / DXFBuilt-environment footprints and vectors
WATER_BODY / SHORELINEGeoJSON / Shapefile / DXFWater polygons and shorelines
CADASTREGeoJSON / Shapefile / DXFCadastral parcels (where available)
TREE_TOPS / TREE_CROWNS / VEGETATION_AREASGeoJSON / Shapefile / DXFIndividual trees and generalized vegetation
BREAKLINES_MAIN / BREAKLINES_DETAILED / BREAKLINES_EXHAUSTIVEGeoJSON / Shapefile / DXFTerrain breaklines by density tier
GRIDGeoJSON / Shapefile / DXFRegular grid with statistics
TOPOGRAPHIC_MAPPDF / DXFPrintable topographic map
FOREST_INVENTORYPDFForest 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), or dxf. Ignored for rasters, point clouds, and PDFs, with one exception: on a TOPOGRAPHIC_MAP you may pass pdf to 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 /process call — processing can take a while. If they expire anyway, use the refresh endpoint below.
  • An Idempotency-Key header 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 size
  • Content-Type — a MIME hint derived from the format (application/vnd.las+laz for laz, application/geo+json for geojson, image/tiff for tif, application/zip for shapefile, application/dxf for dxf, application/pdf for pdf)

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):

TokenDeliversFormatsProduced when
classified_pointcloudClassified point cloudlazAlways
dtmDigital Terrain ModeltifcreateDTM
dsmDigital Surface ModeltifcreateDSM
chmCanopy Height ModeltifcreateCHM
slope_mapSlope analysis rastertifcreateSlopeMap
tinTriangulated Irregular NetworktifcreateTIN
powerlines_vectorPower line cablesgeojson, shapefile, dxfdetectPowerLines
towers_vectorTowers / pylonsgeojson, shapefile, dxfdetectTowers
contours_majorMajor contour linesgeojson, shapefile, dxfcreateContours
contours_minorMinor contour linesgeojson, shapefile, dxfcreateContours
gridRegular grid with statisticsgeojson, shapefile, dxfcreateGrid
buildingsBuilding footprintsgeojson, shapefile, dxfdetectBuildings
bridgesBridge slab footprintsgeojson, shapefile, dxfdetectBridges (on by default; some presets turn it off)
roadsRoad vectorsgeojson, shapefile, dxfdetectRoads
water_bodyWater body polygonsgeojson, shapefile, dxfdetectWaterBody
shorelineShoreline linesgeojson, shapefile, dxfdetectShoreline
cadastreCadastral parcelsgeojson, shapefile, dxffetchCadastre (reference data — may be empty outside covered areas)
tree_topsIndividual tree locationsgeojson, shapefile, dxfdetectTreeTops
tree_crownsTree crown polygonsgeojson, shapefile, dxfdetectTreeCrowns
vegetation_areasGeneralized vegetation groupsgeojson, shapefile, dxfvegetationAreas
breaklines_mainMain terrain breaklinesgeojson, shapefile, dxfbreaklinesMain
breaklines_detailedDetailed breaklinesgeojson, shapefile, dxfbreaklinesDetailed
breaklines_exhaustiveExhaustive breaklinesgeojson, shapefile, dxfbreaklinesExhaustive
topographic_mapPrintable topographic mappdf, dxfcreateTopographicMap
forest_inventory_reportForest statistics reportpdfforestInventory

"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 attempt
  • X-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:

errorCodeMeaningRetryable
invalid_url, scheme_not_allowedMalformed URL, or not HTTPSno
host_blocked, ip_blocked, host_not_in_allowlist, ssrf_blockedThe host is not a public host, or is outside your key's allowed hostsno
dns_lookup_failedThe host name did not resolveno
http_4xx (e.g. http_403, http_404)Your storage rejected the GET — usually an expired signatureno*
http_5xxTransient error on your storage sideyes
size_mismatchTransferred size differs from expectedSize by more than 1%no
sha_mismatchContent hash differs from expectedSha256no
fetch_failedNetwork error mid-transferyes
pilot_restart_loopThe fetch was interrupted repeatedly by restarts on our sideyes

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:

errorCodeMeaningRetryable
asset_unavailableThe asset was not produced by the preset/options of this run. Re-run /process with different options.no
asset_empty_no_featuresThe 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_invalidInternal inconsistency — contact support.no
entity_too_largeThe output exceeds the 5 GB single-PUT cap. Use the download endpoint for this asset.no
ssrf_blocked, host_not_in_allowlistThe 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, timeoutTransient 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.

Lidarvisor — Process LiDAR in Minutes, Not Hours