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:
previewabout 90 secondsspaceabout 8 minutesOne 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
A key with the ai scope
Create an API key in the Developers hub with the
aiscope (the endpoint consumes AI credits). The REST API is available on the Pro and Teams plans.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.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 reportscreditsCharged, 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.
imageUrlsuri[] (1 to 8)videoUrluriimageUrluriqualitypreview | fulltitlestring (80 max)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"
}'{
"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);
}
}{
"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"
}
}{
"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"
}
}{
"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.
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"] }'{
"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.
spaceUrl.spz (Gaussian splat)spaceFullUrl.spz (Gaussian splat), full resolutionmetricScale, floorOffsetMnumber | nullcolliderUrlGLBpanoramaUrlequirectangular 2:1 (PNG on a room, JPEG on a one-photo space)depthUrlPNG, 16-bitposterUrlimagewidthM, depthM, ceilingM, cameraHeightM, floorAreaM2{ value, confidence, lowerBound } | nulllowerBoundbooleanconfidence0 to 1fovDeg, wallsSeenobject, integersummarystringOne 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
400 validation_errorbad body401 unauthorized / 403 forbiddenauth402 quota_exceededcredits404 not_foundon the poll429 rate_limitedthrottle503 service_unavailableengine offOne 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
/real-world/spacesStart a Theo Real World space job (202, a space_-prefixed job id).GET/jobs/{id}Poll the job; the preview rides the result while processing.POST/webhooksRegister space.completed / space.failed deliveries.Schemas: RealWorldSpaceRequest, RealWorldSpaceResult and RealWorldMeasurement in the API reference and the live OpenAPI document at /api/v1/openapi.