Build with the API

Real World spaces

Turn a few photos of a room, or a short video of it, into an imagined, walkable 3D room at real scale (one photo alone: a measured space): start the job, walk the quick room mid-flight, use the splat, the scale, the panorama and the dimensions honestly.

How it works

Theo Real World turns photos of a room into an imagined, walkable 3D space. Send several photos of ONE room (up to eight, taken from one spot, turning between shots so each overlaps the last) or one short video of it, and Theo works out where each photo was taken, builds the room, and imagines every wall, floor and corner the photos did not show. One request, one space_ job, two deliverables on the same job:

The two tiers of a room
preview
about 90 seconds
The quick room: a walkable Gaussian-splat space (.spz) in the room's own units (metricScale is null until the full room lands). Lands on the poll while the job is still processing, so you can walk the room before the full one finishes.
space
about 8 minutes
The full room at real scale: the splat plus metricScale (world units to metres), the floor offset, a 360 panorama and a collider mesh. Completes the job.

One photo alone builds a room the same way. On a deployment without the room engine it becomes a measured, imagined, walkable space instead: Theo measures the room from the photo with a metric depth model, places the photo in a 360 panorama, imagines everything the photo did not show, and builds a walkable shell. Its preview is the panorama plus the room's dimensions (no splat yet); its space stage is the walkable shell. The 202 response and every result say which you got: source is photos, video or photo, and a room carries metricScale where a one-photo space carries dimensions.

The photos are the only real part

Everything the photos did not show is generated. The result carries imagined: true on both tiers. A room is truest where the photos looked; a one-photo space's dimensions are estimates with a confidence each (monocular scale is typically within 5 to 15 percent). Tell your users the same thing: built from their photos, the rest imagined; measurements are about, never exact.

Before you start

  1. A key with the ai scope

    Create an API key in the Developers hub with the ai scope (the endpoint consumes AI credits). The REST API is available on the Pro and Teams plans.

  2. Photos (or a video) of ONE room at public URLs

    JPEG, PNG or WebP photos, or one MP4 / MOV / WebM video (30 seconds and 100 MB or less), hosted at http(s) URLs the OpenCharts servers can fetch (signed URLs are fine). For the photos, stand in one spot and turn between shots so each overlaps the last, up to eight of one room; for a single photo, wide and level, with the floor and at least one wall in view. Two rooms are two requests: photos of different rooms in one set confuse the build. Do not send an object or a person: those are captures, not spaces, and are not on this API.

  3. Decide preview or full

    quality: "preview" stops at the first tier (the quick room, or a one-photo space's panorama and dimensions). quality: "full" (the default) continues to the final tier (the full room at real scale, or the walkable space) and costs more. The 202 response reports creditsCharged, the figure charged once, at dispatch. A final tier that does not finish is neither charged again nor refunded: the preview it leaves behind is the deliverable, and the poll result says so.

1. Start a space

POST /real-world/spaces answers 202 with the job envelope immediately. Send an Idempotency-Key so a network retry replays the same job instead of dispatching (and billing) a second one.

Request body (exactly one of the three inputs)
imageUrls
uri[] (1 to 8)
Photos of ONE room, in the order they were taken, http(s) only, 2048 characters each at most. Builds a room.
videoUrl
uri
One video of the room (MP4, MOV or WebM; 30 seconds and 100 MB or less), http(s) only. The whole input: send no photos beside it. Builds a room.
imageUrl
uri
ONE photo (JPEG, PNG or WebP), http(s) only. A room on a deployment with the room engine, else a measured one-photo space.
quality
preview | full
Default full.
title
string (80 max)
Optional label for the job; a room also reads it as a short description of the room.
curl https://www.opencharts.com/api/v1/real-world/spaces \
  -H "Authorization: Bearer $OPENCHARTS_API_KEY" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: living-room-2026-09-26" \
  -d '{
    "imageUrls": [
      "https://example.com/photos/living-room-1.jpg",
      "https://example.com/photos/living-room-2.jpg",
      "https://example.com/photos/living-room-3.jpg",
      "https://example.com/photos/living-room-4.jpg"
    ],
    "quality": "full",
    "title": "Living room"
  }'
202 response
{
  "jobId": "space_6ab8060e0017e9bf3867",
  "type": "space",
  "status": "queued",
  "pollUrl": "/api/v1/jobs/space_6ab8060e0017e9bf3867",
  "quality": "full",
  "source": "photos",
  "photoCount": 4,
  "imagined": true,
  "engine": "Theo Real World 1.1",
  "creditsCharged": 24000,
  "approxSeconds": { "preview": 90, "space": 480 }
}

A one-photo space on a deployment without the room engine answers the same envelope with source: "photo" and approxSeconds: { "preview": 90, "space": 180 }.

2. Poll the job

Poll GET /jobs/{jobId} every five to ten seconds. Statuses run queued then processing then complete or failed. Unlike every other job type, a space job carries a result while processing as soon as the first tier has landed (result.stage === "preview"); the same result grows into stage: "space" when the final tier completes it.

