Set Up Bundle Uploads

How to implement adaptive direct and resumable uploads for spatial bundles and bundle archives.

Bundle uploads use the same adaptive upload-start model as document uploads. The API returns either mode: "direct" for a bounded small single-file upload or mode: "session" for large, multipart, many-file, or explicitly resumable uploads.

Bundles and their upload tickets or sessions must be owned by the organization that owns their location. User-owned bundle or upload state is unsupported and blocks startup/readiness for investigation; the service does not reassign it.

Authorization and capacity

Bundle permissions are location-wide. List, metadata, file, download, ZIP export, and archive-status operations require final LocationBundles=Read for the containing visible location. Create, upload, import, and delete operations require final LocationBundles=Write. Individual bundles do not have ACLs or assignment endpoints; a bundle ID resolves the permission result of its location.

The organization entitlement version caps that result. PAT-authenticated requests are additionally narrowed by the token's organization and capability scope, and every transfer/finalization request keeps that credential identity.

Before a positive-byte upload is issued, the server atomically reserves the organization's effective Bundle-storage pool together with the bundle placeholder and durable upload state. Regular file finalization is queued durably, rechecks current Write access, reuses the metadata reconciled during file completion, and commits resolved bytes with the bundle attachment. Archive completion keeps the reservation active while extraction runs. The background finalizer revalidates the current requester or PAT, rechecks LocationBundles=Write and Bundle capacity, then attaches the extracted file set and commits its exact expanded object bytes in one transaction. The source archive is staging data and is deleted rather than billed as another durable Bundle object. Cancellation, expiry, denial, and pre-commit cleanup release the reservation idempotently. A storage reduction below current use blocks new positive-byte additions but does not remove existing data or independently block an authorized read or deletion.

Capture, texture, reference, render-settings, and model artifact kinds are classified as Bundle storage. If a legacy or mixed bundle contains a Document-classified artifact, its metadata/download and a full bundle ZIP also require the artifact's final path-aware Document read permission. Generated bundle ZIPs are rebuildable, non-billable derived caches, and the API checks current permissions before returning a download URL.

Bundle ZIP exports

Admit an export of every artifact with POST, then poll its status with GET:

POST /v1/spatial/bundles/{bundleId}/zip
GET /v1/spatial/bundles/{bundleId}/zip/status

Use the POST flow when the client already has an exact artifact selection, for example the source photographs matched to one model point:

POST /v1/spatial/bundles/{bundleId}/zip-operations
Content-Type: application/json
{
  "artifactIds": [
    "11111111-1111-1111-1111-111111111111",
    "22222222-2222-2222-2222-222222222222"
  ]
}

The selected-artifact request is content-addressed. Repeat the same POST while the response status is Queued or Preparing; the API returns the prepared download URL with Ready. Every identifier must belong to the addressed bundle. Empty selections, empty GUIDs, and selections above 10,000 distinct artifacts are rejected.

Regular per-file bundles:

POST /v1/spatial/locations/{locationId}/bundles/upload-start
POST /v1/spatial/locations/{locationId}/bundles/upload-complete

Archive imports:

GET  /v1/spatial/bundles/archive-upload-support
POST /v1/spatial/locations/{locationId}/bundles/archive-upload-start
POST /v1/spatial/locations/{locationId}/bundles/archive-upload-complete
GET  /v1/spatial/bundles/archive-imports/{taskId}

Use regular per-file bundle uploads when the client needs each file to remain distinct during transfer. Prefer archive uploads for huge file counts because one archive session avoids creating and refreshing URLs for thousands of individual objects.

Regular Bundle Start

{
  "bundleType": "Model",
  "detailLevel": "Full",
  "displayName": "Drone Capture March",
  "capturedAt": "2026-03-12T08:30:00Z",
  "preferResumable": true,
  "files": [
    {
      "fileName": "metadata.json",
      "contentType": "application/json",
      "sizeBytes": 2048,
      "relativePath": ""
    },
    {
      "fileName": "tileset.bin",
      "contentType": "application/octet-stream",
      "sizeBytes": 171798691840,
      "relativePath": "tiles"
    }
  ]
}

