Skip to content

Uploading Data to a Data Bundle

This guide explains how to upload the raw input files of a capture to a data bundle through the RealityConnect API.

All upload endpoints require the write:bundle scope; listing sessions requires read:bundle.


A data bundle holds the raw inputs for a single capture: a point cloud, a trajectory, images, per-image metadata, and so on. The exact set of files a bundle accepts depends on the device the bundle was created for.

Files are uploaded in upload sessions. A session is a server-created container for one batch of a bundle’s files. When you open it you declare how many files the batch will contain (expectedFileCount); the session completes automatically once that many files have been finalized.

Each individual file is uploaded with an S3 multipart upload:

  1. Open a session on the bundle, declaring the total number of files you will send.
  2. For each file: initiate the upload, PUT its parts directly to the returned URLs, then finalize it.
  3. When the number of finalized files reaches expectedFileCount, the session is marked completed.
sequenceDiagram
  participant Client as Client
  participant RCAPI as RCAPI
  participant S3 as S3

  Client->>RCAPI: POST upload-sessions
  RCAPI-->>Client: sessionId + requirements

  loop for each file
    Client->>RCAPI: POST initiate file
    RCAPI-->>Client: fileId + presigned part URLs
    loop for each part
      Client->>S3: PUT part
      S3-->>Client: ETag
    end
    Client->>RCAPI: POST finalize
    RCAPI-->>Client: file completed + sessionStatus
  end

The upload requirements (returned when you open a session) tell you exactly which file types the bundle accepts, which are mandatory, and how they depend on each other.

A ZF SLAM bundle is a good example because it accepts four different file types with a cross-type dependency. A complete ZF capture consists of:

