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"
}
}| Field | What it does |
|---|---|
maxCost | Refuse the render if it could cost more than this, in dollars. |
advanced | The same fields as the SDK's advanced settings, caustics included. |
priority | normal (the default) or high. high goes before your other renders in the same lane. |
metadata | Your own data. It comes back on the record and in every webhook. |
idempotencyKey | Send 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 }
}protocolVersionis"2.0"for a still and"3.0"with ananimation. A manifest with aroomneeds"4.0", and one withenvironment.fogneeds"5.0".sceneAssetis the hash of a GLB you uploaded. To light, hide or animate an object, give its node an id in the GLB'sextras, for example"extras": { "bakery3Id": "model" }, and use that id in the manifest.colorcan 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/cancelPOST /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/retryReturns 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/k1793xk2qyb6p9nw0f8d5c4h3m7s2v6aGET /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.
| Method | Route | Does |
|---|---|---|
POST | /v1/batches | Open 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/:id | The batch record. ?wait=25 holds the request for up to 25 seconds, until the counts change. |
POST | /v1/batches/:id/items | Add 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/export | NDJSON, one line per item: key, id, state, resultUrl, error, cost, versions, metadata. Secret key only. |
POST | /v1/batches/:id/close | Close the batch. It takes no more items. |
POST | /v1/batches/:id/pause | Stop starting new items. Running items finish. |
POST | /v1/batches/:id/resume | Start items again. |
POST | /v1/batches/:id/cancel | Cancel every item that has not finished. |
POST | /v1/batches/:id/retry | Retry 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
}| Field | What it holds |
|---|---|
concurrency | How many renders your plan runs at once. |
inFlight | How many are running now. |
waiting | Your 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. |
staged | Batch items that have not entered the queue yet. |
batches | Your batches that are still open, sealed or paused. |
paceSeconds | The 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
→ completedA 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);