capturedAt follows the OpenAPI date-time format, which is the RFC 3339 profile of ISO 8601. Send a timezone designator with the value; prefer UTC Z, for example 2026-03-12T08:30:00Z. The API stores and returns capturedAt in UTC.

For regular multi-file bundle uploads, entries declared with sizeBytes: 0 are omitted instead of failing the entire batch. The successful upload-start response returns one warning per omitted entry so clients can name the affected files:

{
  "warnings": [
    {
      "code": "zero_byte_file_skipped",
      "message": "File was skipped because its declared size is 0 bytes.",
      "fileName": "DJI_0042.JPG",
      "relativePath": "photos"
    }
  ]
}

The upload target or session contains only the remaining positive-byte files, and Bundle capacity is reserved only for those files. A batch containing no non-empty files is rejected. Negative sizes remain invalid.

The logical bundle layout combines each upload request's relativePath directory with fileName. Pipelines and ZIP downloads receive that layout: a root file is mounted as fileName, and a file with relativePath: "tiles" is mounted as tiles/fileName. Metadata and download responses return the complete logical path in relativePath, including the file name. Resolve nested model assets by this complete path; a bare file name may be ambiguous. Storage keys are an implementation detail and can differ between processing attempts.

If upload-start returns mode: "direct", upload to direct.uploadUrl and complete the scoped route:

{
  "directUploadId": "11111111-1111-1111-1111-111111111111",
  "completionToken": "server-issued-token"
}

The completion response includes result: { kind: "bundle", bundleId } when finalization succeeds. Upload admission returns HTTP 201 with mode, operationId, statusUrl, and exactly one of direct or session. Poll the returned status URL; a created transfer is not yet a published resource.

Session Transfer

If upload-start returns mode: "session", inspect the returned session files and request short-lived URLs only when a worker is ready to transfer each file or part.

Only the user who created the session can inspect, transfer, complete, or cancel it. The API also re-checks current bundle write access on every session request.

GET  /v1/upload-sessions/{sessionId}
POST /v1/upload-sessions/{sessionId}/files/{fileId}/upload-url
POST /v1/upload-sessions/{sessionId}/files/{fileId}/parts/{partNumber}/upload-url
POST /v1/upload-sessions/{sessionId}/files/{fileId}/parts/{partNumber}/complete
POST /v1/upload-sessions/{sessionId}/files/{fileId}/complete
POST /v1/upload-sessions/{sessionId}/files/complete-batch
POST /v1/upload-sessions/{sessionId}/complete
POST /v1/upload-sessions/{sessionId}/cancel

For many small files, use bounded batch refresh:

POST /v1/upload-sessions/{sessionId}/upload-urls/batch

Batch requests must contain at least one item and must stay within the configured maximum.

{
  "items": [
    { "fileId": "33333333-3333-3333-3333-333333333333" },
    { "fileId": "44444444-4444-4444-4444-444444444444" }
  ]
}

Batch refresh is intentionally bounded. Request URLs for the next small transfer window, not for the entire upload queue.

Multipart files include partSizeBytes, expectedPartCount, and completed parts in the session status response. After each object-storage part upload succeeds, report the ETag with the expected byte range. Repeating the same part and ETag is idempotent; reporting a different ETag for an already-completed part is rejected.

File completion is idempotent after the file reaches uploaded or completed. If a response is lost, repeat the file-complete request to receive the persisted state without completing the object-storage upload again. Multipart retries also reconcile an exact-size stored object when object storage completed the upload before the API could persist the result.

URL refresh and part reporting are accepted only while the session and file are pending or uploading. Expired sessions are marked expired at request time, and files that are already uploaded or terminal no longer receive fresh upload URLs.

After each bounded transfer window, send its file ids to POST /v1/upload-sessions/{sessionId}/files/complete-batch. The API reconciles object storage with bounded concurrency and returns per-item success or error; successful progress remains persisted when another item fails.