const sleep = (ms) => new Promise((r) => setTimeout(r, ms));

async function waitForSpace(jobId, onPreview) {
  const headers = { Authorization: `Bearer ${process.env.OPENCHARTS_API_KEY}` };
  let shownPreview = false;
  for (;;) {
    const res = await fetch(`https://www.opencharts.com/api/v1/jobs/${jobId}`, { headers });
    const job = await res.json();

    // The first tier lands while the job is still processing: show it right away.
    if (job.result && !shownPreview) {
      onPreview(job.result); // stage "preview": a walkable quick room (spaceUrl, no metricScale yet), or a one-photo space's panoramaUrl + dimensions
      shownPreview = true;
    }
    if (job.status === "complete") return job.result; // stage "space" (or "preview", see notes)
    if (job.status === "failed") throw new Error(job.error);
    await sleep(5000);
  }
}
a room, while processing (the quick room, already walkable)
{
  "jobId": "space_6ab8060e0017e9bf3867",
  "type": "space",
  "status": "processing",
  "rawStatus": "generating",
  "pollUrl": "/api/v1/jobs/space_6ab8060e0017e9bf3867",
  "progressMessage": "Quick room ready — building the full room at real scale…",
  "result": {
    "stage": "preview",
    "imagined": true,
    "source": "photos",
    "photoCount": 4,
    "panoramaUrl": null,
    "spaceUrl": "https://www.opencharts.com/api/canvas-video/...",
    "spaceFullUrl": null,
    "format": "spz",
    "metricScale": null,
    "floorOffsetM": null,
    "colliderUrl": "https://www.opencharts.com/api/canvas-video/...",
    "depthUrl": null,
    "posterUrl": "https://www.opencharts.com/api/canvas-video/...",
    "gaussians": null,
    "dimensions": null,
    "engine": "Theo Real World 1.1"
  }
}
a room, once complete (the full room at real scale)
{
  "status": "complete",
  "result": {
    "stage": "space",
    "imagined": true,
    "source": "photos",
    "photoCount": 4,
    "panoramaUrl": "https://www.opencharts.com/api/canvas-video/...",
    "spaceUrl": "https://www.opencharts.com/api/canvas-video/...",
    "spaceFullUrl": "https://www.opencharts.com/api/canvas-video/...",
    "format": "spz",
    "metricScale": 1.38,
    "floorOffsetM": 1.351,
    "colliderUrl": "https://www.opencharts.com/api/canvas-video/...",
    "depthUrl": null,
    "posterUrl": "https://www.opencharts.com/api/canvas-video/...",
    "gaussians": null,
    "dimensions": null,
    "engine": "Theo Real World 1.1"
  }
}
a one-photo space, while processing (the 360 preview + dimensions)
{
  "status": "processing",
  "progressMessage": "360° preview ready — building the walkable space…",
  "result": {
    "stage": "preview",
    "imagined": true,
    "source": "photo",
    "photoCount": 1,
    "panoramaUrl": "https://www.opencharts.com/api/canvas-video/...",
    "spaceUrl": null,
    "format": null,
    "metricScale": null,
    "floorOffsetM": null,
    "colliderUrl": null,
    "depthUrl": null,
    "posterUrl": "https://www.opencharts.com/api/canvas-video/...",
    "gaussians": null,
    "dimensions": {
      "widthM": { "value": 3.59, "confidence": 0.35, "lowerBound": true },
      "depthM": { "value": 5.57, "confidence": 0.6, "lowerBound": false },
      "ceilingM": { "value": 2.98, "confidence": 0.48, "lowerBound": false },
      "cameraHeightM": { "value": 0.82, "confidence": 0.7, "lowerBound": false },
      "floorAreaM2": null,
      "fovDeg": { "x": 70.5, "y": 53.2 },
      "wallsSeen": 4,
      "summary": "At least 3.6 m wide × 5.6 m deep · ceiling about 3.0 m — estimated from your photo"
    },
    "engine": "Theo Real World 1.1"
  }
}

A one-photo space completes with stage: "space", spaceUrl set, format: "spz", metricScale: 1 (its splat is written in metres) and a depthUrl.

A complete job can stay at stage preview

When you asked for quality: "preview", or when the final tier did not finish, the job completes with stage: "preview": a room keeps its quick spaceUrl (still without metricScale), a one-photo space keeps spaceUrl: null. In the second case result.notes carries a plain-language sentence you can show as is. The preview you already have is the deliverable; nothing is charged twice.

3. Or receive a webhook

Register a webhook for space.completed and space.failed and skip polling. The delivery carries the job id and an absolute pollUrl to fetch the full result, plus the highlights. Verify the signature before trusting it; see Webhooks.

register
curl https://www.opencharts.com/api/v1/webhooks \
  -H "Authorization: Bearer $OPENCHARTS_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{ "name": "Spaces", "url": "https://example.com/hooks/opencharts", "events": ["space.completed", "space.failed"] }'
