REST API

Call Bakery3 over HTTP from any language. The SDK uses these same routes, so you only need this page when you are not using the SDK.

Base URL: https://api.bakery3.com

Authentication

Authorization: Bearer bk_sk_…

Send a bk_sk_ secret key or a bk_pt_ browser token. Making tokens, listing, deleting, importing and reading the queue need a secret key. See Auth & keys.

A missing header, a value that is not a Bakery3 key and a key that is not valid each answer 401 Unauthorized with their own message and a link to the Keys page. A browser token that has started all its renders, or expired less than 24 hours ago, can still read and cancel the renders it started, and answers 401 to anything that starts new work.

Errors

Every failure has this body. The SDK throws the same fields.

{
  "error": {
    "code": "RenderBudgetExceeded",
    "message": "This render could cost up to $0.16, which exceeds maxCost $0.14 (estimated $0.12)",
    "why": "The render was refused before any GPU work started, so nothing was charged.",
    "fix": "Raise maxCost, lower the quality preset, or reduce the output resolution.",
    "docs": "https://bakery3.com/docs/cost"
  }
}

code never changes, so you can branch on it. A 5xx or a 429 is worth trying again. Any other 4xx will fail the same way until you change the request.

POST /v1/tokens

Make a short-lived browser token for one of your users. Secret key only.

POST /v1/tokens
Content-Type: application/json

{
  "expiresInSeconds": 300,
  "maxRenders": 1,
  "maxCost": 1,
  "allowedOrigins": ["https://shop.example.com"],
  "subject": "user_8812"
}
{ "token": "bk_pt_…", "expiresAt": 1735689900000, "projectId": "prj_…" }

Defaults: expiresInSeconds 300, maxRenders 5, maxCost 2.

POST /v1/tokens/revoke

Stop a token from working before it expires. Secret key only.

POST /v1/tokens/revoke
{ "token": "bk_pt_…" }
{ "revoked": true }

POST /v1/assets/check

Ask which files we already have, and get an upload URL for each one we don't. hash is the SHA-256 of the file's bytes.

POST /v1/assets/check
{
  "assets": [
    {
      "hash": "sha256:9f2b…",
      "kind": "scene",
      "bytes": 155189248,
      "contentType": "model/gltf-binary",
      "name": "scene.glb"
    }
  ]
}
{
  "present": ["sha256:1a4c…"],
  "uploads": [
    {
      "hash": "sha256:9f2b…",
      "url": "https://storage…?X-Amz-Signature=…",
      "headers": { "content-type": "model/gltf-binary" },
      "expiresAt": 1735690200000
    }
  ]
}

present lists the hashes we already have. Upload every file in uploads with a PUT to its url.

POST /v1/assets/import

Give us a public URL and we fetch the file into your project, so you don't upload it yourself. Secret key only, on the Production or Scale plan. A browser token answers 403 Unauthorized and another plan answers 403 PlanRequired. You cannot call this route from a browser.

POST /v1/assets/import
Authorization: Bearer bk_sk_…

{ "url": "https://cdn.shop.com/models/chair-aria.glb", "kind": "scene" }

kind is scene (a .glb, the default) or hdri (a Radiance .hdr or an OpenEXR file). You get the file's hash back, which goes in a manifest like any other hash. For a scene you also get facts about the model:

{
  "hash": "sha256:204459ec…7aab23e3",
  "kind": "scene",
  "bytes": 9784036,
  "unchanged": false,
  "facts": {
    "triangles": 116938,
    "bounds": { "min": [-0.28, 0, -0.28], "max": [0.27, 0.56, 0.28] },
    "cameras": [],
    "objects": ["pot", "foliage"],
    "extensionsRequired": [],
    "issues": []
  }
}

unchanged: true means the file has not changed since your last import, so nothing was downloaded. ImportRefused (400) means we will not fetch that URL. ImportFailed means we fetched it and could not use the file: 422, or 502 when trying again may help. The rules for a URL are under Importing by URL.

DELETE /v1/assets/:hash