Session completion actively reconciles any remaining stored files in bounded chunks. It does not wait for transfers or multipart parts that are still incomplete, so finish those and retry when final completion reports an incomplete session.

For regular organization bundle files, the first successful completion call atomically acquires the finalization fence and queues a durable BackgroundJobs task with the requester authorization snapshot. It normally returns finalizing immediately. BackgroundJobs rechecks current Write access and Bundle capacity, reuses the exact size and content type already reconciled during file completion, batch-applies all files, and atomically commits the reservation and session state. It does not issue another object metadata request for every prepared file.

Session completion returns HTTP 202 and a Location while work is active. Poll GET /v1/upload-sessions/{sessionId}/operation; this reads the persisted receipt without renewing the lease or enqueueing another task. The completion/read wrapper contains session and operation; operation.status is finalizing until terminal and operation.result is null until publication. A completed archive transfer returns { kind: "bundle-archive", bundleId, archiveImportTaskId }; its extraction task continues separately. Regular bundle files return { kind: "bundle", bundleId }.

If a completion response is lost, read the operation before deciding whether to retry the command. Completion is idempotent. Use GET /v1/upload-sessions/{sessionId} for detailed per-file resume state. Entering finalization extends expiresAtUtc with a bounded server-managed lease; progress GETs do not renew it. A finalizing session cannot be canceled.

Archive Uploads

Archive upload-start uses one archive file and can also return direct or session mode:

{
  "bundleType": "Upload",
  "displayName": "Drone Capture March",
  "capturedAt": "2026-03-12T08:30:00Z",
  "archiveFileName": "drone-run-001.7z",
  "contentType": "application/x-7z-compressed",
  "sizeBytes": 1876543210,
  "stripSingleRootFolder": true,
  "preferResumable": true
}

When an archive direct upload completes, call archive-upload-complete with the direct upload id and completion token. When an archive session completes, call POST /v1/upload-sessions/{sessionId}/complete.

Both completion paths enqueue extraction and return:

{
  "succeeded": true,
  "status": "completed",
  "bundleId": "22222222-2222-2222-2222-222222222222",
  "archiveImportTaskId": "33333333-3333-3333-3333-333333333333"
}

Poll the archive import task until terminal:

GET /v1/spatial/bundles/archive-imports/{taskId}

The completion response means the source archive was accepted for background processing; it does not commit the compressed source size as final usage. The reservation remains active until the background finalizer has the exact expanded file set. If current Write access, PAT validity, or final Bundle capacity no longer permits the import, finalization returns the stable AuthorizationChanged or CapacityChanged task status, publishes no files, quarantines staged objects for bounded cleanup, and releases the reservation. A fresh archive upload retries the full set with a new reservation.

The support endpoint is the source of truth for archive formats and limits. Current server support includes .zip, .7z, .tar, .tar.gz, and .tgz. Encrypted archives, symlinks, absolute paths, path traversal, duplicate normalized targets, and archives that exceed configured limits are rejected.

Background extraction downloads the source archive to local temporary storage before unpacking it. The background-jobs host therefore needs enough free disk space for the archive itself plus extraction overhead.

Cancellation And Cleanup

Cancel unfinished direct uploads with:

POST /v1/uploads/direct/cancel

Cancel unfinished sessions with:

POST /v1/upload-sessions/{sessionId}/cancel

Canceled and expired upload state is cleaned by BackgroundJobs. Cleanup aborts incomplete multipart uploads and deletes uploaded objects that were not finalized into bundle records or archive extraction tasks.

Active session transfers use a sliding idle lease. Each accepted upload URL request, completed-part report, and file-completion request atomically moves a pending session to uploading and extends its expiresAtUtc. The renewed lease covers every presigned URL returned by the request, preventing cleanup from reclaiming a slow but active upload. Progress reads do not extend the lease, and expired, finalizing, or terminal sessions cannot be renewed.

On this page