HomeGuidesAPI ReferenceChangelogDiscussions
Hire a ReactVision Expert 🛠️Launch Studio 🖥️Sponsor on GitHub ❤️Log In
Guides

Retrieving Scans with the API

List locations and scans, check a scan's status and download its mesh, splat, poses or objects.

Every location and scan in ReactVision Spatial can be read through the Advanced VPS API, so your backend can fetch a place's mesh, splat, camera poses or detected objects and use them in your app or pipeline. This page walks through the read operations, then loads a downloaded mesh in ViroReact. Every operation is in the API reference.


Base URL and headers

https://platform.reactvision.xyz/functions/v1
HeaderWhenValue
x-project-idAlwaysThe project ID.
x-api-keyWith a keyA secret key (rv_secret_) with the vps scope on your backend, or a client key (rv_client_) for the shared-location reads.
AuthorizationWith a user tokenBearer rv_ut_..., minted by your backend. A request that sends a user token and x-api-key is judged by the user token.
x-app-user-idOptional, with a secret keyAct for one app user: see that user's locations and scans and the shared ones, and change only theirs. A client key that sends it gets 403 APP_USER_ID_NOT_ALLOWED.

A successful Advanced VPS response is the documented shape alone, without meta. An error is the usual error object, { "error": { "code", "message" }, "meta": { "request_id" } }. Branch on error.code.

Who sees what

CallerSeesMay change
Client or legacy keyThe project's shared locations and their map, world_mesh and semantic filesNothing
Secret key, without x-app-user-idEvery location and scan of the projectAll of them
Secret key, with x-app-user-id UU's locations and scans, and the shared onesOnly U's
User token of app user UU's locations and scans, and the shared onesOnly U's

A location or scan the request cannot see answers 404 NOT_FOUND, the same as one that does not exist.


The operations

All 15 operations need the vps scope and accept a user token. None counts against the monthly API request quota.

OperationEndpointClient keySecret key or user token
Get the VPS server addressGET /vps/server✅✅
List or search locationsGET /vps/locations✅ shared only✅
Get a locationGET /vps/locations/{location_id}✅ shared only✅
Download a location's fileGET /vps/locations/{location_id}/{kind}map, world_mesh, semantic✅ every kind
List a location's scansGET /vps/locations/{location_id}/scans❌✅
Get a scanGET /vps/scans/{scan_id}❌✅
Download a scan's fileGET /vps/scans/{scan_id}/{kind}❌✅
Create a locationPOST /vps/locations❌✅ (paid plan)
Rename a locationPATCH /vps/locations/{location_id}❌✅
Delete a locationDELETE /vps/locations/{location_id}❌✅
Create a scanPOST /vps/scans❌✅ (paid plan)
Start a scan's uploadPOST /vps/scans/{scan_id}/upload❌✅ (paid plan)
Complete a scan's uploadPOST /vps/scans/{scan_id}/upload/complete❌✅
Delete a scanDELETE /vps/scans/{scan_id}❌✅
Delete an app user's dataDELETE /vps/app-users/{app_user_id}❌✅ (a user token for its own app user)

A client key on an operation it cannot call gets 403 API_KEY_TYPE_NOT_ALLOWED. In an app, uploadScanRecording() makes the create, upload and complete calls for you: see Use Advanced VPS in your app.


1. List your locations

GET /vps/locations returns the locations the caller can see, newest first, each with a summary of its active scan (active_scan: id, kind, assets, quality, geo, map_accepted, created_at, or null).

