Skip to content

Marble World API quickstart in TypeScript: an unofficial walkthrough

By Atlas World Model EditorialUpdated 4 min read

Independent article. Not affiliated with World Labs.

Key facts

EndpointPOST /marble/v1/worlds:generate, model marble-1.1(as of 2026-09-03)source
Credits endpointGET /marble/v1/credits(as of 2026-09-03)source
Cost$1 = 1,250 credits; 1,500 per world; $5 minimum(as of 2026-09-03)source
Exports.spz / .ply splats (free) and GLB mesh (3,500 credits); no video export(as of 2026-09-13)source
RuntimeNode 22+ (native fetch, AbortSignal.timeout); this guide’s choice, not a World Labs requirement(as of 2026-09-03)

This tutorial gets you from zero to a downloaded Gaussian splat world using the World Labs Marble World API and plain TypeScript. It is written by an independent site, not affiliated with World Labs; the endpoint and model name come from the official docs, and response field names below are illustrative, so check them against the reference as you go.

Prerequisites

  • A World Labs developer account and an API key from the platform’s API keys page.
  • Credits: $1 buys 1,250, the minimum purchase is $5 for 6,250, and one Marble 1.1 world costs 1,500, about $1.20, as of September 3, 2026 (World Labs docs). Full details on the pricing page.
  • Node 22 or later, for native fetch and AbortSignal.timeout.
  • Ten minutes.

Project setup

mkdir marble-quickstart && cd marble-quickstart
pnpm init
pnpm add -D typescript tsx @types/node
echo 'WORLDLABS_API_KEY=your-key-here' > .env

Never commit the .env file. Load it with node --env-file=.env or your preferred loader.

Step 1: check your credits

// src/credits.ts
const API = 'https://api.worldlabs.ai';
const headers = { 'WLT-Api-Key': process.env.WORLDLABS_API_KEY! };

const res = await fetch(API + '/marble/v1/credits', { headers, signal: AbortSignal.timeout(15_000) });
if (!res.ok) throw new Error('credits request failed: ' + res.status);
console.log(await res.json()); // { remaining_credits: ... }
curl https://api.worldlabs.ai/marble/v1/credits \
-H "WLT-Api-Key: $WORLDLABS_API_KEY"

Run it with pnpm exec tsx --env-file=.env src/credits.ts. A 401 with No API key found in request almost always means the key went into an Authorization: Bearer header instead of WLT-Api-Key — the API ignores the former entirely.

Step 2: generate a world from text

// src/generate.ts
export const API = 'https://api.worldlabs.ai';
export const headers = {
'WLT-Api-Key': process.env.WORLDLABS_API_KEY!,
'Content-Type': 'application/json',
};

export async function generateWorld(text_prompt: string, model = 'marble-1.0-draft') {
const res = await fetch(API + '/marble/v1/worlds:generate', {
  method: 'POST',
  headers,
  body: JSON.stringify({ model, world_prompt: { type: 'text', text_prompt } }),
  signal: AbortSignal.timeout(30_000),
});
if (!res.ok) throw new Error('generate failed: ' + res.status + ' ' + (await res.text()));
return (await res.json()) as { operation_id: string; done: boolean };
}

const op = await generateWorld('a sunlit greenhouse full of ferns, glass roof, wooden benches, soft morning haze');
console.log('operation:', op.operation_id);
curl -X POST https://api.worldlabs.ai/marble/v1/worlds:generate \
-H "WLT-Api-Key: $WORLDLABS_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"model": "marble-1.0-draft",
"world_prompt": {
"type": "text",
"text_prompt": "a sunlit greenhouse full of ferns, glass roof, wooden benches"
}
}'

The call returns in well under a second. It does not return a world; it returns an operation carrying operation_id, done: false and an expires_at one hour out. Note the default model here is marble-1.0-draft at 150 credits rather than marble-1.1 at 1,500 — iterate cheaply, then re-run the keeper at full quality. For image input, swap the world_prompt type as described in the Marble World API guide.

Step 3: wait for the operation

Generation takes minutes. Poll with a growing interval so you are not hammering the API.

// src/poll.ts
import { API, headers } from './generate';

type Operation = {
  done: boolean;
  error: { code: number | null; message: string | null } | null;
  metadata: {
    progress?: { status: string; description: string };
    world_id?: string;
  } | null;
  response: World | null;
  cost: {
    total_credits: number;
    line_items: { name: string; credits: number }[];
  } | null;
};

type World = {
  world_id: string;
  assets: {
    splats: { spz_urls: Record<'full_res' | '500k' | '100k', string> };
    mesh: { collider_mesh_url: string | null; hq_mesh_url: string | null };
    imagery: { pano_url: string | null };
    thumbnail_url: string;
    caption: string;
  };
  world_marble_url: string;
};

export async function waitForOperation(operationId: string) {
  let delay = 3_000;
  const deadline = Date.now() + 20 * 60_000; // give up after 20 minutes
  while (Date.now() < deadline) {
    const res = await fetch(API + '/marble/v1/operations/' + operationId, {
      headers,
      signal: AbortSignal.timeout(15_000),
    });
    if (res.status >= 500) {
      await new Promise((r) => setTimeout(r, delay));
      continue; // transient; retry
    }
    if (!res.ok) throw new Error('poll failed: ' + res.status);
    const op = (await res.json()) as Operation;
    if (op.error) throw new Error('generation failed: ' + op.error.message);
    if (op.done && op.response) return { world: op.response, cost: op.cost };
    // op.metadata?.progress?.status is IN_PROGRESS then SUCCEEDED — a state, not a percentage
    await new Promise((r) => setTimeout(r, delay));
    delay = Math.min(delay * 2, 15_000);
  }
  throw new Error('timed out waiting for the world');
}

