Downloading Scan Files
This guide explains how to download processed scan outputs from the RealityConnect API: point clouds, meshes, and photosphere images. The workflow uses the Client Credentials flow and the read:hierarchy scope.
Prerequisites
Section titled “Prerequisites”- An OAuth application configured for Client Credentials
- Scopes:
read:basic,read:hierarchy - Your
client_idandclient_secret - Content access to the site that holds the bundles you want to download
read:twin, read:asset, and download:scan are not required for scan file download — the file bytes are authorized by the signed CDN URL, not by an OAuth scope.
How scan data is organized
Section titled “How scan data is organized”Processed scan data lives in bundles attached to a site. Each bundle contains one or more components, where each component is a different output format:
| Component type | Format | Description |
|---|---|---|
OgcPointCloudHlod | OGC 3D Tiles (open standard) | Point cloud data. tileset.json entry point with .glb tile files |
RealityMeshHlod | Prevu3D HLOD | Mesh data. hlod_tree.json entry point with .pvt tile files |
RealityPhotosphere | JPEG images | Photosphere data. stations.json manifest with .jpeg images |
End-to-end flow
Section titled “End-to-end flow”flowchart LR A["1. Authenticate"] --> B["2. Find a site"] B --> C["3. List bundles"] C --> D["4. Get bundle details"] D --> E["5. Download entry files"] E --> F["6. Follow file references"]
| Step | Endpoint | What you get |
|---|---|---|
| Authenticate | POST /oauth/token | Access token |
| Resolve API URL | GET /oauth/api-info | Regional apiUrl and organization ID |
| Find site | GET /v1/nodes/{id}/browse | Site ID from the content hierarchy |
| List bundles | GET /v1/site/{siteId}/bundles | Bundle IDs for the site |
| Get components | GET /v1/site/{siteId}/bundles/{bundleId} | signedLink and roots per component |
| Download files | GET on the constructed CDN URL | Entry files, tiles, and images |
Step 1 — Authenticate
Section titled “Step 1 — Authenticate”Obtain an access token with Client Credentials:
POST https://cloud-api.prevu3d.com/oauth/tokenAuthorization: Basic base64(client_id:client_secret)Content-Type: application/x-www-form-urlencoded
grant_type=client_credentialsResolve your regional API URL:
GET https://cloud-api.prevu3d.com/oauth/api-infoAuthorization: Bearer {access_token}The response contains apiUrl (for example https://api-ue1.prevu3d.com/realityconnect-api). Use this as the base URL for all subsequent API calls.
Step 2 — Find a site
Section titled “Step 2 — Find a site”Browse the node hierarchy starting from your organization:
GET {api_url}/v1/nodes/{organizationId}/browseAuthorization: Bearer {access_token}Traverse the tree recursively until you find a node with type: "Site". Note its id.
Step 3 — List bundles
Section titled “Step 3 — List bundles”GET {api_url}/v1/site/{siteId}/bundlesAuthorization: Bearer {access_token}Returns a paginated list of bundles for that site.
Step 4 — Get bundle details
Section titled “Step 4 — Get bundle details”GET {api_url}/v1/site/{siteId}/bundles/{bundleId}Authorization: Bearer {access_token}Example response:
{ "id": "f75de673-...", "name": "My Bundle", "components": [ { "id": "abc123", "type": "OgcPointCloudHlod", "signedLink": "https://cdn.prevu3d.com/.../OgcPointCloudHlod/*?Policy=...&Signature=...&Key-Pair-Id=...", "roots": [ "sessions/0f4a60cb09a8/tileset.json", "sessions/2a5ed597dc0f/tileset.json" ] }, { "id": "def456", "type": "RealityMeshHlod", "signedLink": "https://cdn.prevu3d.com/.../RealityMeshHlod/*?Policy=...&Signature=...&Key-Pair-Id=...", "roots": ["hlod_tree.json"] }, { "id": "ghi789", "type": "RealityPhotosphere", "signedLink": "https://cdn.prevu3d.com/.../RealityPhotosphere/*?Policy=...&Signature=...&Key-Pair-Id=...", "roots": ["stations.json"] } ]}Each component exposes two fields you need for downloading:
signedLink: a CDN signed URL ending in/*. The*is a placeholder. The signature grants read access to all files under this prefix, not just a single file.roots: an array of relative paths to the entry-point file(s) for this component.
Step 5 — Download files
Section titled “Step 5 — Download files”Construct a downloadable URL by replacing the * in signedLink with a file path:
download_url = component["signedLink"].replace("*", root)response = requests.get(download_url)The query parameters (Policy, Signature, Key-Pair-Id) stay attached and authenticate the request. Download file bytes directly from the CDN with no Authorization header on the GET.
Component-specific details
Section titled “Component-specific details”OgcPointCloudHlod (point clouds)
Section titled “OgcPointCloudHlod (point clouds)”Entry point: each value in roots is a path to a tileset.json file (one per scan session).
for root in component["roots"]: url = component["signedLink"].replace("*", root) tileset = requests.get(url).json()Tile traversal: tileset.json follows the OGC 3D Tiles specification. It contains a tree of tile nodes. Each node may have a content field with a uri pointing to a .glb file relative to the tileset directory:
{ "asset": { "version": "1.0" }, "root": { "boundingVolume": { }, "content": { "uri": "cell.glb" }, "children": [ { "content": { "uri": "cell0.glb" }, "children": [ ] } ] }}Resolve tile paths relative to the tileset directory:
session_dir = root.rsplit("/tileset.json", 1)[0]tile_path = f"{session_dir}/{tile_uri}"tile_url = component["signedLink"].replace("*", tile_path)tile_data = requests.get(tile_url).contentTraverse the tree recursively to download all tiles.
RealityMeshHlod (meshes)
Section titled “RealityMeshHlod (meshes)”Entry point: roots contains ["hlod_tree.json"].
url = component["signedLink"].replace("*", "hlod_tree.json")hlod_tree = requests.get(url).json()Tree structure: hlod_tree.json contains an HLOD tree:
{ "environment_offset": [ ], "version": 1, "root": { "center": [1.17, -1.22, 5.63], "size": [50.54, 46.61, 19.63], "model_path": "cell", "density": 6.73, "children": [ { "model_path": "cell0", "children": [ ] } ], "textures": [ { "name": "color", "min_mip": 5, "max_mip": 11, "path": "cell" }, { "name": "ao", "min_mip": 5, "max_mip": 11, "path": "cell_ao" } ] }}Tile file naming: geometry and texture files use the pattern {model_path}_{mip_level}.pvt:
model_path = node["model_path"]mip_level = texture["min_mip"]tile_file = f"{model_path}_{mip_level}.pvt"
tile_url = component["signedLink"].replace("*", tile_file)tile_data = requests.get(tile_url).contentTraverse the children array recursively to discover all model_path values at every level of detail.
RealityPhotosphere (photospheres)
Section titled “RealityPhotosphere (photospheres)”Entry point: roots contains ["stations.json"].
url = component["signedLink"].replace("*", "stations.json")stations = requests.get(url).json()Additional files:
index.tsv: a tab-separated index file alongsidestations.json- Photosphere images follow the pattern
station_{n}/H_{face}_{x}_{y}.jpeg
image_path = "station_1/H_0_0_0.jpeg"image_url = component["signedLink"].replace("*", image_path)image_data = requests.get(image_url).contentParse stations.json to discover all station directories and their associated image files.
Complete example
Section titled “Complete example”This script authenticates, finds a site, fetches the first bundle, downloads entry-point files, and follows one referenced tile or image per component.
import base64import jsonimport osfrom pathlib import Path
import requests
CLIENT_ID = "your-client-id"CLIENT_SECRET = "your-client-secret"CLOUD_API_BASE = "https://cloud-api.prevu3d.com"OUTPUT_DIR = Path("downloads")
credentials = base64.b64encode(f"{CLIENT_ID}:{CLIENT_SECRET}".encode()).decode()token_response = requests.post( f"{CLOUD_API_BASE}/oauth/token", data={"grant_type": "client_credentials"}, headers={"Authorization": f"Basic {credentials}"},)token_response.raise_for_status()access_token = token_response.json()["access_token"]headers = {"Authorization": f"Bearer {access_token}"}
api_info = requests.get(f"{CLOUD_API_BASE}/oauth/api-info", headers=headers).json()api_url = api_info["apiUrl"].rstrip("/")organization_id = api_info["organization"]["id"]
def api_get(path: str) -> dict: response = requests.get(f"{api_url}{path}", headers=headers) response.raise_for_status() return response.json()
def download(url: str, dest: Path) -> bool: response = requests.get(url, timeout=30) if not response.ok: return False dest.parent.mkdir(parents=True, exist_ok=True) dest.write_bytes(response.content) return True
def find_tile_uri(node: dict, depth: int = 0): if depth > 5 or not isinstance(node, dict): return None content = node.get("content", {}) uri = content.get("uri") or content.get("url") if isinstance(uri, str) and not uri.startswith("http"): return uri for value in node.values(): if isinstance(value, dict): found = find_tile_uri(value, depth + 1) if found: return found elif isinstance(value, list): for item in value: found = find_tile_uri(item, depth + 1) if found: return found return None
queue = api_get(f"/v1/nodes/{organization_id}/browse").get("items", [])site_id = Nonevisited = set()
while queue and not site_id: node = queue.pop(0) if node["id"] in visited: continue visited.add(node["id"]) browse = api_get(f"/v1/nodes/{node['id']}/browse") current = browse.get("node", {}) if current.get("type", "").lower() == "site": site_id = current["id"] break queue.extend(browse.get("items", []))
if not site_id: raise RuntimeError("No site found in hierarchy")
bundles = api_get(f"/v1/site/{site_id}/bundles")bundle_id = bundles["items"][0]["id"]bundle = api_get(f"/v1/site/{site_id}/bundles/{bundle_id}")
for component in bundle.get("components", []): comp_type = component.get("type", "unknown") signed_link = component.get("signedLink", "") roots = component.get("roots", [])
if not signed_link or not roots: continue
for root in roots: entry_url = signed_link.replace("*", root) entry_dest = OUTPUT_DIR / comp_type / root.replace("/", os.sep) if not download(entry_url, entry_dest): continue
if root.endswith("tileset.json"): tileset = json.loads(entry_dest.read_bytes()) tile_uri = find_tile_uri(tileset.get("root", tileset)) if tile_uri: session_dir = root.rsplit("/tileset.json", 1)[0] tile_path = f"{session_dir}/{tile_uri}" if session_dir else tile_uri download(signed_link.replace("*", tile_path), OUTPUT_DIR / comp_type / tile_path.replace("/", os.sep))
elif root == "hlod_tree.json": tree = json.loads(entry_dest.read_bytes()) model_path = tree.get("root", {}).get("model_path", "") if model_path: tile_file = f"{model_path}_5.pvt" download(signed_link.replace("*", tile_file), OUTPUT_DIR / comp_type / tile_file)
elif root == "stations.json": download(signed_link.replace("*", "index.tsv"), OUTPUT_DIR / comp_type / "index.tsv") download(signed_link.replace("*", "station_1/H_0_0_0.jpeg"), OUTPUT_DIR / comp_type / "station_1" / "H_0_0_0.jpeg")
breakWhat’s next?
Section titled “What’s next?”- Set up authentication end to end in the Client Credentials Flow guide.
- Upload and process new captures with Data Bundle Workflows.
- Browse every operation in the API reference.