FileTypeNotes
scan.laspoint cloudexactly one
…laser_0.txttrajectoryexactly one
panorama/*.jpgpanorama imagesmany, each paired with metadata
panorama/*.jsonper-image metadatamany, one per image

So a full ZF batch is 1 point cloud + 1 trajectory + N images + N metadata files. We will upload all of them in a single session.

All paths are relative to your regional api_url. The bundle is addressed by its own id (bundleId).

MethodPathScopePurpose
POST/v1/bundles/{bundleId}/upload-sessionswrite:bundleOpen a session
GET/v1/bundles/{bundleId}/upload-sessionsread:bundleList sessions (resume / progress)
POST/v1/bundles/{bundleId}/upload-sessions/{sessionId}/fileswrite:bundleInitiate a file upload
GET/v1/bundles/{bundleId}/upload-sessions/{sessionId}/files/{fileId}/refresh/{startIndex}write:bundleReissue expired part URLs
POST/v1/bundles/{bundleId}/upload-sessions/{sessionId}/files/{fileId}/finalizewrite:bundleFinalize a file upload

In order to upload data to a data bundle, you need:

  • a completed OAuth flow with an access_token and your regional api_url
  • an existing data bundle (bundleId)

If you do not have a bundle yet, create one first by following Creating a Data Bundle. That flow fetches the device input tree, picks a leaf dbuPath, and creates the bundle under a parent node.

Declare the total number of files the batch will contain. For a full ZF capture with 13 images that is 1 + 1 + 13 + 13 = 28.

POST {api_url}/v1/bundles/{bundleId}/upload-sessions
Authorization: Bearer {access_token}
Content-Type: application/json
{ "expectedFileCount": 28 }

The response returns the sessionId and the bundle’s requirements:

{
"sessionId": "6ebf0fe5-4428-4b26-a7e4-e3274980123b",
"expectedFileCount": 28,
"requirements": [
{ "type": "pointclouds", "allowedExtensions": ["las", "laz"], "optional": false, "allowMultiples": false, "dependencies": [] },
{ "type": "trajectories", "allowedExtensions": ["txt"], "optional": false, "allowMultiples": false, "dependencies": [] },
{ "type": "pictures", "allowedExtensions": ["jpg", "png", "jpeg"], "optional": true, "allowMultiples": true, "dependencies": ["picturesMetadata"] },
{ "type": "picturesMetadata", "allowedExtensions": ["json"], "optional": true, "allowMultiples": true, "dependencies": ["pictures"] }
]
}

Understanding requirements and cross-dependencies

Section titled “Understanding requirements and cross-dependencies”

Each entry in requirements describes one file type the bundle accepts:

FieldMeaning
typeThe identifier you pass as type when initiating a file upload.
allowedExtensionsFile extensions accepted for this type.
optionalIf false, the bundle cannot be processed without at least one file of this type.
allowMultiplesIf false, only one file of this type may be uploaded.
dependenciesOther types that must also be present whenever this type is present.

For the ZF example:

  • pointclouds and trajectories are mandatory (optional: false) and single (allowMultiples: false). Every ZF bundle needs exactly one of each.
  • pictures and picturesMetadata are optional and accept multiple files.
  • The two image types depend on each other: pictures.dependencies = ["picturesMetadata"] and picturesMetadata.dependencies = ["pictures"].

A dependency means: if a type is present, every type it lists must also be present. Because the ZF image types reference each other, they are all-or-nothing as a pair. You may upload the images and their metadata together, or skip both, but you cannot upload one without the other.

Valid ZF batches:

  • Minimal1 pointcloud + 1 trajectory (no images). expectedFileCount = 2.
  • Full1 pointcloud + 1 trajectory + N pictures + N picturesMetadata. expectedFileCount = 2 + 2N.

Invalid:

  • Images without their metadata (or vice versa) since the cross-dependency is unsatisfied.
  • A batch with no point cloud or no trajectory since a mandatory type is missing.

Before you open a session, count every file you plan to upload and set that total as expectedFileCount. Optional types only count if you include them in the batch. The session completes when that many files are finalized, so the number you declare must match what you actually upload.

Repeat the following three calls for every file in the batch.

Pass the file name, its size in bytes, and its type (one of the requirements types):

POST {api_url}/v1/bundles/{bundleId}/upload-sessions/{sessionId}/files
Authorization: Bearer {access_token}
Content-Type: application/json
{ "fileName": "scan.las", "fileSize": 734003200, "type": "pointclouds" }

The response describes the multipart upload: how many parts to send and one presigned url per part.

{
"fileId": "c8c8cb0b-f476-4211-a3a4-72e4e04b91d5",
"parts": 8,
"urls": [
{ "partNumber": 1, "url": "https://s3…/part-1?…" },
{ "partNumber": 2, "url": "https://s3…/part-2?…" }
]
}

Split the file into parts sequential chunks of ceil(fileSize / parts) bytes and PUT each chunk to its presigned url. No auth header is sent on these requests; the URL is already signed. Keep the ETag response header of every part, you need it to finalize.

import math, requests
def upload_parts(file_path, file_size, urls):
part_size = math.ceil(file_size / len(urls))
parts = []
with open(file_path, "rb") as handle:
for entry in sorted(urls, key=lambda u: u["partNumber"]):
res = requests.put(entry["url"], data=handle.read(part_size))
res.raise_for_status()
parts.append({"partNumber": entry["partNumber"], "etag": res.headers["ETag"]})
return parts

Pass the ETag back exactly as returned, including its surrounding quotes.

Send the collected parts to complete the file. The endpoint returns 200 OK.

POST {api_url}/v1/bundles/{bundleId}/upload-sessions/{sessionId}/files/{fileId}/finalize
Authorization: Bearer {access_token}
Content-Type: application/json
{ "parts": [ { "partNumber": 1, "etag": "\"a1b2…\"" }, { "partNumber": 2, "etag": "\"c3d4…\"" } ] }

The response echoes the file and reports the session status:

{
"fileId": "c8c8cb0b-f476-4211-a3a4-72e4e04b91d5",
"fileName": "scan.las",
"type": "pointclouds",
"size": 734003200,
"status": "completed",
"sessionStatus": "pending"
}

sessionStatus stays pending until the last expected file is finalized, at which point it becomes completed.

Presigned URLs expire. If an upload runs long and a PUT starts returning 403, reissue the URLs from the part you stopped at and continue:

GET {api_url}/v1/bundles/{bundleId}/upload-sessions/{sessionId}/files/{fileId}/refresh/0
Authorization: Bearer {access_token}
{ "startIndex": 0, "urls": [ { "partNumber": 1, "url": "https://s3…/part-1?…" } ] }

The file does not need to be re-initiated; only the part URLs are renewed.

List a bundle’s sessions to check progress or resume an interrupted batch without keeping the sessionId in memory. The list is paginated and sortable, with an optional status filter.

Query parameters: page (default 1), limit (default 20, maximum 20), sortBy (createdAt or status, default createdAt), sortDir (ASC or DESC, default ASC), status (optional filter: pending or completed).

GET {api_url}/v1/bundles/{bundleId}/upload-sessions?page=1&limit=20&sortBy=createdAt&sortDir=DESC
Authorization: Bearer {access_token}
{
"items": [
{
"sessionId": "6ebf0fe5-4428-4b26-a7e4-e3274980123b",
"status": "pending",
"expectedFileCount": 28,
"finalizedFileCount": 14
}
],
"total": 1
}

finalizedFileCount versus expectedFileCount tells you how many files are left. To resume, initiate and finalize only the files that have not completed yet.

Read this carefully as it determines what you are responsible for when using the RealityConnect API to upload data.

  • The upload endpoints record each file under the type you provide and complete the session purely on count: once finalizedFileCount equals expectedFileCount, the session is completed. At upload time, the server does not verify that the type is one of the requirements, that the file extension is allowed, that mandatory types are present, or that cross-dependencies are satisfied.
  • All the rules (mandatory types, allowMultiples, and the dependencies between types) are enforced after the bundle is sent for processing. A batch that ignores the requirements will upload successfully but fail to process.
  • Deep content validation is never performed by the upload API. Whether a .las is a genuine ZF point cloud, or a .json correctly references its image, is only checked at processing.

Treat requirements as the contract. Honor the types, extensions, optionality, multiplicity, and dependencies in your upload batch so the bundle is processable once uploaded. Processing failures caused by an incorrect upload structure are not refundable.

Putting it together for the ZF capture. files is the list you build from your local dataset, each entry pairing a path with its type from requirements.

import math, requests
API_URL = "https://<your-regional-api-url>"
TOKEN = "<access_token>"
BUNDLE_ID = "<bundleId>"
HEADERS = {"Authorization": f"Bearer {TOKEN}"}
# (local path, schema type) for every file in the batch
files = [
("scan.las", "pointclouds"),
("2024-07-scan.laser_laser_0.txt", "trajectories"),
("panorama/2024-07-scan-idx40.jpg", "pictures"),
("panorama/2024-07-scan-idx40.json", "picturesMetadata"),
# … remaining image / metadata pairs …
]
def upload_one(session_id, path, file_type):
size = __import__("os").path.getsize(path)
base = f"{API_URL}/v1/bundles/{BUNDLE_ID}/upload-sessions/{session_id}/files"
initiated = requests.post(
base, headers=HEADERS,
json={"fileName": path.split("/")[-1], "fileSize": size, "type": file_type},
).json()
part_size = math.ceil(size / initiated["parts"])
parts = []
with open(path, "rb") as handle:
for entry in sorted(initiated["urls"], key=lambda u: u["partNumber"]):
res = requests.put(entry["url"], data=handle.read(part_size))
res.raise_for_status()
parts.append({"partNumber": entry["partNumber"], "etag": res.headers["ETag"]})
return requests.post(
f"{base}/{initiated['fileId']}/finalize", headers=HEADERS, json={"parts": parts},
).json()
# 1. Open the session for the exact number of files we will send.
session = requests.post(
f"{API_URL}/v1/bundles/{BUNDLE_ID}/upload-sessions",
headers=HEADERS, json={"expectedFileCount": len(files)},
).json()
# 2. Upload every file under its type.
result = None
for path, file_type in files:
result = upload_one(session["sessionId"], path, file_type)
# 3. The last finalize reports the session as completed.
assert result["sessionStatus"] == "completed"

Once every file is uploaded and the session is completed, continue to Processing and Monitoring to turn the raw inputs into viewable outputs.