curl "https://platform.reactvision.xyz/functions/v1/vps/locations?limit=50" \
  -H "x-api-key: $RV_SECRET_KEY" \
  -H "x-project-id: $RV_PROJECT_ID"
{
  "locations": [
    {
      "id": "0b9c...",
      "name": "Front entrance",
      "lat": 51.5072,
      "lon": -0.1276,
      "active_scan_id": "7f3e...",
      "app_user_id": null,
      "created_at": 1762780000000,
      "active_scan": { "id": "7f3e...", "kind": "scan", "assets": ["map", "mesh", "world_mesh"], "map_accepted": true, "...": "..." }
    }
  ],
  "next_cursor": "eyJ..."
}
  • Paging: limit is 1 to 100 (default 50). Pass next_cursor as cursor for the next page; it is null on the last.
  • Near a point: add lat and lon (together) for the placed locations within radius_m (default 200, up to 5,000), nearest first, each with distance_m. limit defaults to 20 there. A lone, blank or out-of-range lat or lon answers 400 INVALID_BODY.
  • lat and lon are null for a place without GPS. app_user_id is null for a shared location. Times are milliseconds since the epoch.

GET /vps/locations/{location_id} returns one location with its full active_scan, or null when it has no map yet.


2. List a location's scans

GET /vps/locations/{location_id}/scans returns every scan of the location, newest first, accepted or not. limit is 1 to 200 (default 50), with the same cursor paging.

curl "https://platform.reactvision.xyz/functions/v1/vps/locations/$LOCATION_ID/scans" \
  -H "x-api-key: $RV_SECRET_KEY" \
  -H "x-project-id: $RV_PROJECT_ID"

The location's active_scan_id is the scan whose map devices localise against: the newest accepted one.


3. Get a scan and check its status

GET /vps/scans/{scan_id} returns one scan. After an upload, poll it every few seconds until status is succeeded or failed.

FieldWhat it tells you
statusawaiting_upload, queued, processing, succeeded or failed. A recording that does not arrive within 24 hours fails.
stageThe latest processing stage.
error, error_codeWhy it failed. Branch on error_code: processing_failed, recording_unreadable, recording_unusable, recording_too_long, recording_too_large, upload_expired, processing_time_limit, plan_ended, merge_input_deleted, merge_replaced, merge_unmatched or merges_off. A failed scan is not billed.
map_accepted, map_rejected_reasonWhether the map passed its self-check (at least 500 points, 70% of check frames localised, median position error 10 cm or less), and why not. Only an accepted map is billed.
assetsWhat was built: map, mesh, world_mesh, splat, poses, semantic, and map_disk for server mode (never downloadable).
outputs, mesh_quality, keep_recordingWhat was asked for.
qualityThe self-check figures (points, localised, position_error_median_m and more) and mesh_confidence (level: good, fair or poor, with reasons).
geoWhere the map sits on Earth: lat, lon, alt of the map origin and r_enu_map, the rows of the rotation from map axes into East-North-Up. null without usable GPS.
kind, merged_scan_idsscan, or merge for a map rebuilt from several scans.
aligned_to_scan_id, alignmentThe active scan this one was aligned to, and how the alignment went.

4. Download a file

Ask for a signed link, then fetch it straight away:

  • GET /vps/locations/{location_id}/{kind}: a file of the location's active scan. Use this to get "the current mesh of this place".
  • GET /vps/scans/{scan_id}/{kind}: a file of one scan, accepted or not. Secret key or user token only.
kindFile
mapmap.rvmap, what ViroReact localises against. The answer also carries the map's quality and geo.
meshThe textured mesh, as binary glTF (.glb). Load it with Viro3DObject and type="GLB".
world_meshThe same mesh, untextured and decimated, which <ViroVPS worldMesh> loads for occlusion and physics.
splatA 3D Gaussian splat.
posesThe camera pose and intrinsics for each frame.
semanticDetected objects, semantic.json.

All of them are in the map's frame, in metres. A client key may download only map, world_mesh and semantic; mesh, splat and poses need a secret key or a user token.

curl "https://platform.reactvision.xyz/functions/v1/vps/locations/$LOCATION_ID/mesh" \
  -H "x-api-key: $RV_SECRET_KEY" \
  -H "x-project-id: $RV_PROJECT_ID"
{
  "kind": "mesh",
  "scan_id": "7f3e...",
  "url": "https://...",
  "expires_at": 1762780900000,
  "bytes": 18734212
}