Those field names are the live ones, verified against the API on September 13, 2026. Two are easy to get wrong: the finished world is under response, not world, and its identifier is world_id, not id. There is no numeric progress anywhere in the payload — metadata.progress.status goes IN_PROGRESS then SUCCEEDED — so a percentage bar has to be estimated from elapsed time. The shape of the loop is the rest of the point: bounded, backing off, tolerant of 5xx, strict on real errors.

Step 4: download and view the splat

A finished world already carries SPZ splats at three resolutions under assets.splats.spz_urls — full_res, 500k and 100k — plus a free collider mesh, with no export call needed. These are CDN URLs that serve without an auth header, so the download needs no credentials.

// src/download.ts
import { createWriteStream } from 'node:fs';
import { Readable } from 'node:stream';
import { pipeline } from 'node:stream/promises';

export async function download(url: string, file: string) {
  // asset URLs are public CDN links; no API key needed here
  const res = await fetch(url, { signal: AbortSignal.timeout(120_000) });
  if (!res.ok || !res.body) throw new Error('download failed: ' + res.status);
  // stream to disk; a .ply can be hundreds of megabytes
  await pipeline(Readable.fromWeb(res.body as never), createWriteStream(file));
}

Then open the file in the free splat viewer: drag it onto the page and orbit. Nothing is uploaded; it renders locally with Spark, World Labs’ MIT-licensed three.js renderer (GitHub). To embed it in your own page, load Spark and point a SplatMesh at the .spz URL.

Step 5: check credits again

Re-run the credits script. A draft world from a text prompt costs 230 credits: the completed operation’s cost.line_items itemises it as 80 for Pano generation (text) plus 150 for Draft world generation, and the balance drops by exactly that. The same prompt on marble-1.1 is 1,580. Read cost rather than assuming a flat per-world price — and if a generation failed, check the balance rather than guessing whether it was charged.

Error handling and retries

  • Timeouts. Every fetch above sets AbortSignal.timeout. A hung request is the most common failure in long-running pipelines.
  • 5xx and 429. Retry with exponential backoff, three attempts, then surface the error.
  • Operation errors. Do not retry blindly; the same input usually fails the same way. Log the operation identifier and inspect.
  • Key handling. Keep the key in server-side environment variables. If you are building a web app, put this code behind your own endpoint, as this site plans to.

Putting it together

A minimal end-to-end script wires the pieces above:

// src/main.ts
import { download } from './download';
import { generateWorld } from './generate';
import { waitForOperation } from './poll';

const op = await generateWorld(
  'a quiet reading room with tall shelves and a window seat'
);
console.log('queued', op.operation_id);
const { world, cost } = await waitForOperation(op.operation_id);
console.log(
  'world ready',
  world.world_id,
  'charged',
  cost?.total_credits,
  'credits'
);
await download(world.assets.splats.spz_urls.full_res, 'world.spz');
console.log('saved world.spz');

Run it with pnpm exec tsx --env-file=.env src/main.ts, wait a few minutes, and drop the resulting file into the viewer. Total cost: 1,500 credits, about $1.20.

Going to production

The script above is fine for a laptop and wrong for a server. In production, the generate call belongs in a request handler that returns immediately, the polling belongs in a queue consumer or a scheduled job, and the download should stream straight into object storage rather than a local file. Reserve credits in your own ledger when the job is queued and settle them when it completes, so a failed generation refunds the user without a support ticket. The Atlas World Model API page describes how this site plans to implement exactly that behind a provider abstraction, so the same code will target Atlas when its endpoint appears.

Troubleshooting

If the generate call returns 400, the request body does not match the current schema; copy the example from the official docs verbatim and diff. If polling never completes, confirm you are requesting the operation URL from the response rather than a URL you constructed. If the downloaded file will not open in the viewer, check that it is a .ply or .spz and not an HTML error page saved with the wrong extension.

Next steps

Sources

  1. World Labs docs: API
  2. World Labs docs: API pricing
  3. Radiance Fields: Marble 1.1 and 1.1 Plus
  4. Radiance Fields: World API for Marble
  5. Spark renderer on GitHub

Frequently asked questions

Do I need an SDK?

No. The examples use the built-in fetch in Node 22 or later. Check the official docs for any published client library.

How long does a world take?

Minutes. The generate call returns immediately with an operation you poll.

What does this tutorial cost to run?

One Marble 1.1 world is 1,500 credits, about $1.20, and the minimum top-up is $5, as of September 3, 2026.

Where do I view the result?

Download the .spz or .ply export and drop it into the free splat viewer on this site, or render it with Spark in your own page.

Will this work with Atlas?

Atlas has no public API yet. The control flow here, generate then poll then export, is the pattern any future endpoint is likely to follow.

Are the field names exact?

The endpoint and model name come from the official docs; response field names are illustrative. Verify against docs.worldlabs.ai/api.