Delete one uploaded file now, without waiting for the project's retention. Every import that fetched it is forgotten too. Secret key only.

DELETE /v1/assets/sha256:9f2b…
{ "hash": "sha256:9f2b…", "deleted": true }

deleted is false when the project has no file with that hash, so deleting twice is not an error. The next render that needs the file uploads it again. A render still waiting in the queue for it can fail, and a failed render costs nothing.

DELETE /v1/assets

Delete every uploaded file in the project, up to 100 per call. Call it again until more is false. Secret key only.

DELETE /v1/assets
{ "deleted": 100, "more": true }

StorageUnavailable (503) means some files could not be deleted just now. The ones that could are gone, and calling again picks up the rest.

POST /v1/estimate

Get the price of a render before you start it. Estimates are free.

POST /v1/estimate
{
  "quality": "studio",
  "output": { "width": 2048, "height": 2048 },
  "scene": { "triangles": 248091 },
  "advanced": { "samples": 256 },
  "frames": 1
}

Set frames to price a video. Leave it out, or send 1, for an image. A frame of a video costs less than an image of the same size. See cost.

{
  "estimatedCost": 0.12,
  "maximumCost": 0.16,
  "estimatedSeconds": 7,
  "compatibilityScore": 1
}

An unknown quality is priced as studio.

Send a bake field to price a bake instead. It takes the same settings as a bake request, with include as a list of object ids:

POST /v1/estimate
{
  "bake": {
    "quality": "standard",
    "textureSize": 1024,
    "include": ["floor", "sofa", "wall-north"],
    "sizes": { "floor": 2048 },
    "atlas": { "size": 2048, "weights": { "sofa": 4 } },
    "deliverGlb": false
  },
  "scene": { "triangles": 480210 }
}

include is required. textureSize is 256, 512, 1024 or 2048 and defaults to 1024. atlas.size, when you send it, is 1024, 2048 or 4096. An unknown quality is priced as standard. What makes a bake cost more is under Estimating a bake.

POST /v1/renders

Start a render from a manifest. Upload every file the manifest names first, with /v1/assets/check.

POST /v1/renders
{
  "manifest": { "sceneAsset": "sha256:9f2b…", "camera": {}, "lights": [] },
  "quality": "studio",
  "output": { "width": 2048, "height": 2048, "format": "png" },
  "maxCost": 0.5,
  "priority": "high",
  "metadata": { "sku": "SOFA-2201" },
  "idempotencyKey": "sku-SOFA-2201-hero-v3"
}

manifest.sceneAsset and output are required. Without them you get this:

{
  "error": {
    "code": "InvalidRequest",
    "message": "A RenderSpec with `manifest` and `output` is required.",
    "docs": "https://bakery3.com/docs/api#renders"
  }
}
FieldWhat it does
maxCostRefuse the render if it could cost more than this, in dollars.
advancedThe same fields as the SDK's advanced settings, caustics included.
prioritynormal (the default) or high. high goes before your other renders in the same lane.
metadataYour own data. It comes back on the record and in every webhook.
idempotencyKeySend the same key again and you get the same render back, not a second one. See retries and idempotency.

Build a manifest by hand

You can write the manifest yourself instead of capturing it with the SDK. This is the smallest one that renders:

{
  "protocolVersion": "2.0",
  "sdkVersion": "my-pipeline 1.0",
  "sceneAsset": "sha256:9f2b…",
  "camera": {
    "type": "perspective",
    "fov": 35,
    "near": 0.01,
    "far": 100,
    "position": [0, 1.2, 4],
    "quaternion": [0, 0, 0, 1]
  },
  "color": { "outputColorSpace": "srgb", "toneMapping": "neutral", "toneMappingExposure": 1 }
}
  • protocolVersion is "2.0" for a still and "3.0" with an animation. A manifest with a room needs "4.0", and one with environment.fog needs "5.0".
  • sceneAsset is the hash of a GLB you uploaded. To light, hide or animate an object, give its node an id in the GLB's extras, for example "extras": { "bakery3Id": "model" }, and use that id in the manifest.
  • color can be left out. You then get no tone mapping at exposure 1, which is Three.js's own default.