The url needs no credentials, names one file and expires 15 minutes after it was signed (expires_at). Fetch it right away rather than storing it, and ask for a new link when you need the file again. A location without an active scan, or a scan without that file, answers 404 NO_ASSET.


Example: download a place's mesh with Node

This script runs on your backend with a secret key. It lists the project's locations, asks for each active scan's mesh and saves it as a .glb. It waits out rate limits, checks that the whole file arrived, and skips anything that does not start with glTF, the first four bytes of every binary glTF.

// node fetch-meshes.mjs   (Node 18 or later)
import { writeFile } from "node:fs/promises";

const BASE = "https://platform.reactvision.xyz/functions/v1";
const headers = {
  "x-api-key": process.env.RV_SECRET_KEY, // a secret key with the vps scope
  "x-project-id": process.env.RV_PROJECT_ID,
};

async function api(path) {
  const r = await fetch(BASE + path, { headers });
  if (r.status === 429) {
    const wait = Number(r.headers.get("retry-after") ?? 1);
    await new Promise((res) => setTimeout(res, wait * 1000));
    return api(path);
  }
  const body = await r.json();
  if (!r.ok) throw new Error(`${r.status} ${body.error?.code}: ${body.error?.message}`);
  return body;
}

async function* allLocations() {
  let cursor = null;
  do {
    const page = await api(`/vps/locations?limit=100${cursor ? `&cursor=${encodeURIComponent(cursor)}` : ""}`);
    yield* page.locations;
    cursor = page.next_cursor;
  } while (cursor);
}

for await (const loc of allLocations()) {
  if (!loc.active_scan?.assets?.includes("mesh")) continue;

  const link = await api(`/vps/locations/${loc.id}/mesh`);
  const file = await fetch(link.url); // no credentials: the link is the grant
  if (!file.ok) throw new Error(`download failed: ${file.status}`);
  const bytes = Buffer.from(await file.arrayBuffer());

  if (link.bytes != null && bytes.length !== link.bytes) {
    console.warn(`${loc.name}: got ${bytes.length} of ${link.bytes} bytes, skipping`);
    continue;
  }
  if (bytes.subarray(0, 4).toString("ascii") !== "glTF") {
    console.warn(`${loc.name}: not a binary glTF, skipping`);
    continue;
  }
  const name = `${loc.id}-${link.scan_id}.glb`;
  await writeFile(name, bytes);
  console.log(`${loc.name}: ${name} (${bytes.length} bytes)`);
}

The same pattern fetches splat, poses or semantic, or one scan's files through /vps/scans/{scan_id}/{kind}. A secret key may make 1,200 requests a minute across all operations; past that it gets 429 RATE_LIMITED with Retry-After in seconds, which the script waits out.

To act for one of your app's users, add x-app-user-id to the headers. That user's own locations and scans then appear beside the shared ones.


Load the mesh in ViroReact

The mesh is in the map's frame, in metres, and so are the children of ViroVPSAnchor. So a mesh placed at the anchor's origin sits over the real place once <ViroVPS> is tracking it. That is useful for a "digital twin" overlay, or to check a scan on site. Outside AR, you can show the same file as a 3D preview of the place.

From your backend

Serve the .glb your backend saved, and load it with Viro3DObject and type="GLB".

import * as React from "react";
import {
  ViroARScene,
  ViroTrackingStateConstants,
  ViroVPS,
  ViroVPSAnchor,
  Viro3DObject,
} from "@reactvision/react-viro";