space.completed payload (data)
{
  "jobId": "space_6ab32aaa001085f82ad8",
  "status": "complete",
  "pollUrl": "https://www.opencharts.com/api/v1/jobs/space_6ab32aaa001085f82ad8",
  "stage": "space",
  "imagined": true,
  "panoramaUrl": "https://www.opencharts.com/api/canvas-video/...",
  "spaceUrl": "https://www.opencharts.com/api/canvas-video/..."
}

A space.completed delivery with stage: "preview" and a notes array means the walkable tier did not finish and the preview stands. A space.failed delivery carries errorMessage, already in plain language.

4. Use the result

Every file URL is absolute and public; fetch and cache what you need.

Files
spaceUrl
.spz (Gaussian splat)
The walkable space. On a room this is the 500k-Gaussian build (about 7 MB): the file to stream first, or to keep on a phone. Load it in any Gaussian-splat renderer that reads .spz (a three.js app can use a splat renderer such as Spark). Written in a camera convention (+Y down, +Z forward) with the origin where the photos were taken; a Y-up scene negates Y and Z, which is what the OpenCharts viewer does. A room: multiply every coordinate by metricScale to get metres (the quick room has no scale yet) and walk within about four metres of the origin. A one-photo space is already in metres; keep the walk within a couple of metres of the origin, because the space is right from where the photo was taken and stretches as you leave it.
spaceFullUrl
.spz (Gaussian splat), full resolution
A room's full-resolution build (about 2M Gaussians, about 28 MB), same frame and scale as spaceUrl: the file to show when detail matters, which is what the OpenCharts full-screen viewer and download use. Arrives with the full room; null on the quick room and on a one-photo space. The 500k file is the engine's low-resolution option, so a room judged on it alone reads grainy.
metricScale, floorOffsetM
number | null
A room's real scale: world units to metres, and how far below the origin the floor sits once scaled (stand the room on its floor by lifting it by floorOffsetM). Null on the quick room; 1 and null on a one-photo space.
colliderUrl
GLB
A room's collider mesh, in the same frame as the splat: use it for walking, placement and occlusion. Null on a one-photo space.
panoramaUrl
equirectangular 2:1 (PNG on a room, JPEG on a one-photo space)
A room's panorama arrives with the full room. A one-photo space's is 2048 x 1024 on the preview and 4096 x 2048 once the walkable tier's detail pass ran. Show it in any equirectangular 360 viewer: map it onto the inside of a sphere with the camera at the centre, or hand it to a photo-sphere component.
depthUrl
PNG, 16-bit
A one-photo space's 360 distance map aligned with the panorama, in millimetres per pixel. Present when the worker emitted one; null on a room.
posterUrl
image
A still to show before the viewer loads.
dimensions (a one-photo space; null on a room, whose scale is metricScale)
widthM, depthM, ceilingM, cameraHeightM, floorAreaM2
{ value, confidence, lowerBound } | null
Metres (square metres for the floor area). A null field was not measurable from this photo (no ceiling outdoors, for instance).
lowerBound
boolean
true means a wall the photo did not show makes the number a floor: read it as at least, not about.
confidence
0 to 1
How much the measurement pass trusts the value.
fovDeg, wallsSeen
object, integer
The photo's horizontal and vertical field of view, and how many walls it showed (the far wall counts).
summary
string
The one line the OpenCharts card shows. Safe to render verbatim.

One real measurement corrects everything

Monocular scale is one global unknown, so if your user knows one true measurement (the ceiling height is the easiest), a single factor of known / estimated rescales every dimension, the splat and the depth map together. Keep the factor within 0.25 to 4; anything further is a typo, not a measurement.

Present the engine by its public name from result.engine (Theo Real World), keep imagined: true visible to the people who see the space, and quote measurements as estimates.

Errors and limits

Status codes
400 validation_error
bad body
The message says which field and why: no input, two inputs at once (a space is built from one photo, from several photos of one room, or from one video), a non-http(s) URL (the index is named for imageUrls), more than eight photos, an unknown quality, a non-string title. Nothing was charged.
401 unauthorized / 403 forbidden
auth
Missing or invalid key; a key without the ai scope; or a plan without API access.
402 quota_exceeded
credits
No AI credits left this month. Nothing was dispatched.
404 not_found
on the poll
An unknown job id, a job owned by another account, or a capture's id behind the space_ prefix. All three look identical on purpose.
429 rate_limited
throttle
Back off for the Retry-After seconds. See Rate limits.
503 service_unavailable
engine off
Real World is not enabled on this deployment; or the request needs the room engine (several photos, or a video) and the deployment has only the one-photo space engine. Nothing was charged.

One request builds ONE room: up to eight photos of it, or one video. Photos of a second room are a second request, and a video is the whole input (photos beside it are refused, not set aside). The job is owned by the account the key acts for, appears in that account's Real World history, and its files stay available like any other generated media.

Reference