You do not need compatibility, stats or animation.checks. The SDK writes them for you. Without stats, the quote prices the scene as one under 500,000 triangles with nothing around it, and corrects itself as your renders finish. Without animation.checks, the frames are not compared against where your browser put things.

Returns a render record:

{
  "id": "k1793xk2qyb6p9nw0f8d5c4h3m7s2v6a",
  "state": "queued",
  "progress": 0.12,
  "lane": "normal",
  "attempts": 1,
  "estimate": { "estimatedCost": 0.12, "maximumCost": 0.16, "estimatedSeconds": 7 },
  "metadata": { "sku": "SOFA-2201" },
  "createdAt": 1735689568000,
  "updatedAt": 1735689568000
}

lane is the queue the render waits in: interactive for a browser token, normal for a secret key, batch for a batch item. attempts is how many times a worker has started the render. It is normally 1, and more means we retried a failure on our side. A batch item also carries batchId and batchKey.

GET /v1/renders/:id

Read one render. You get the same record, with previewUrl once a preview exists and result once it completes:

{
  "id": "k1793xk2qyb6p9nw0f8d5c4h3m7s2v6a",
  "state": "completed",
  "progress": 1,
  "result": {
    "url": "https://storage…?X-Amz-Signature=…",
    "width": 2048,
    "height": 2048,
    "format": "png",
    "bytes": 4912338,
    "cost": 0.13,
    "expiresAt": 1735776000000,
    "versions": { "translator": "5", "qualityPreset": "studio" }
  }
}

result.url is signed and works for 24 hours, until result.expiresAt. Read the render again for a new one. A render from another project answers 404.

POST /v1/renders/:id/cancel

Cancel a render that has not finished. Cancelling one that already finished changes nothing and returns the record as it is.

POST /v1/renders/k1793xk2qyb6p9nw0f8d5c4h3m7s2v6a/cancel

POST /v1/renders/:id/retry

Run a render again after it failed with error.attemptsExhausted: true. That flag means every automatic attempt failed on our side. Any other render answers 409, because it is still running or it would fail the same way again.

POST /v1/renders/k1793xk2qyb6p9nw0f8d5c4h3m7s2v6a/retry

Returns a new render record with a new id, in queued, with the same request and metadata. It is checked against your balance and limits again. Calling the route twice returns the same new render. See attemptsExhausted.

DELETE /v1/renders/:id

Delete a render's files now, without waiting for the project's retention to end. The result, the preview and every signed URL to them stop working at once. The record stays, as expired, so you can still read what it cost. A render that is still running is cancelled first. Deleting twice returns the same expired record. Secret key only.

DELETE /v1/renders/k1793xk2qyb6p9nw0f8d5c4h3m7s2v6a

GET /v1/renders

List renders for the project, newest first. Secret key only.

GET /v1/renders?limit=25
{ "renders": [], "cursor": null }

POST /v1/bakes

Start a bake. You name objects and get their lighting back as lightmap textures, not as an image. The SDK's bake() builds this body.

POST /v1/bakes
{
  "manifest": { "sceneAsset": "sha256:9f2b…", "camera": {}, "lights": [] },
  "bake": {
    "quality": "standard",
    "textureSize": 1024,
    "include": ["wall-north", "floor", "sofa"],
    "atlas": { "size": 2048, "weights": { "sofa": 4 } },
    "view": { "position": [3, 1.6, 4] }
  },
  "maxCost": 1,
  "metadata": { "room": "showroom-a" }
}

manifest.sceneAsset and bake.include are required. A bake over the object or texel limit is refused as SceneTooLarge, with the number that was over. A bake is checked against the same balance and monthly limits as a render, and it takes one of the same concurrency slots.

Returns a bake record. It looks like a render record, with a different result once it completes:

{
  "id": "k1745r9mzt0wq3x6c8n2b5h7d4j1f9pe",
  "state": "completed",
  "progress": 1,
  "result": {
    "bundleUrl": "https://storage…/bundle.json?X-Amz-Signature=…",
    "files": {
      "bundle.json": "https://storage…?X-Amz-Signature=…",
      "atlas.exr": "https://storage…?X-Amz-Signature=…",
      "preview_atlas.png": "https://storage…?X-Amz-Signature=…"
    },
    "expiresAt": 1735775968000,
    "summary": { "objects": [], "skipped": [], "atlas": { "size": 2048, "coverage": 0.56 } },
    "bytes": 34412201,
    "cost": 0.11,
    "versions": { "bake": "4", "translator": "6" }
  },
  "createdAt": 1735689568000,
  "updatedAt": 1735689610000
}

bundleUrl is the bundle: the baked objects, their lightmap UVs and what was skipped. files has a signed URL for every file name the bundle uses, and summary is the bundle without the UVs.

GET /v1/bakes/:id

Read one bake. Every read signs new URLs, and they work for 24 hours. A bake from another project answers 404.

POST /v1/bakes/:id/cancel

Works like cancelling a render. POST /v1/bakes/:id/retry works like a render's retry too.

DELETE /v1/bakes/:id

Delete every file of a bake now: the bundle, the lightmaps, the previews and the .glb. Secret key only.

GET /v1/bakes

List bakes for the project, newest first. Secret key only.

GET /v1/bakes?limit=25
{ "bakes": [], "cursor": null }

Batches

A batch is many renders sent as one job, such as a whole product catalog. You open a batch, add items in pages and read one record for the whole run. Every item is an ordinary render (or bake) with a batchId. Both key types work. A batch from another project answers 404.

MethodRouteDoes
POST/v1/batchesOpen a batch. Body: key, name?, kind? (render or bake), maxCost?, lane? (batch or normal), maxParallel?. The same key returns the same batch until it is completed or cancelled, with the new maxCost, maxParallel and name applied. maxCost cannot go below what the batch has accepted, and kind and lane cannot change.
GET/v1/batches/:idThe batch record. ?wait=25 holds the request for up to 25 seconds, until the counts change.
POST/v1/batches/:id/itemsAdd a page of at most 100 items. Each item has key, spec, metadata?, maxCost?. dryRun: true prices the page without starting anything. Returns { batch, items }.
GET/v1/batches/:id/items?state=&cursor=&limit=. Each item is the render record plus key.
GET/v1/batches/:id/exportNDJSON, one line per item: key, id, state, resultUrl, error, cost, versions, metadata. Secret key only.
POST/v1/batches/:id/closeClose the batch. It takes no more items.
POST/v1/batches/:id/pauseStop starting new items. Running items finish.
POST/v1/batches/:id/resumeStart items again.
POST/v1/batches/:id/cancelCancel every item that has not finished.
POST/v1/batches/:id/retryRetry every item that failed with attemptsExhausted.
POST /v1/batches
{ "key": "fall-2026-v3", "maxCost": 600, "lane": "batch" }
{
  "id": "jd71cf4a8w2k6t9n3q5x0m7v1r8e4s2y",
  "key": "fall-2026-v3",
  "kind": "render",
  "state": "open",
  "lane": "batch",
  "maxCost": 600,
  "counts": { "staged": 0, "queued": 0, "running": 0, "completed": 0,
              "failed": 0, "refused": 0, "cancelled": 0, "adopted": 0 },
  "total": 0,
  "done": 0,
  "cost": { "estimated": 0, "maximum": 0, "actual": 0 },
  "createdAt": 1735689568000
}

A page is accepted whole or refused whole. The refusals are 402 InsufficientBalance, 402 BatchBudgetExceeded and 409 BatchClosed. When one item in an accepted page cannot be taken, that item carries error in items[i]. A batch page is never refused as QueueFull. Every state, count and error is on Render every variant.

GET /v1/queue

See what is waiting on your account, in the order it will run, with a rough wait for each render. Secret key only.