export function TwinScene(props: any) {
  const { locationId, meshUrl } = props.arSceneNavigator.viroAppProps;
  const vpsRef = React.useRef<ViroVPS>(null);
  const started = React.useRef(false);

  const onTrackingUpdated = (state: number) => {
    if (state === ViroTrackingStateConstants.TRACKING_NORMAL && !started.current) {
      started.current = true;
      vpsRef.current?.startTracking(locationId);
    }
  };

  return (
    <ViroARScene onTrackingUpdated={onTrackingUpdated}>
      <ViroVPS ref={vpsRef} arSceneNavigator={props.arSceneNavigator} />
      <ViroVPSAnchor>
        <Viro3DObject
          source={{ uri: meshUrl }} // for example https://api.example.com/places/<id>/mesh.glb
          type="GLB"
          position={[0, 0, 0]}
          opacity={0.5}
        />
      </ViroVPSAnchor>
    </ViroARScene>
  );
}

ViroVPSAnchor keeps the mesh hidden until localisation converges. See Track the place.

From the app, with a user token

An app holding a user token can ask for the link itself with getLocationFileDownload(locationId, "mesh", { credentials }) (or getScanFileDownload(scanId, "mesh", { credentials })) and pass link.url to Viro3DObject with type="GLB" straight away, since it expires after 15 minutes. A client key cannot download mesh: it gets 403 API_KEY_TYPE_NOT_ALLOWED.

For occlusion and physics against the place, you do not need to download anything: use <ViroVPS worldMesh>, which loads world_mesh for you.


Deleting data

  • DELETE /vps/scans/{scan_id} deletes a scan and its files. If it was the active map, the newest remaining accepted scan takes over, or the location has no map.
  • DELETE /vps/locations/{location_id} deletes a location with every scan of it.
  • DELETE /vps/app-users/{app_user_id} deletes everything of one app user in the project. Call it from your backend when a user deletes their account in your app. A user token may delete only its own app user.

Deletion is permanent. An app user changing a shared location or scan gets 403 VPS_SHARED_LOCATION.


Errors and limits

CodeStatusMeaning
MISSING_PROJECT_ID, INVALID_PROJECT_ID400No x-project-id, or not a UUID.
BAD_REQUEST, INVALID_BODY400A parameter or body field is missing or out of range, or a cursor this API did not issue.
MISSING_API_KEY, INVALID_API_KEY, API_KEY_EXPIRED, INVALID_SESSION_FORMAT401No usable key or user token.
API_KEY_TYPE_NOT_ALLOWED403A client or legacy key on an operation or file kind it cannot use.
API_KEY_SCOPE_NOT_ALLOWED, APP_USER_TOKEN_SCOPE_NOT_ALLOWED403The secret key or user token lacks the vps scope.
PROJECT_NOT_FOUND, PROJECT_ACCESS_DENIED403The project does not exist, or the key or token cannot reach it.
APP_USER_ID_NOT_ALLOWED, APP_USER_MISMATCH403A client key sent x-app-user-id, or a user token named another app user.
PAID_PLAN_REQUIRED403Creating locations or scans, or uploading, without a paid plan.
COMPUTE_BILLING_NOT_SET_UP, COMPUTE_PAYMENT_FAILED, COMPUTE_SPEND_LIMIT_REACHED403Compute billing has no payment method, an unpaid invoice, or is at the spend limit.
VPS_SHARED_LOCATION403An app user changing a shared location or scan.
NOT_FOUND404No such location or scan, one the request cannot see, or a kind the operation does not serve.
NO_ASSET404No active scan, or no file of that kind.
RATE_LIMITED429More than 1,200 requests a minute for a secret key. Wait Retry-After seconds.
INTERNAL_ERROR500Retry, and quote meta.request_id to support if it persists.
AUTH_UNAVAILABLE, VPS_LOOKUP_FAILED, COMPUTE_BILLING_UNAVAILABLE503The key, the records or billing could not be checked. Retry.

Limits that apply to reading: locations pages of up to 100, scans pages of up to 200, a search radius of up to 5,000 m, and signed download links that last 15 minutes. Uploads have their own limits (512 MiB a recording, 10 scans a day and 3 waiting per app user, 50 waiting per team): see Errors and Limits.


See also