GET /v1/queue
Authorization: Bearer bk_sk_…
{
  "concurrency": 5,
  "inFlight": 5,
  "ready": 2,
  "waiting": [
    { "id": "k17ef3n6vz1c8a5t2w9q0m4x7p3d6h8r", "kind": "render", "lane": "normal", "priority": 10, "position": 1, "etaSeconds": 24 }
  ],
  "staged": 310,
  "batches": [
    { "id": "j97…", "key": "autumn-2026", "state": "sealed", "staged": 310, "queued": 12, "running": 4 }
  ],
  "paceSeconds": 24
}
FieldWhat it holds
concurrencyHow many renders your plan runs at once.
inFlightHow many are running now.
waitingYour queued renders in order. etaSeconds is an estimate from how long your recent renders took. A render that belongs to a batch also has batchId and batchKey, and one waiting to be retried has retryingAt.
stagedBatch items that have not entered the queue yet.
batchesYour batches that are still open, sealed or paused.
paceSecondsThe median time of your last 20 completed renders, or null before you have any.

Render states

created → uploading → validating → queued → starting → preparing
        → rendering_preview → rendering → encoding → uploading_result
        → completed

A render ends in completed, failed, cancelled or expired and never leaves that state. When we retry a failure on our side, the render goes back to queued and attempts goes up by one.

Error codes

RenderBudgetExceeded, UnsupportedMaterial, SceneTooLarge, TextureTooLarge, AssetNotFound, AssetUploadIncomplete, ProtocolVersionUnsupported, RoomPresetUnknown, RoomPresetBuildMismatch, CompatibilityPreflightFailed, OutOfVideoMemory, RenderTimeout, WorkerFailure, WorkerLost, RendererCrashed, UploadFailed, ResultLost, CapacityUnavailable, InsufficientBalance, QuotaExceeded, ConcurrencyLimitReached, QueueFull, QueueTimeout, BatchBudgetExceeded, BatchClosed, ImportRefused, ImportFailed, PlanRequired, StorageUnavailable, RateLimited, Unauthorized, TokenExpired, OriginNotAllowed, InvalidRequest.

The SDK adds a few of its own: NetworkError when the API could not be reached, CaptureFailed when the scene could not be read, Cancelled and Expired when a job ended that way.

Troubleshooting has the fix for the common ones. WorkerLost, RendererCrashed, UploadFailed, ResultLost and WorkerFailure are failures on our side, and we retry them for you. BatchBudgetExceeded and BatchClosed refuse a batch page. ImportRefused and ImportFailed answer an import, and StorageUnavailable answers deleting files.

Call the API from a browser

A page on an allowed origin can call these routes with a browser token: /v1/assets/check, /v1/estimate, /v1/renders, /v1/bakes, /v1/batches and the routes under them, except the ones marked Secret key only on this page. /v1/assets/import and the DELETE /v1/assets routes cannot be called from a browser. A page may send the Authorization, Content-Type and bakery3-sdk headers; the SDK uses the last one to say which version it is.

From Node

@oeave/bakery3/node has methods for the token, asset, render and batch routes:

import { Bakery3Server } from '@oeave/bakery3/node';

const bakery3 = new Bakery3Server(process.env.BAKERY3_SECRET_KEY!);

await bakery3.renders.create(spec);
await bakery3.renders.get(id);
await bakery3.renders.cancel(id);
await bakery3.renders.retry(id);
await bakery3.renders.delete(id);
await bakery3.renders.list({ limit: 25 });
await bakery3.renders.estimate({
  quality: 'studio',
  output: { width: 2048, height: 2048 },
});

await bakery3.assets.import('https://cdn.shop.com/models/chair-aria.glb');
await bakery3.assets.delete('sha256:9f2b…');
await bakery3.assets.deleteAll();

const batch = await bakery3.batches.open({ key: 'fall-2026-v3', maxCost: 600 });
batch.add({ key: 'chair-aria/oak/front', spec, metadata: { sku: 'chair-aria' } });
await batch.flush();
await batch.close();
await batch.wait({ onProgress: (p) => console.log(p.done, '/', p.total) });
for await (const item of batch.items({ state: 'failed' })) console.error(item.key);