Overview
The Sceneweave API gives your platform the studio's catalog, writing, image and editorial workflows. It is a JSON API over HTTPS. Each API key belongs to one workspace and carries only the scopes its owner granted.
Generation runs asynchronously. You queue a job, then poll it or receive a signed webhook when it finishes. Results are saved as records your editors can review: article ideas, articles and proposed revisions, Japanese translations, generated scenes and product attributes.
A typical integration
- A workspace owner creates a scoped API key in Settings → API & connections.
- Your platform imports stores and products and uploads private product and room photos.
- You queue jobs: ideas, articles, revisions, Japanese translations, images and product metadata.
- You poll
GET /jobs/:idor receive a webhook, then read the saved results. - Editors review and approve in the studio or through the API. You export approved articles and copy image bytes into your own publishing system.
Names, URLs and IDs in the examples are placeholders. … stands for an opaque ID or a value you supply.
Authentication
Create a key
A workspace owner creates keys in the studio under Settings → API & connections → Create key. Choose a name, the scopes the integration needs, optional store restrictions and an optional expiry date; the key stops working at the end of that day in UTC. The token is shown once. Sceneweave stores only its SHA-256 hash and lists the key by its first 16 characters.
Tokens are sw_live_ followed by 43 URL-safe characters. Keep them on your server. The API is designed for server-to-server calls and does not send CORS headers.
Send the key
Send the token in the Authorization header with the Bearer scheme on every request:
curl https://sceneweave.levships.com/api/v1/models \
-H "Authorization: Bearer $SCENEWEAVE_API_KEY"- A missing, malformed, expired or revoked key returns
401 UNAUTHORIZED. - Each key can make 120 requests per minute. Further requests in the same minute return
429 RATE_LIMITEDwithRetry-After: 60. - A request outside the key's scopes returns
403 FORBIDDENwith a message that names the missing scope, for exampleThis API key requires jobs:write.
Scopes
| Scope | Allows |
|---|---|
catalog:read | List stores and products; stores and products in GET /workspace. |
catalog:write | Create, update and import stores and products. metadata jobs also need it. |
assets:read | Download private images with GET /assets/:id; assets in GET /workspace. |
assets:write | Upload images with either upload flow. |
jobs:read | Read and list jobs. Replaying a request with its Idempotency-Key also needs it. |
jobs:write | Queue and cancel jobs. |
content:read | List ideas, articles and images; read and export articles. |
content:write | Create and edit articles, save, restore and approve versions, resolve proposals, set idea status and review images. revision and translation jobs also need it. |
calendar:read | List planned releases. |
calendar:write | Create and remove planned releases. |
GET /models and GET /workspace accept any valid key. The workspace snapshot leaves out the collections your scopes do not cover.
Store restrictions
A key limited to selected stores can only read and change records in those stores. It cannot create stores, must send storeId when it uploads images, and cannot read images uploaded without a store. Lists and the workspace snapshot only include its stores. Anything else returns 403 FORBIDDEN.
Expiry, revocation and rotation
An expired key, or one an owner revoked in Settings, returns 401 UNAUTHORIZED from its next request. To rotate a key, create a new one, deploy it, then revoke the old key.
Studio sessions and API keys
The studio calls the same /api/v1 endpoints with the signed-in editor's session and chooses a workspace with the X-Workspace-Id header. Session requests that change data must come from the studio itself; cross-site requests are rejected with 403. API keys always act in their own workspace, ignore X-Workspace-Id, and have editor permissions limited by their scopes.
Quickstart
This walkthrough uploads a product photo, creates a product, commissions an article and reads the result. You need a key with catalog:read, catalog:write, assets:write, jobs:write, jobs:read and content:read, a store, and a workspace with generation activated. Article jobs usually take several minutes.
1. Set up
# Requires curl and jq.
export SCENEWEAVE_API_KEY="sw_live_…" # shown once when the key is created
# Find the store to work in (or create one with POST /stores)
curl -sS https://sceneweave.levships.com/api/v1/stores \
-H "Authorization: Bearer $SCENEWEAVE_API_KEY" | jq '.data.items[] | {id, name}'
export STORE_ID="…" # one of the IDs above2. Upload a photo
The two-step upload sends the bytes straight to storage. Private uploads explains each call.
FILE=vase.jpg # PNG, JPEG, WebP or GIF up to 20 MB
TYPE=image/jpeg
SIZE=$(wc -c < "$FILE" | tr -d ' ')
SHA256=$(shasum -a 256 "$FILE" | cut -d ' ' -f 1) # or: sha256sum "$FILE"
# 1. Reserve a one-time upload bound to the file's size and hash (valid for 15 minutes)
UPLOAD=$(curl -sS https://sceneweave.levships.com/api/v1/assets/upload-url \
-H "Authorization: Bearer $SCENEWEAVE_API_KEY" \
-H "Content-Type: application/json" \
-d "$(jq -n --arg f "$FILE" --arg t "$TYPE" --argjson s "$SIZE" --arg h "$SHA256" --arg store "$STORE_ID" \
'{filename: $f, contentType: $t, size: $s, sha256: $h, storeId: $store}')")
echo "$UPLOAD"
# 2. POST the raw bytes to the storage URL. Send the Content-Type header only: no API key.
STORAGE_ID=$(curl -sS -X POST "$(jq -r .data.uploadUrl <<<"$UPLOAD")" \
-H "Content-Type: $TYPE" \
--data-binary @"$FILE" | jq -r .storageId)
# 3. Finalize. Sceneweave re-reads the bytes and checks the hash, type and dimensions.
ASSET=$(curl -sS https://sceneweave.levships.com/api/v1/assets/complete \
-H "Authorization: Bearer $SCENEWEAVE_API_KEY" \
-H "Content-Type: application/json" \
-d "$(jq -n --arg u "$(jq -r .data.uploadId <<<"$UPLOAD")" --arg s "$STORAGE_ID" \
'{uploadId: $u, storageId: $s}')")
echo "$ASSET"
ASSET_ID=$(jq -r .data.id <<<"$ASSET")3. Create the product
PRODUCT=$(curl -sS https://sceneweave.levships.com/api/v1/products \
-H "Authorization: Bearer $SCENEWEAVE_API_KEY" \
-H "Content-Type: application/json" \
-d "$(jq -n --arg store "$STORE_ID" --arg asset "$ASSET_ID" '{
storeId: $store,
externalId: "sku-1042",
name: "Cobalt glaze studio vase",
description: "Hand-thrown stoneware vase with a cobalt glaze.",
url: "https://shop.example.com/products/sku-1042",
imageAssetIds: [$asset],
available: true
}')")
echo "$PRODUCT"
PRODUCT_ID=$(jq -r .data.id <<<"$PRODUCT")4. Queue an article job
The response is 202 Accepted with the queued job. The Idempotency-Key makes a retried request return this job instead of starting another.
JOB=$(curl -sS https://sceneweave.levships.com/api/v1/jobs \
-H "Authorization: Bearer $SCENEWEAVE_API_KEY" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: article-sku-1042-care-guide" \
-d "$(jq -n --arg store "$STORE_ID" --arg product "$PRODUCT_ID" '{
kind: "article",
storeId: $store,
input: {
title: "Living with studio pottery",
brief: "A practical guide to choosing, displaying and caring for hand-thrown vases.",
productIds: [$product],
maxCostUsd: 6
}
}')")
echo "$JOB"
JOB_ID=$(jq -r .data.id <<<"$JOB"){
"data": {
"id": "…",
"workspaceId": "…",
"storeId": "…",
"kind": "article",
"input": {},
"model": "openai/gpt-5.6-terra",
"status": "queued",
"stage": "Queued",
"progress": 0,
"attempt": 0,
"estimatedCostUsd": 6,
"costUsd": 0,
"createdAt": 1790000000000,
"updatedAt": 1790000000000
}
}5. Wait for the result
while true; do
JOB=$(curl -sS "https://sceneweave.levships.com/api/v1/jobs/$JOB_ID" \
-H "Authorization: Bearer $SCENEWEAVE_API_KEY")
STATUS=$(jq -r .data.status <<<"$JOB")
echo "$STATUS · $(jq -r .data.stage <<<"$JOB") · $(jq -r .data.progress <<<"$JOB")%"
case "$STATUS" in succeeded|failed|cancelled|null) break ;; esac
sleep 15
done
jq .data <<<"$JOB"{
"data": {
"id": "…",
"kind": "article",
"model": "openai/gpt-5.6-terra",
"status": "succeeded",
"stage": "Complete",
"progress": 100,
"attempt": 1,
"result": {
"articleId": "…"
},
"completedAt": 1790000420000
}
}6. Read the article
ARTICLE_ID=$(jq -r .data.result.articleId <<<"$JOB")
curl -sS "https://sceneweave.levships.com/api/v1/articles/$ARTICLE_ID" \
-H "Authorization: Bearer $SCENEWEAVE_API_KEY" \
| jq '.data | {id, title, status, version, currentRevisionId, excerpt}'
curl -sS "https://sceneweave.levships.com/api/v1/articles/$ARTICLE_ID/export?format=html" \
-H "Authorization: Bearer $SCENEWEAVE_API_KEY" \
-o article.htmlThe same flow in TypeScript
A complete script for Node.js 18 or later, with no dependencies. Save it, set SCENEWEAVE_API_KEY and SCENEWEAVE_STORE_ID, and run it with npx tsx sceneweave-quickstart.ts ./vase.jpg.
// Uploads a product photo, creates a product, commissions an article and prints it.
// Run: SCENEWEAVE_API_KEY=sw_live_… SCENEWEAVE_STORE_ID=… npx tsx sceneweave-quickstart.ts ./vase.jpg
import { createHash, randomUUID } from "node:crypto";
import { readFile } from "node:fs/promises";
import { basename, extname } from "node:path";
const API = "https://sceneweave.levships.com/api/v1";
const apiKey = requireEnv("SCENEWEAVE_API_KEY");
const storeId = requireEnv("SCENEWEAVE_STORE_ID");
type JobStatus = "queued" | "running" | "succeeded" | "failed" | "cancelled";
type Job = {
id: string;
status: JobStatus;
stage: string;
progress: number;
model?: string;
result?: { articleId?: string; proposalId?: string; ideaIds?: string[]; imageIds?: string[]; productId?: string };
error?: string;
};
type Asset = { id: string; filename: string; size: number };
type Product = { id: string; name: string };
type Article = { id: string; title: string; status: string; version: number; currentRevisionId: string | null; excerpt: string };
class SceneweaveApiError extends Error {
status: number;
code: string;
constructor(status: number, code: string, message: string) {
super(`Sceneweave ${status} ${code}: ${message}`);
this.status = status;
this.code = code;
}
}
function requireEnv(name: string): string {
const value = process.env[name];
if (!value) throw new Error(`Set ${name} first.`);
return value;
}
async function sceneweave<T>(path: string, options: { method?: string; body?: unknown; headers?: Record<string, string> } = {}): Promise<T> {
const response = await fetch(`${API}${path}`, {
method: options.method ?? (options.body === undefined ? "GET" : "POST"),
headers: {
Authorization: `Bearer ${apiKey}`,
...(options.body === undefined ? {} : { "Content-Type": "application/json" }),
...options.headers,
},
body: options.body === undefined ? undefined : JSON.stringify(options.body),
});
const payload = (await response.json().catch(() => null)) as { data?: T; error?: { code: string; message: string } } | null;
if (!response.ok || payload?.data === undefined) {
throw new SceneweaveApiError(response.status, payload?.error?.code ?? "HTTP_ERROR", payload?.error?.message ?? response.statusText);
}
return payload.data;
}
const IMAGE_TYPES: Record<string, string> = { ".jpg": "image/jpeg", ".jpeg": "image/jpeg", ".png": "image/png", ".webp": "image/webp", ".gif": "image/gif" };
async function uploadImage(path: string): Promise<Asset> {
const contentType = IMAGE_TYPES[extname(path).toLowerCase()];
if (!contentType) throw new Error("Use a JPEG, PNG, WebP or GIF file.");
const bytes = new Uint8Array(await readFile(path));
const sha256 = createHash("sha256").update(bytes).digest("hex");
// 1. Reserve a one-time upload bound to this file's size and hash (valid for 15 minutes).
const upload = await sceneweave<{ uploadId: string; uploadUrl: string }>("/assets/upload-url", {
body: { filename: basename(path), contentType, size: bytes.byteLength, sha256, storeId },
});
// 2. POST the raw bytes to storage. Send the Content-Type header only, never your API key.
const stored = await fetch(upload.uploadUrl, { method: "POST", headers: { "Content-Type": contentType }, body: bytes });
if (!stored.ok) throw new Error(`Storage upload failed with HTTP ${stored.status}.`);
const { storageId } = (await stored.json()) as { storageId: string };
// 3. Finalize. Sceneweave re-reads the bytes and verifies the hash, type and dimensions.
return sceneweave<Asset>("/assets/complete", { body: { uploadId: upload.uploadId, storageId } });
}
const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms));
async function waitForJob(id: string): Promise<Job> {
let delay = 5_000;
for (;;) {
const job = await sceneweave<Job>(`/jobs/${id}`);
console.log(`${job.status} · ${job.stage} · ${job.progress}%`);
if (job.status === "succeeded" || job.status === "failed" || job.status === "cancelled") return job;
await sleep(delay);
delay = Math.min(delay * 2, 30_000); // 5 s, 10 s, 20 s, then every 30 s
}
}
async function main() {
const file = process.argv[2];
if (!file) throw new Error("Usage: npx tsx sceneweave-quickstart.ts ./product-photo.jpg");
const asset = await uploadImage(file);
console.log(`Uploaded ${asset.filename} as ${asset.id}`);
// Reusing an externalId in the same store updates that product instead of duplicating it.
const product = await sceneweave<Product>("/products", {
body: {
storeId,
externalId: "sku-1042",
name: "Cobalt glaze studio vase",
description: "Hand-thrown stoneware vase with a cobalt glaze.",
imageAssetIds: [asset.id],
available: true,
},
});
// Store the key with your own record and send it again on retries: a retry returns the same job.
const idempotencyKey = randomUUID();
const queued = await sceneweave<Job>("/jobs", {
headers: { "Idempotency-Key": idempotencyKey },
body: {
kind: "article",
storeId,
input: {
title: "Living with studio pottery",
brief: "A practical guide to choosing, displaying and caring for hand-thrown vases.",
productIds: [product.id],
maxCostUsd: 6,
},
},
});
console.log(`Queued job ${queued.id} on ${queued.model ?? "the default model"}`);
const job = await waitForJob(queued.id);
if (job.status !== "succeeded" || !job.result?.articleId) throw new Error(`Job ${job.status}: ${job.error ?? "no article"}`);
const article = await sceneweave<Article>(`/articles/${job.result.articleId}`);
console.log(`\n${article.title} (${article.status}, version ${article.version})\n${article.excerpt}`);
}
main().catch((error: unknown) => {
console.error(error instanceof Error ? error.message : error);
process.exitCode = 1;
});Ephemeral jobs
Generate article ideas, full articles and product scenes in a single request, with nothing to set up first. Send the store's voice, the products and their photos inline; Sceneweave runs the same research, writing and image pipeline as for catalog jobs and returns the results inside the job when it finishes. Nothing is added to your catalog, and everything the request created is deleted when its retention period ends.
POST /jobs
room, products and photos inline
GET /jobs/{id}
poll until it finishes
data.outputs
ideas, article or scenes
| Catalog jobs | Ephemeral jobs | |
|---|---|---|
| Before the first job | Create a store, sync products and upload photos | Nothing |
| Products and photos | Referenced by ID | Sent in the request, photos by URL or inline |
| Results | Saved records, read by ID from result | Returned in data.outputs |
| Kept | Until you delete them | 1–720 hours, then deleted |
| Best for | Editorial work reviewed in the studio, daily ideas, repeat generation | Tight integrations, one-off generation, trying the API |
A product scene in one call
POST/jobs
Add an ephemeral object to an ordinary job request. This one places a vase into a living room and asks for two variations. The placement is planned automatically from the photos unless you write it yourself (see Images).
{
"kind": "image",
"input": {
"mode": "place",
"variants": 2,
"model": "microsoft/mai-image-2.6",
"maxCostUsd": 1
},
"ephemeral": {
"products": [
{
"ref": "sku-1042",
"name": "Celadon ribbed vase",
"description": "Hand-thrown stoneware vase with a pale celadon glaze, 28 cm tall.",
"url": "https://your-marketplace.example/items/sku-1042",
"images": [
{
"url": "https://cdn.your-marketplace.example/sku-1042/front.jpg"
}
],
"metadata": {
"heightCm": 28
}
}
],
"baseImage": {
"url": "https://cdn.your-marketplace.example/rooms/living-room.jpg"
},
"retentionHours": 24
}
}# 1. One call: the room, the products and their photos. Nothing is added to your catalog.
JOB_ID=$(curl -sS https://sceneweave.levships.com/api/v1/jobs \
-H "Authorization: Bearer $SCENEWEAVE_API_KEY" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: scene-sku-1042-living-room" \
-d @scene-request.json | jq -r .data.id)
# 2. Wait. A finished ephemeral job carries its results in data.outputs.
until curl -sS "https://sceneweave.levships.com/api/v1/jobs/$JOB_ID" \
-H "Authorization: Bearer $SCENEWEAVE_API_KEY" -o job.json &&
jq -e '.data.status | IN("succeeded", "failed", "cancelled")' job.json >/dev/null; do
sleep 10
done
# 3. Download every scene (the URLs need your API key)
jq -r '.data.outputs.images[] | "\(.id) \(.url)"' job.json | while read -r id url; do
curl -sS "$url" -H "Authorization: Bearer $SCENEWEAVE_API_KEY" -o "scene-$id.png"
doneWhen the job has succeeded, GET /jobs/{id} includes outputs. Each scene has a download URL, the products it shows with your ref, and the placement brief that produced it.
{
"data": {
"id": "…",
"kind": "image",
"status": "succeeded",
"model": "microsoft/mai-image-2.6",
"ephemeral": true,
"expiresAt": 1790086400000,
"costUsd": 0.19,
"result": {
"imageIds": ["…", "…"]
},
"outputs": {
"images": [
{
"id": "…",
"label": "Vase on the console's left third",
"mode": "place",
"assetId": "…",
"url": "https://sceneweave.levships.com/api/v1/assets/…",
"accepted": false,
"products": [
{
"id": "…",
"ref": "sku-1042",
"name": "Celadon ribbed vase",
"url": "https://your-marketplace.example/items/sku-1042"
}
],
"placement": "Celadon ribbed vase rests on the oak console at its left third, about 28 cm tall, roughly a quarter of the console's height…",
"placementSource": "auto",
"model": "microsoft/mai-image-2.6"
}
]
}
}
}Request fields
| Field | Type | Notes |
|---|---|---|
kind | string | ideas, article or image. |
input | object | The same input as a catalog job of that kind, without IDs: Sceneweave fills in productIds and baseAssetId. input.model, input.maxCostUsd and input.autoPlacement work as usual. |
ephemeral.products | object[] | Required. 1–20 products, described below. Every product in a place or replace scene needs at least one image. |
ephemeral.baseImage | image | Required for image jobs, and only for them: the room or interior photo. |
ephemeral.store | object | Optional name, voice and audience that shape ideas and articles. Default: an unnamed store with no voice guide. |
ephemeral.retentionHours | integer | How long the request's records and results are kept: 1–720 hours. Default 168 (7 days). |
| Field | Type | Notes |
|---|---|---|
name | string | Required. |
ref | string | Your own ID, such as a SKU. Echoed back as ref wherever the product appears in the outputs. Unique within the request. |
description | string | What the writer and image model may rely on: materials, size, maker, condition. Facts not given here are never invented. |
url | string | Public HTTPS product page. Articles link product mentions to it. |
images | image[] | Up to 4. Each is {"url": "https://…"} or {"dataUrl": "data:image/png;base64,…"}. |
metadata | object | Known attributes, such as heightCm or era. Treated as confirmed facts; sizes help scenes use a realistic scale. |
Image URLs must be public HTTPS addresses. Sceneweave downloads them from its servers, following at most three redirects and refusing private or local addresses; each image can be up to 20 MB (PNG, JPEG, WebP or GIF). Inline dataUrl images count toward the 2 MB request limit, so use URLs for full-size photos.
Articles and ideas
Writing jobs take the same inline products; ephemeral.store sets the voice and audience the writer follows. Articles need input.title; everything else in the article and ideas inputs (see Jobs) is optional.
{
"kind": "article",
"input": {
"title": "Living with celadon: five quiet ways to display one vase",
"brief": "Practical styling for small homes. 700–900 words, warm but precise.",
"model": "openai/gpt-5.6-terra"
},
"ephemeral": {
"store": {
"name": "Kiln & Co",
"voice": "Calm, precise and never salesy.",
"audience": "First-time collectors of studio ceramics"
},
"products": [
{
"ref": "sku-1042",
"name": "Celadon ribbed vase",
"description": "Hand-thrown stoneware vase with a pale celadon glaze, 28 cm tall.",
"url": "https://your-marketplace.example/items/sku-1042",
"images": [
{
"url": "https://cdn.your-marketplace.example/sku-1042/front.jpg"
}
]
}
]
}
}{
"article": {
"id": "…",
"title": "Living with celadon: five quiet ways to display one vase",
"excerpt": "One well-placed vase can settle a room…",
"locale": "en",
"status": "review",
"currentRevisionId": "…",
"content": {
"type": "doc",
"content": [
{
"type": "paragraph",
"content": [
{
"type": "text",
"text": "One well-placed vase can settle a room…"
}
]
}
]
},
"html": "<article><h1>Living with celadon…</h1>…</article>",
"sources": [
{
"url": "https://museum.example/ceramics/celadon",
"title": "Celadon glazes",
"evidence": "Glaze history and care guidance."
}
],
"products": [
{
"id": "…",
"ref": "sku-1042",
"name": "Celadon ribbed vase",
"url": "https://your-marketplace.example/items/sku-1042"
}
]
}
}The article arrives as editor-ready Tiptap JSON in content and as HTML with product mentions linked to each product's url. Its sources are the pages the research actually read.
{
"kind": "ideas",
"input": {
"count": 3,
"direction": "Small-space styling for spring",
"model": "openai/gpt-5.6-luna"
},
"ephemeral": {
"store": {
"name": "Kiln & Co",
"voice": "Calm and precise.",
"audience": "Urban renters who collect ceramics"
},
"products": [
{
"ref": "sku-1042",
"name": "Celadon ribbed vase",
"images": [
{
"url": "https://cdn.your-marketplace.example/sku-1042/front.jpg"
}
]
},
{
"ref": "sku-2210",
"name": "Ash-glazed serving bowl",
"description": "Wide, shallow stoneware bowl, 32 cm."
}
],
"retentionHours": 6
}
}{
"ideas": [
{
"id": "…",
"title": "One vase, three rooms: moving a single piece through the seasons",
"angle": "Rotate one statement vessel instead of buying more.",
"rationale": "Fits renters with limited surfaces.",
"sources": [
{
"url": "https://museum.example/display/small-spaces",
"title": "Displaying ceramics in small spaces",
"evidence": "Negative space and grouping guidance."
}
],
"products": [
{
"id": "…",
"ref": "sku-1042",
"name": "Celadon ribbed vase"
}
]
}
]
}A complete client
A typed helper for Node 18 or later: it queues a scene, waits for it and returns the scene URLs.
// Places one product into a room photo and returns the scene download URLs.
// Run: SCENEWEAVE_API_KEY=sw_live_… npx tsx place-product.ts
const API = "https://sceneweave.levships.com/api/v1";
const apiKey = process.env.SCENEWEAVE_API_KEY;
if (!apiKey) throw new Error("Set SCENEWEAVE_API_KEY.");
type Scene = { id: string; label: string; url: string; placement: string | null };
type Job = { id: string; status: "queued" | "running" | "succeeded" | "failed" | "cancelled"; error?: string; outputs?: { images?: Scene[] } };
async function sceneweave<T>(path: string, init: { method?: string; body?: unknown; idempotencyKey?: string } = {}): Promise<T> {
const response = await fetch(`${API}${path}`, {
method: init.method ?? "GET",
headers: { authorization: `Bearer ${apiKey}`, "content-type": "application/json", ...(init.idempotencyKey ? { "idempotency-key": init.idempotencyKey } : {}) },
...(init.body === undefined ? {} : { body: JSON.stringify(init.body) }),
});
const payload = (await response.json()) as { data?: T; error?: { code: string; message: string } };
if (!response.ok || payload.data === undefined) throw new Error(`${response.status} ${payload.error?.code ?? "ERROR"}: ${payload.error?.message ?? "Request failed"}`);
return payload.data;
}
export async function placeProduct(room: string, product: { sku: string; name: string; photo: string; url?: string }): Promise<Scene[]> {
let job = await sceneweave<Job>("/jobs", {
method: "POST",
idempotencyKey: `scene-${product.sku}-${Date.now()}`,
body: {
kind: "image",
input: { mode: "place", model: "microsoft/mai-image-2.6" },
ephemeral: { baseImage: { url: room }, products: [{ ref: product.sku, name: product.name, url: product.url, images: [{ url: product.photo }] }], retentionHours: 24 },
},
});
while (job.status === "queued" || job.status === "running") {
await new Promise((resolve) => setTimeout(resolve, 10_000));
job = await sceneweave<Job>(`/jobs/${job.id}`);
}
if (job.status !== "succeeded") throw new Error(job.error ?? `The job ended as ${job.status}.`);
return job.outputs?.images ?? [];
}
const scenes = await placeProduct("https://cdn.your-marketplace.example/rooms/living-room.jpg", { sku: "sku-1042", name: "Celadon ribbed vase", photo: "https://cdn.your-marketplace.example/sku-1042/front.jpg" });
for (const scene of scenes) console.log(scene.label, scene.url);Outputs
- Succeeded ephemeral jobs include
outputsautomatically. Any other job returns the same shape withGET /jobs/{id}?expand=outputs; passexpand=noneto leave outputs out. outputs.ideas: each idea with its title, angle, rationale, sources and products.outputs.article: title, excerpt,content,html, sources and products. Revision jobs also returnoutputs.proposal, the suggested change.outputs.images: each scene's downloadurl(send your API key with it), its products and theplacementbrief withplacementSource.- Reading outputs needs
content:readas well asjobs:read.
curl -sS "https://sceneweave.levships.com/api/v1/jobs/$JOB_ID?expand=outputs" \
-H "Authorization: Bearer $SCENEWEAVE_API_KEY" | jq '.data.outputs'Retention and privacy
- Ephemeral jobs report
ephemeral: trueandexpiresAt, the time their records will be deleted. - At
expiresAt, Sceneweave deletes the temporary store and products, every photo it stored for the request, and all outputs: ideas, articles with their versions, and scenes. Download or copy anything you want to keep before then. - The job itself stays in your usage history with its cost, but its inputs are removed and it reports
expired: true. It no longer returns outputs, andPOST /jobs/{id}/retryreturns410 EPHEMERAL_EXPIRED. - Until then, temporary records never appear in
GET /products,/ideas,/articles,/imagesor the studio catalog. The jobs appear in job lists as temporary API requests.
Limits and permissions
| Limit | Value |
|---|---|
| Products per request | 1–20 |
| Images per product | Up to 4 |
| Image size | 20 MB by URL; inline data counts toward the 2 MB request limit |
| Retention | 1–720 hours, default 168 |
| Scopes | jobs:write to create, jobs:read to poll, content:read for outputs. Keys restricted to specific stores cannot create ephemeral jobs. |
| Everything else | Same as catalog jobs: models and capabilities, input.maxCostUsd, monthly budgets, concurrency, Idempotency-Key and retries |
Conventions
Requests
- Base URL:
https://sceneweave.levships.com/api/v1. All traffic is HTTPS. - Send JSON bodies with
Content-Type: application/json. A body must be a JSON object; an empty body counts as{}. Anything else returns400 INVALID_JSON. - JSON bodies are limited to 2 MB (
413 REQUEST_TOO_LARGE). Split larger imports into several requests. - IDs are opaque strings. Store them as given; do not parse or construct them.
- Timestamps are Unix epoch milliseconds in UTC, for example
createdAt,scheduledAtandexpiresAt. The one exception is thetvalue in webhook signatures, which is in seconds.
Responses
- Success:
{"data": …}. Errors:{"error": {"code": "…", "message": "…"}}. Branch oncode;messageis readable and safe to show to editors. - Status codes:
200for reads and updates,201for created records and uploads,202for queued jobs. - Responses are sent with
Cache-Control: no-store. The public OpenAPI document is the exception: it may be cached for five minutes.
{
"error": {
"code": "FORBIDDEN",
"message": "This API key requires jobs:write."
}
}Pagination
List endpoints return {"items": [...], "nextCursor": …} with the newest records first. Set limit from 1 to 100 (default 50) and pass nextCursor back as cursor until it is null. On products, ideas, articles, images, jobs and schedules, storeId filters to one store.
CURSOR=""
while :; do
PAGE=$(curl -sS -G https://sceneweave.levships.com/api/v1/products \
-H "Authorization: Bearer $SCENEWEAVE_API_KEY" \
--data-urlencode "storeId=$STORE_ID" \
--data-urlencode "limit=100" \
${CURSOR:+--data-urlencode "cursor=$CURSOR"})
jq -c '.data.items[] | {id, name, available}' <<<"$PAGE"
CURSOR=$(jq -r '.data.nextCursor // empty' <<<"$PAGE")
[ -z "$CURSOR" ] && break
done{
"data": {
"items": [
{
"id": "…",
"workspaceId": "…",
"storeId": "…",
"name": "Cobalt glaze studio vase",
"available": true,
"createdAt": 1790000000000,
"updatedAt": 1790000000000
}
],
"nextCursor": "…"
}
}The workspace snapshot
GET /workspace returns settings, this month's usage, the model catalog and recent records: up to 200 stores, 500 products, 200 assets, 200 ideas, 100 articles, 100 images, 100 jobs and 300 schedules. Use the paginated lists when you need everything.
Stores, products, ideas and images have no single-record read endpoint. Find them in their lists, filtered by storeId, or in the snapshot.
Errors
Every error has an HTTP status and a stable code. Validation failures return INVALID_REQUEST with up to five problems in the message, each as path: message:
{
"error": {
"code": "INVALID_REQUEST",
"message": "input.maxCostUsd: The spending cap cannot exceed this job's default limit of $8."
}
}| Code | Status | Meaning | What to do |
|---|---|---|---|
INVALID_REQUEST | 400 | A required field is missing or a value is out of range. Validation messages list up to five problems as path: message. | Fix the listed fields. Retrying the same request will fail again. |
INVALID_JSON | 400 | The request body is not a JSON object. | Send a JSON object with Content-Type: application/json. |
INVALID_IMAGE | 400 | The file is not a PNG, JPEG, WebP or GIF under 20 MB, its bytes do not match its type, or it exceeds 100 megapixels or 25,000 px per side. | Re-encode or resize the image and upload it again. |
INVALID_UPLOAD | 400 | The stored bytes do not match the size or SHA-256 declared when the upload started. | Start a new upload with the file's exact size and hash. |
INVALID_SCHEMA | 400 | The product metadata schema cannot be compiled or is larger than 50,000 characters. | Send a valid JSON Schema (draft-07) with inline definitions. |
INVALID_METADATA | 400 | Product metadata does not satisfy its schema. The message includes the validation errors. | Correct the metadata or the schema. |
INVALID_REFERENCE | 400 | A referenced record belongs to another store or article, such as a product, photo, idea, mask or saved version. | Reference records from the same store or article. |
INVALID_DOCUMENT | 400 | Article content is not a supported document: an unknown node or mark, a non-HTTPS link, an external image source, or too large. | Use the document format described under Articles and versions. |
IMAGE_NOT_ACCEPTED | 400 | The article references a generated image that has not been accepted. | Accept the image with POST /images/:id/accept, then save the article. |
MISSING_IMAGE | 400 | A metadata job targets a product without photos. | Add imageAssetIds or imageUrls to the product first. |
INVALID_URL | 400 | The webhook URL is not a public HTTPS endpoint, or its hostname does not resolve to public addresses. | Use a public HTTPS URL on the default port. |
UNAUTHORIZED | 401 | The API key is missing, malformed, expired or revoked, or the session has ended. | Send Authorization: Bearer sw_live_… with an active key. |
FORBIDDEN | 403 | The key lacks a scope, the record is in a store the key cannot access, the endpoint needs an owner session, or a session request came from another origin. | Read the message: it names the missing scope or restriction. |
GENERATION_DISABLED | 403 | Generation is not activated for this workspace, so jobs and daily ideas cannot start. | Ask the workspace owner to arrange activation. Retrying will not help until then. |
ALLOWANCE_EXCEEDED | 403 | The requested monthly budget is above the workspace's approved allowance. | Choose a budget within the approved allowance. |
NOT_FOUND | 404 | The endpoint does not exist, or the record is not in this workspace. | Check the method, path and ID. |
REVISION_CONFLICT | 409 | The article changed after the version you sent in expectedRevisionId and expectedVersion. | Fetch the article, reapply your change and resend with its current currentRevisionId and version. |
UNSAVED_DRAFT | 409 | Approval requires the current draft to be a saved version. | Save a version (saveVersion: true), then approve with the new currentRevisionId. |
TRANSLATION_STALE | 409 | The English original changed after this translation's source version. | Translate the latest saved English version and accept the resulting proposal. |
PRODUCT_UNAVAILABLE | 409 | The article references a product marked unavailable. | Remove the reference or mark the product available. |
PROPOSAL_RESOLVED | 409 | The proposal was already accepted or rejected. | Reload the article to see its current state. |
IDEMPOTENCY_CONFLICT | 409 | The Idempotency-Key was already used for a different job request in this workspace. | Use a new key for a new request. |
INVALID_STATE | 409 | Only failed or cancelled jobs can be retried. | Wait for the job to finish or cancel it first. A succeeded job's result is already saved. |
UPLOAD_EXPIRED | 409 | The upload is older than 15 minutes or was started by a different key or user. | Start a new upload. |
EPHEMERAL_EXPIRED | 410 | The ephemeral job's retention period ended, so its inputs and outputs were deleted. | Send a new ephemeral job. Keep results you need by saving them when the job succeeds. |
REQUEST_TOO_LARGE | 413 | The JSON body exceeds 2 MB, or a direct upload exceeds 20 MB. | Split large imports; use the two-step upload for large files. |
UNSUPPORTED_MODEL | 422 | The model that would run the job cannot handle it: the workspace default when input.model is omitted, or the model sent to a retry. For example, a painted mask with a model that cannot follow masks. | Choose a capable model from GET /models, or change the request. |
MODEL_UNAVAILABLE | 422 | The requested model is not available on this service right now. | Choose an available model from GET /models. |
IMAGE_UNAVAILABLE | 422 | An image URL in an ephemeral job could not be downloaded: it is not public, redirects too often, is too large or is not an image. | Use a public HTTPS image URL (PNG, JPEG, WebP or GIF under 20 MB) or send the image as dataUrl. |
RATE_LIMITED | 429 | The API key made more than 120 requests in one minute. | Wait for Retry-After (60 seconds) before retrying. |
BUDGET_EXCEEDED | 429 | The job's reservation would take the workspace over its monthly generation budget. | Wait for running jobs to settle, lower input.maxCostUsd, or ask the owner to raise the budget. |
STORAGE_LIMIT | 429 | The workspace has reached its 2,000-image or 1 GB storage allowance. | Contact the workspace owner. Retrying will not free space. |
SERVICE_ERROR | 500 | An unexpected error occurred. | Retry with backoff. For POST /jobs, reuse the same Idempotency-Key. |
UPLOAD_FAILED | 502 | Storage did not accept or return the image. | Retry the upload. |
ASSET_UNAVAILABLE | 502 | The private image bytes could not be read from storage. | Retry shortly. |
SERVICE_UNAVAILABLE | 503 | The workspace service is temporarily unavailable. | Retry with backoff. |
Retrying
- Retry
RATE_LIMITEDafter theRetry-Afterheader, and5xxresponses with exponential backoff. - Every
429carriesRetry-After: 60, includingBUDGET_EXCEEDEDandSTORAGE_LIMIT. For those two, waiting only helps if running jobs release their reservations or someone raises the limit. - Do not retry other
4xxerrors unchanged. - Retry
POST /jobsonly with the sameIdempotency-Key, so a request that succeeded before the connection dropped is not queued twice.
Catalog
Stores hold a voice and an audience; products hold the facts, photos and availability that every job works from. Reads need catalog:read and writes need catalog:write.
Stores
POST/stores
| Field | Type | Notes |
|---|---|---|
name | string | Required. Up to 500 characters. |
externalId | string | Your platform's store ID, up to 250 characters. Creating a store with an existing externalId updates that store. |
url | string | Public HTTPS URL of the storefront. |
voice | string | Voice and style guidance for this store's writing, up to 12,000 characters. |
audience | string | Who the store writes for, up to 4,000 characters. |
dailyIdeasEnabled | boolean | Queue one ideas job a day. Default false. Needs an active generation budget, otherwise 403 GENERATION_DISABLED. |
dailyIdeasHour | integer | 0–23, default 8. The daily job is queued at or after this hour in the workspace time zone. |
curl -sS https://sceneweave.levships.com/api/v1/stores \
-H "Authorization: Bearer $SCENEWEAVE_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"name": "North Kiln Ceramics",
"externalId": "store-118",
"url": "https://shop.example.com/north-kiln",
"voice": "Warm and precise. Explain techniques without jargon.",
"audience": "Collectors furnishing a first home"
}'PATCH /stores/:id changes only the fields you send. GET /stores lists stores. Store-restricted keys cannot create stores.
Products
POST/products
| Field | Type | Notes |
|---|---|---|
storeId | string | Required when creating. A product cannot move between stores. |
name | string | Required. Up to 500 characters. |
externalId | string | Your product ID, up to 250 characters. Creating a product with an existing externalId in the same store updates it. |
description | string | Up to 20,000 characters. |
url | string | Public HTTPS listing URL. Article product references link to it. |
imageAssetIds | string[] | Up to 20 private assets from the same store, or uploaded without a store. See Private uploads. |
imageUrls | string[] | Up to 20 public HTTPS photo URLs, fetched when a job needs them. |
available | boolean | Default true. Unavailable products are left out of new ideas, articles and images, and block approval of articles that reference them. |
metadata | object | Attributes you know. Every key you send is added to manualFields, marked manual in provenance, and preserved when enrichment runs. |
metadataSchema | object | JSON Schema for metadata. See below. |
metadataSchemaVersion | string | Your label for the schema version, up to 80 characters. Default "1". |
manualFields | string[] | Extra metadata field names to protect from enrichment. |
POST /products returns 201 whether it created or updated the product. PATCH /products/:id changes only the fields you send and merges metadata into the existing attributes. GET /products?storeId=… lists a store's products.
Bulk import
POST/products/import
Send 1–200 products in products, each shaped like a POST /products body with its own storeId. The import runs in one transaction: if any item is rejected, nothing is saved. The response is 201 with {"products": [...]} in request order.
curl -sS https://sceneweave.levships.com/api/v1/products/import \
-H "Authorization: Bearer $SCENEWEAVE_API_KEY" \
-H "Content-Type: application/json" \
-d "$(jq -n --arg store "$STORE_ID" '{products: [
{storeId: $store, externalId: "sku-1042", name: "Cobalt glaze studio vase",
imageUrls: ["https://cdn.example.com/sku-1042.jpg"], metadata: {heightCm: 24}},
{storeId: $store, externalId: "sku-1043", name: "Ash glaze tea bowl", available: false}
]}')"Metadata schemas
Give products a JSON Schema (draft-07) in metadataSchema and label it with metadataSchemaVersion. When you send metadata together with a schema, the metadata must satisfy it (400 INVALID_METADATA). In an import, every item that includes a schema is checked, even when its metadata is empty. A schema that cannot compile, or is over 50,000 characters, returns 400 INVALID_SCHEMA.
- Keep definitions inline and reference them with
#/…. Remote$refURLs are not loaded. - Allow
nullfor facts you might not know, so enrichment can leave them unknown instead of guessing.
{
"metadataSchemaVersion": "vessels-2",
"metadataSchema": {
"type": "object",
"additionalProperties": false,
"required": ["form", "dominantColors", "glaze"],
"properties": {
"form": {
"type": ["string", "null"],
"enum": ["vase", "bowl", "jar", "plate", null]
},
"dominantColors": {
"type": "array",
"items": {
"type": "string"
},
"maxItems": 5
},
"glaze": {
"type": ["string", "null"],
"description": "Only when visible or supplied by the seller"
}
}
}
}A metadata job describes a product from up to four of its photos. It uses input.schema when given, then the product's metadataSchema, then Sceneweave's default schema (productType, category, style, designPeriod, dominantColors, motifs, materialAppearance, description). Each generated value gets a provenance entry of observed or inferred with a confidence and evidence. Fields about era, period, date, age, maker, designer, origin, provenance, authenticity or material are always inferred: appearance never becomes a verified fact.
Private uploads
Photos are private to the workspace. Upload them, then reference the returned asset ID from products, image jobs and articles. Uploads need assets:write; downloads need assets:read.
| Limit | Value |
|---|---|
| Formats | PNG, JPEG, WebP, GIF. Sceneweave checks the bytes and dimensions, not the filename. |
| File size | Up to 20 MB. Room photos, product photos and masks used by jobs must be under 12 MB. |
| Dimensions | Up to 100 megapixels and 25,000 px per side. |
| Workspace allowance | 2,000 images or 1 GB in total (429 STORAGE_LIMIT). |
| Upload window | Complete a two-step upload within 15 minutes, with the same key. |
Two-step upload (recommended)
POST /assets/upload-urlwithfilename,contentType, the exactsizein bytes, the lowercase hexsha256of the file and an optionalstoreId. The response is201withuploadIdand a one-timeuploadUrl.POSTthe raw bytes touploadUrlwith only the imageContent-Typeheader. Do not send your API key there. The response is{"storageId": "…"}.POST /assets/completewithuploadIdandstorageId. Sceneweave re-reads the stored bytes, checks the hash, type, size and dimensions, and returns theAssetwith201. Completing the same upload again returns the same asset.
FILE=vase.jpg # PNG, JPEG, WebP or GIF up to 20 MB
TYPE=image/jpeg
SIZE=$(wc -c < "$FILE" | tr -d ' ')
SHA256=$(shasum -a 256 "$FILE" | cut -d ' ' -f 1) # or: sha256sum "$FILE"
# 1. Reserve a one-time upload bound to the file's size and hash (valid for 15 minutes)
UPLOAD=$(curl -sS https://sceneweave.levships.com/api/v1/assets/upload-url \
-H "Authorization: Bearer $SCENEWEAVE_API_KEY" \
-H "Content-Type: application/json" \
-d "$(jq -n --arg f "$FILE" --arg t "$TYPE" --argjson s "$SIZE" --arg h "$SHA256" --arg store "$STORE_ID" \
'{filename: $f, contentType: $t, size: $s, sha256: $h, storeId: $store}')")
echo "$UPLOAD"
# 2. POST the raw bytes to the storage URL. Send the Content-Type header only: no API key.
STORAGE_ID=$(curl -sS -X POST "$(jq -r .data.uploadUrl <<<"$UPLOAD")" \
-H "Content-Type: $TYPE" \
--data-binary @"$FILE" | jq -r .storageId)
# 3. Finalize. Sceneweave re-reads the bytes and checks the hash, type and dimensions.
ASSET=$(curl -sS https://sceneweave.levships.com/api/v1/assets/complete \
-H "Authorization: Bearer $SCENEWEAVE_API_KEY" \
-H "Content-Type: application/json" \
-d "$(jq -n --arg u "$(jq -r .data.uploadId <<<"$UPLOAD")" --arg s "$STORAGE_ID" \
'{uploadId: $u, storageId: $s}')")
echo "$ASSET"
ASSET_ID=$(jq -r .data.id <<<"$ASSET"){
"data": {
"id": "…",
"workspaceId": "…",
"storeId": "…",
"filename": "vase.jpg",
"contentType": "image/jpeg",
"size": 482113,
"createdAt": 1790000000000,
"url": "/api/v1/assets/…?workspaceId=…"
}
}Direct multipart upload
POST /assets accepts multipart/form-data with a file part and an optional storeId, and returns the same Asset. Set the file part's content type explicitly. The file passes through the application server, where the hosting platform caps request bodies at about 4.5 MB, so use the two-step upload for anything larger.
curl -sS https://sceneweave.levships.com/api/v1/assets \
-H "Authorization: Bearer $SCENEWEAVE_API_KEY" \
-F "file=@vase.jpg;type=image/jpeg" \
-F "storeId=$STORE_ID"Downloading
An asset's url is a relative private path such as /api/v1/assets/…?workspaceId=…. Request it on https://sceneweave.levships.com with your key. Sceneweave returns the image bytes with their content type.
curl -sS "https://sceneweave.levships.com/api/v1/assets/$ASSET_ID" \
-H "Authorization: Bearer $SCENEWEAVE_API_KEY" \
-o vase-copy.jpgJobs
Research, writing, translation, image generation and product analysis run as jobs. Queueing a job returns 202 Accepted straight away; the work continues in the background. Creating and cancelling jobs needs jobs:write; reading them needs jobs:read.
Lifecycle
queued: waiting to start. Jobs also wait here while the workspace is already running its concurrency limit of jobs (2 unless the owner changes it).running:stagenames the current step andprogressrises toward 100.succeeded:resultreferences the saved records.failed:errorexplains what went wrong.cancelled: stopped withPOST /jobs/:id/cancel.
Sceneweave retries temporary problems itself. An interrupted attempt, or one that hits a briefly unavailable provider, returns the job to queued with a stage such as Waiting to retry. The job keeps the previous attempt's error while it waits, so treat error as final only when status is failed. Each job gets at most three attempts and 120 model or research requests; reaching its spending cap or request allowance fails it without another attempt. Ideas, article and image jobs need at least one available product in the store. To run a failed or cancelled job again, retry it.
Create a job
POST/jobs
The body has kind, storeId and input. To skip the catalog entirely, send the products inline instead: see Ephemeral jobs. storeId is required for ideas, article and image jobs; revision, translation and metadata jobs take it from the article or product. Revision and translation jobs also need content:write, and metadata jobs also need catalog:write.
JOB=$(curl -sS https://sceneweave.levships.com/api/v1/jobs \
-H "Authorization: Bearer $SCENEWEAVE_API_KEY" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: article-sku-1042-care-guide" \
-d "$(jq -n --arg store "$STORE_ID" --arg product "$PRODUCT_ID" '{
kind: "article",
storeId: $store,
input: {
title: "Living with studio pottery",
brief: "A practical guide to choosing, displaying and caring for hand-thrown vases.",
productIds: [$product],
maxCostUsd: 6
}
}')")
echo "$JOB"
JOB_ID=$(jq -r .data.id <<<"$JOB")Idempotency
- Send an
Idempotency-Keyheader with 1–200 printable ASCII characters and no spaces, such as a UUID or your own record ID. Anything else returns400 INVALID_REQUEST. - Keys are unique per workspace, across every API key and studio user, and do not expire.
- Repeating a request with the same key and the same body returns the original job with
202, whatever its status now. The comparison ignores property order. Replays also needjobs:read. - The same key with a different body returns
409 IDEMPOTENCY_CONFLICT. - Without a key, every request queues a new job and reserves more budget.
Polling
GET /jobs/:id returns the job. Poll every 5 seconds at first and back off to every 30 seconds. Articles usually take several minutes, and one attempt can run for up to 30 minutes. Polls count toward the key's 120 requests per minute, so when you run many jobs, use webhooks for completion and keep polling as a fallback.
while true; do
JOB=$(curl -sS "https://sceneweave.levships.com/api/v1/jobs/$JOB_ID" \
-H "Authorization: Bearer $SCENEWEAVE_API_KEY")
STATUS=$(jq -r .data.status <<<"$JOB")
echo "$STATUS · $(jq -r .data.stage <<<"$JOB") · $(jq -r .data.progress <<<"$JOB")%"
case "$STATUS" in succeeded|failed|cancelled|null) break ;; esac
sleep 15
done
jq .data <<<"$JOB"Cancel
curl -sS -X POST "https://sceneweave.levships.com/api/v1/jobs/$JOB_ID/cancel" \
-H "Authorization: Bearer $SCENEWEAVE_API_KEY"Cancelling a queued or running job stops its worker, releases its reservation and returns the job as cancelled. Usage already incurred stays recorded. A finished job is returned unchanged. Cancelling does not send a webhook.
Retry
POST/jobs/:id/retry
Retrying a failed or cancelled job queues a new job with the original's kind, store, input and pinned model, and returns it with 202. The new job's retryOfJobId points to the original, which stays unchanged in your history. Retrying any other job returns 409 INVALID_STATE.
- Send an empty body to keep the pinned model, or
{"model": "…"}to use another model that suits the job's kind and inputs (see Models). A model this service cannot serve right now returns422 MODEL_UNAVAILABLE, and one that does not suit the job's kind or inputs returns422 UNSUPPORTED_MODEL. Nothing is queued in either case. - The same scopes, budget checks and cost cap apply as when the job was first queued. The retry reserves budget again, using the original's
maxCostUsdwhen it had one. - A retried revision applies the same request to the article's current working draft, including edits made since the original failed.
Idempotency-Keyworks as forPOST /jobs: repeating the same retry with the same key returns the same new job.
# Omit the body to repeat the job with its pinned model, or choose another model
# that suits the job's kind: here a writing model, for an article job.
RETRY=$(curl -sS -X POST "https://sceneweave.levships.com/api/v1/jobs/$JOB_ID/retry" \
-H "Authorization: Bearer $SCENEWEAVE_API_KEY" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: retry-$JOB_ID-1" \
-d '{"model": "openai/gpt-5.6-luna"}')
echo "$RETRY"
JOB_ID=$(jq -r .data.id <<<"$RETRY") # poll the new job; .data.retryOfJobId is the originalReading results
| Kind | result | Read it with |
|---|---|---|
ideas | ideaIds | GET /ideas?storeId=…. Each idea also carries its jobId. |
article | articleId | GET /articles/:id. New articles arrive with status review. |
revision | articleId, proposalId | The proposal in GET /articles/:id. See Proposals. |
translation | articleId, plus proposalId when a translation already existed | The Japanese article in GET /articles/:id. See Japanese translations. |
image | imageIds | GET /images?storeId=…, then GET /assets/:assetId for the bytes. |
metadata | productId | GET /products?storeId=…: the product's metadata and provenance. |
| Field | Type | Notes |
|---|---|---|
id | string | Job ID. |
kind | string | ideas, article, revision, translation, image or metadata. |
storeId | string | Store the job works in. |
input | object | Always {} for API keys: prompts, briefs and intermediate work are not returned. |
model | string | Model pinned when the job was queued. |
retryOfJobId | string | Only on a retry: the failed or cancelled job it repeats. See Retry. |
ephemeral, expiresAt, expired | boolean, integer | Only on ephemeral jobs: when the request's records are deleted, and whether that has happened. |
outputs | object | Finished results inline: automatic for ephemeral jobs, or with ?expand=outputs. See Outputs. |
status | string | queued, running, succeeded, failed or cancelled. |
stage | string | Readable label for the current step, such as Researching sources. |
progress | integer | 0–100. |
attempt | integer | Attempts started so far, at most 3. |
result | object | References to the saved records. See the table above. |
error | string | Readable failure message. Final only when status is failed. |
estimatedCostUsd | number | Reservation held against the monthly budget: input.maxCostUsd or the default limit. |
costUsd | number | Recorded model and research cost. |
createdAt, updatedAt, completedAt | integer | Unix milliseconds. completedAt is set when the job reaches a final status. |
Inputs by kind
Every kind also accepts input.model (see Models) and input.maxCostUsd (see Cost controls).
ideas
Researches live sources and proposes article candidates with sources and products, checked against existing ideas and articles for duplicates.
| Field | Type | Notes |
|---|---|---|
count | integer | How many candidates to propose, 1–10. Default 5. |
direction | string | Theme or editorial direction to follow. |
productIds | string[] | Up to 100 products from the store to focus on. Default: the store's available products. |
{
"kind": "ideas",
"storeId": "…",
"input": {
"count": 5,
"direction": "Gifts for people who cook",
"maxCostUsd": 2
}
}article
Prepares a brief, researches, outlines, drafts and runs an independent fact and style review before saving the article. See the Quickstart for a full request.
| Field | Type | Notes |
|---|---|---|
title | string | Working title. Required unless ideaId supplies it. |
brief | string | What to cover: audience, angle, points to include and length. Articles default to 900–1,300 words. |
ideaId | string | An idea from the same store. Fills a missing title, brief and product list from the idea, and marks it commissioned. |
productIds | string[] | Up to 100 products to feature. Default: the store's available products. |
revision
Snapshots the article's working draft when it is queued, including edits not yet saved as a version, and returns a proposal instead of changing the article.
| Field | Type | Notes |
|---|---|---|
articleId | string | Required. The article to revise. |
message | string | Required. The change you want, in plain language. |
expectedRevisionId | string | null | Required. The article's current currentRevisionId. |
expectedVersion | integer | Required. The article's current version. A stale pair returns 409 REVISION_CONFLICT before anything is queued. |
{
"kind": "revision",
"input": {
"articleId": "…",
"message": "Tighten the introduction and add a short care section.",
"expectedRevisionId": "…",
"expectedVersion": 7
}
}translation
| Field | Type | Notes |
|---|---|---|
articleId | string | Required. The English original. |
sourceRevisionId | string | Required. A saved version of that article. |
glossary | any JSON | Optional terminology guidance, for example an object mapping English terms to preferred Japanese. |
{
"kind": "translation",
"input": {
"articleId": "…",
"sourceRevisionId": "…",
"glossary": {
"glaze": "釉薬",
"stoneware": "炻器"
}
}
}image
See Images for the modes and model limits.
| Field | Type | Notes |
|---|---|---|
mode | string | Required: place, replace or suggest. |
baseAssetId | string | Required. The room or interior photo. |
productIds | string[] | Required for place and replace, up to the model's products per scene. For suggest, an optional shortlist to choose from. |
style | string | Interior style to match. Required for suggest. Up to 2,000 characters. |
instructions | string | Up to 8,000 characters. place: your own placement brief, used as written. Omit it and the workspace writing model studies the room and product photos and writes a brief for each variant (see autoPlacement). replace: which object to swap. suggest: notes for the combinations. |
autoPlacement | boolean | Whether the writing model plans the scene before rendering. Defaults to true for replace and for place without instructions, and false when you send your own place brief. The written brief is saved as recipe.placement, with recipe.placementSource and recipe.placementModel. |
targetDescription | string | replace: the object to replace. Defaults to instructions. |
maskAssetId | string | replace: a painted mask. Only models that follow masks. |
parentImageId | string | A generated image this request refines, kept as lineage. |
variants | integer | 1–3 renderings, default 1. suggest always makes 3. |
{
"kind": "image",
"storeId": "…",
"input": {
"mode": "place",
"baseAssetId": "…",
"productIds": ["…", "…"],
"instructions": "Place the vase on the sideboard, left of the window.",
"variants": 2,
"model": "openai/gpt-image-2.5-sunburst"
}
}metadata
| Field | Type | Notes |
|---|---|---|
productId | string | Required. The product must have at least one photo, otherwise 400 MISSING_IMAGE. |
schema | object | JSON Schema for this run. Defaults to the product's metadataSchema, then Sceneweave's default schema. |
{
"kind": "metadata",
"input": {
"productId": "…",
"model": "openai/gpt-6-astra"
}
}Models
GET /models lists the models Sceneweave offers, what each can do and whether it is available right now. Any valid key or session can call it, and GET /workspace includes the same catalog as models. Check available before offering a model; a model can be listed but unavailable, with an unavailableReason.
{
"data": {
"writing": [
{
"id": "openai/gpt-5.6-terra",
"label": "GPT-5.6 Terra",
"provider": "OpenAI",
"summary": "Balanced research and writing quality at a moderate cost. The Sceneweave default.",
"costTier": 2,
"route": "codex-gateway",
"available": true
}
],
"image": [
{
"id": "microsoft/mai-image-2.6",
"label": "MAI-Image-2.6",
"provider": "Microsoft",
"summary": "Photorealistic edits from up to five references: the room and four products. Replacements follow the written description.",
"costTier": 1,
"route": "foundry",
"modes": ["place", "replace", "suggest"],
"masks": false,
"maxProducts": 4,
"available": true
}
],
"metadata": [
{
"id": "openai/gpt-6-astra",
"label": "GPT-6 Astra",
"provider": "OpenAI",
"summary": "OpenAI's most capable model for demanding research and long-form writing.",
"costTier": 3,
"route": "codex-gateway",
"available": true
}
],
"serviceDefaults": {
"writing": "openai/gpt-5.6-terra",
"image": "openai/gpt-image-2.5-sunburst",
"metadata": "openai/gpt-6-astra"
}
}
}Writing models
Used by ideas, article, revision and translation jobs.
| Model | Relative cost | Notes |
|---|---|---|
GPT-5.6 TerraDefaultopenai/gpt-5.6-terra | Medium | Balanced research and writing quality at a moderate cost. The Sceneweave default. |
GPT-5.6 Lunaopenai/gpt-5.6-luna | Lower | The fastest, lowest-cost option for drafts, idea lists and routine edits. |
GPT-6 Astraopenai/gpt-6-astra | Higher | OpenAI's most capable model for demanding research and long-form writing. |
Claude Fable 5.1anthropic/claude-fable-5.1 | Higher | Anthropic's frontier model with a distinct editorial voice. |
Claude Opus 5anthropic/claude-opus-5 | Medium | Anthropic's careful long-form writer. |
Image models
| Model | Modes | Masks | Products per scene | Relative cost |
|---|---|---|---|---|
GPT Image 2.5 SunburstDefaultopenai/gpt-image-2.5-sunburstPrecise editing. The only model that follows painted replacement masks. | place, replace, suggest | Yes | 8 | Higher |
Nano Banana 2google/gemini-3.1-flash-imageFast multi-reference compositions. Replacements follow the written description. | place, replace, suggest | No | 8 | Medium |
MAI-Image-2.6microsoft/mai-image-2.6Photorealistic edits from up to five references: the room and four products. Replacements follow the written description. | place, replace, suggest | No | 4 | Lower |
MAI-Image-2.6 takes up to five reference images: the room photo and four products. It returns PNG scenes, and Sceneweave keeps its web grounding off so private photos are never used for web retrieval. Models without mask support replace the object you describe in targetDescription or instructions.
Metadata jobs accept openai/gpt-6-astra and openai/gpt-5.6-luna. Without input.model they use openai/gpt-6-astra; workspace defaults do not apply to them.
Choosing a model
input.modelon the job, when you send it.- Otherwise the workspace default:
defaultArticleModelfor ideas, article, revision and translation jobs,defaultImageModelfor image jobs. - If the workspace has not chosen one, the Sceneweave default: GPT-5.6 Terra for writing and GPT Image 2.5 Sunburst for images.
The resolved model is pinned when the job is queued and returned as model. Every attempt and retry uses it, and later changes to workspace defaults do not affect queued jobs. In suggest mode, Sceneweave chooses product combinations with the workspace's writing model, then renders them with the job's image model.
When a model cannot be used
| Situation | Response |
|---|---|
input.model is not a model for this kind of job | 400 INVALID_REQUEST naming input.model and the supported IDs |
input.model cannot do what you asked: a mask, too many products or an unsupported mode | 400 INVALID_REQUEST naming input.model and the limit |
You did not send input.model and the workspace default cannot do what you asked | 422 UNSUPPORTED_MODEL |
The model sent to POST /jobs/:id/retry does not suit the job | 422 UNSUPPORTED_MODEL |
input.model is not available on this service right now | 422 MODEL_UNAVAILABLE |
{
"error": {
"code": "INVALID_REQUEST",
"message": "input.model: MAI-Image-2.6 cannot follow painted masks. Use GPT Image 2.5 Sunburst for masked replacement, or remove the mask and describe the object to replace."
}
}If a workspace default becomes unavailable after you queue a job that relies on it, the job fails with a readable error. Send input.model to choose another model.
Workspace defaults
GET /workspace returns the effective defaults as workspace.defaultArticleModel and workspace.defaultImageModel, and the workspace's own choices as articleModelOverride and imageModelOverride. An override is null while the workspace follows the Sceneweave default.
curl -sS https://sceneweave.levships.com/api/v1/workspace \
-H "Authorization: Bearer $SCENEWEAVE_API_KEY" \
| jq '.data.workspace | {defaultArticleModel, defaultImageModel, articleModelOverride, imageModelOverride}'Owners change the defaults in Settings, or with PUT /workspace from a signed-in owner session. Send a model ID to choose it, or null to follow the Sceneweave default again. API keys cannot change workspace defaults: choose a model per request with input.model.
{
"defaultArticleModel": "anthropic/claude-opus-5",
"defaultImageModel": null
}Cost controls
When a job is queued, it reserves a spending ceiling against the workspace's monthly generation budget. When it finishes, the recorded model and research cost replaces the reservation. These limits are ceilings for your control, not prices.
| Kind | Default limit |
|---|---|
ideas | $3 |
article | $8 |
revision | $4 |
translation | $4 |
metadata | $1 |
image | $2 per variant; suggest makes 3, so $6 |
Capping a job with input.maxCostUsd
- Send a finite number above 0 and no higher than the job's default limit. For image jobs the limit depends on
variants. A higher value returns400 INVALID_REQUEST. - The cap replaces the reservation and bounds the job's total model and research usage across every attempt. Retries do not get a fresh allowance.
- Before each model call, Sceneweave reserves a conservative estimate. If it does not fit in what is left, the call is not made and the job fails with
This job has reached its spending or request limit.A small cap can stop a job before it finishes. - The cap covers model and research usage. Hosting and worker compute are recorded separately.
{
"kind": "image",
"storeId": "…",
"input": {
"mode": "place",
"baseAssetId": "…",
"productIds": ["…"],
"variants": 3,
"maxCostUsd": 4.5
}
}The job's estimatedCostUsd shows the reservation and costUsd the recorded cost.
Monthly budget
- A new workspace has no generation allowance. Until Sceneweave activates it,
POST /jobsreturns403 GENERATION_DISABLED. The owner then sets a monthly budget up to the approved allowance. - If this month's spend, plus current reservations, plus the new job's reservation would exceed the budget,
POST /jobsreturns429 BUDGET_EXCEEDED. Months follow the UTC calendar. GET /workspacereportsusage:spentUsd,reservedUsd,monthlyBudgetUsd,activeJobs,totalJobsandbyKind.
curl -sS https://sceneweave.levships.com/api/v1/workspace \
-H "Authorization: Bearer $SCENEWEAVE_API_KEY" | jq .data.usageArticles and versions
An article has a working draft and a history of immutable saved versions. Reading needs content:read; changes need content:write.
The article record
| Field | Type | Notes |
|---|---|---|
title, excerpt | string | Title up to 500 characters; excerpt up to 5,000. |
locale | string | en for originals, ja for Japanese translations. |
status | string | draft, review or approved. Generated articles start in review and manual drafts in draft. Any content change returns an article to draft. |
content | object | Tiptap JSON. See Document format. |
productIds | string[] | Products the article references, including those in its content and accepted scenes. |
sources | object[] | Research sources: url, title, evidence and optional claims. |
version | integer | Increments on every content change, saved or not. |
currentRevisionId | string | null | The latest saved version. |
approvedRevisionId | string | null | The approved saved version. Kept as history after later edits. |
sourceArticleId, sourceRevisionId, translationStale | string, boolean | Japanese translations only. See Japanese translations. |
GET /articles/:id adds revisions (saved versions, up to 100, newest first), messages (revision requests and replies, up to 100, oldest first), proposals (up to 50, newest first), translations and unavailableProductIds. GET /articles lists originals and translations without them.
Document format
content is a Tiptap JSON document: a doc node whose content holds block nodes.
- Nodes:
paragraph,heading,text,bulletList,orderedList,listItem,blockquote,codeBlock,hardBreak,horizontalRule,table,tableRow,tableCell,tableHeader,productReference,sceneImage,productandimage. - Marks:
bold,italic,strike,code,underline,link,textStyleandhighlight. Links must be public HTTPS URLs. - Products: an inline
productReferencewithattrs.productIdfrom the article's store and acaption. - Images: a block
sceneImagewithattrs.assetId(a private asset, or an accepted scene'sassetId),altandcaption. External image URLs are rejected. - Limits: 600 KB serialized, 24 levels deep and 20,000 nodes. Anything else returns
400 INVALID_DOCUMENT.
{
"type": "doc",
"content": [
{
"type": "heading",
"attrs": {
"level": 2
},
"content": [
{
"type": "text",
"text": "Caring for a glazed vase"
}
]
},
{
"type": "paragraph",
"content": [
{
"type": "text",
"text": "Rinse it with lukewarm water and let it dry upside down, like the "
},
{
"type": "productReference",
"attrs": {
"productId": "…",
"caption": "cobalt glaze studio vase"
}
},
{
"type": "text",
"text": ". Read more about "
},
{
"type": "text",
"text": "glaze care",
"marks": [
{
"type": "link",
"attrs": {
"href": "https://example.com/glaze-care"
}
}
]
},
{
"type": "text",
"text": "."
}
]
},
{
"type": "sceneImage",
"attrs": {
"assetId": "…",
"alt": "Cobalt glaze vase on an oak sideboard",
"caption": "Product photograph"
}
}
]
}Create a manual draft
POST/articles
Send storeId, title and content, with optional excerpt, productIds and sources. Sceneweave saves a draft and its first version without generation or cost, and returns 201 with the article.
Edit with optimistic concurrency
PATCH/articles/:id
Send expectedRevisionId (the article's currentRevisionId) and expectedVersion (its version) with any of title, content, excerpt, productIds and sources. If either value is out of date, the request fails with 409 REVISION_CONFLICT and nothing changes.
- Without
saveVersion, the change updates the working draft:versionincrements andcurrentRevisionIdstays the same. - With
"saveVersion": true, the result is also saved as an immutable version andcurrentRevisionIdpoints to it. An optionalreasonof up to 500 characters is stored with it. - Any content change sets
statustodraft. The previously approved version stays inapprovedRevisionIdas history. - Changing an English article marks its Japanese translations stale.
- Always take
expectedRevisionIdandexpectedVersionfrom the latest response.
ARTICLE=$(curl -sS "https://sceneweave.levships.com/api/v1/articles/$ARTICLE_ID" \
-H "Authorization: Bearer $SCENEWEAVE_API_KEY")
curl -sS -X PATCH "https://sceneweave.levships.com/api/v1/articles/$ARTICLE_ID" \
-H "Authorization: Bearer $SCENEWEAVE_API_KEY" \
-H "Content-Type: application/json" \
-d "$(jq '{
expectedRevisionId: .data.currentRevisionId,
expectedVersion: .data.version,
title: "Living with studio pottery: a care guide",
saveVersion: true,
reason: "Title agreed with merchandising"
}' <<<"$ARTICLE")"When you get a conflict, read the article again, reapply your change to the current content and retry once. If it conflicts again, leave the change to an editor.
// Uses sceneweave() and SceneweaveApiError from the quickstart.
async function renameArticle(articleId: string, title: string) {
for (let attempt = 0; attempt < 2; attempt++) {
const article = await sceneweave<Article>(`/articles/${articleId}`);
try {
return await sceneweave<Article>(`/articles/${articleId}`, {
method: "PATCH",
body: { expectedRevisionId: article.currentRevisionId, expectedVersion: article.version, title, saveVersion: true },
});
} catch (error) {
if (!(error instanceof SceneweaveApiError) || error.code !== "REVISION_CONFLICT") throw error;
// Someone else saved first. Re-read and reapply this change once.
}
}
throw new Error("The article keeps changing. Ask an editor to apply the change.");
}Save, restore and approve
- Save the current draft as a version:
PATCHwith only the two expected values and"saveVersion": true. - Restore:
POST /articles/:id/restorewithrevisionIdand the two expected values saves a new version with the old content. History is never rewritten. - Approve:
POST /articles/:id/approvewith the two expected values approves the current saved version and sends thearticle.approvedwebhook.
Approval checks that the draft matches a saved version (409 UNSAVED_DRAFT), that every referenced product is available (409 PRODUCT_UNAVAILABLE), that generated scenes are accepted (400 IMAGE_NOT_ACCEPTED) and, for a translation, that it is not stale (409 TRANSLATION_STALE).
ARTICLE=$(curl -sS "https://sceneweave.levships.com/api/v1/articles/$ARTICLE_ID" \
-H "Authorization: Bearer $SCENEWEAVE_API_KEY")
curl -sS -X POST "https://sceneweave.levships.com/api/v1/articles/$ARTICLE_ID/approve" \
-H "Authorization: Bearer $SCENEWEAVE_API_KEY" \
-H "Content-Type: application/json" \
-d "$(jq '{expectedRevisionId: .data.currentRevisionId, expectedVersion: .data.version}' <<<"$ARTICLE")"{
"revisionId": "…",
"expectedRevisionId": "…",
"expectedVersion": 9
}Proposals from revision jobs
A revision job never edits the article. It saves a proposal with a complete new title, content, excerpt, products and sources, plus the baseRevisionId and baseVersion it started from, and adds the request and reply to messages.
- Accept with
{"action": "accept", "expectedVersion": …}, sending the article's currentversion. Sceneweave applies the proposal and saves a version. - If the article changed after the job was queued, accepting fails with
409 REVISION_CONFLICT. Compare the proposal with the current article and apply what you want withPATCH, or queue a new revision. - Reject with
{"action": "reject"}. A proposal can be resolved once; after that,409 PROPOSAL_RESOLVED.
PROPOSAL_ID=$(jq -r .data.result.proposalId <<<"$JOB")
ARTICLE=$(curl -sS "https://sceneweave.levships.com/api/v1/articles/$ARTICLE_ID" \
-H "Authorization: Bearer $SCENEWEAVE_API_KEY")
curl -sS -X POST "https://sceneweave.levships.com/api/v1/articles/$ARTICLE_ID/proposals/$PROPOSAL_ID" \
-H "Authorization: Bearer $SCENEWEAVE_API_KEY" \
-H "Content-Type: application/json" \
-d "$(jq '{action: "accept", expectedVersion: .data.version}' <<<"$ARTICLE")"Export
GET /articles/:id/export?format=html downloads a standalone HTML document with all text escaped. format=json downloads the article record with its versions, messages and proposals. Product references link to each product's url.
Ideas
Ideas from ideas jobs start as new. POST /ideas/:id/status sets status to new, shortlisted, rejected or commissioned. Queueing an article job with ideaId marks the idea commissioned for you.
Japanese translations
English is the original language. A translation job turns one saved English version into Japanese, and a separate review compares the result with the English source before it is saved.
- Save the English article as a version (
"saveVersion": true). Edits that were never saved are not translated. - Queue a
translationjob witharticleIdandsourceRevisionId, usually the article'scurrentRevisionId. It needscontent:writeas well asjobs:write. - The first translation creates a Japanese article (
locale: "ja") withsourceArticleIdandsourceRevisionId. It has its own versions and is edited and approved like any article. - Translating again once a Japanese article exists creates a proposal on it instead of overwriting it, so Japanese edits survive until someone accepts. The job result then includes
proposalId.
ARTICLE=$(curl -sS "https://sceneweave.levships.com/api/v1/articles/$ARTICLE_ID" \
-H "Authorization: Bearer $SCENEWEAVE_API_KEY")
curl -sS https://sceneweave.levships.com/api/v1/jobs \
-H "Authorization: Bearer $SCENEWEAVE_API_KEY" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: ja-$ARTICLE_ID-$(jq -r .data.currentRevisionId <<<"$ARTICLE")" \
-d "$(jq '{kind: "translation", input: {articleId: .data.id, sourceRevisionId: .data.currentRevisionId, glossary: {glaze: "釉薬"}}}' <<<"$ARTICLE")"Stale translations
translationStale is true when the English original has changed since the translation's sourceRevisionId, including unsaved edits. English changes never alter Japanese text. A stale translation cannot be approved (409 TRANSLATION_STALE). To refresh it, save the English article, translate that version and accept the proposal. Accepting updates sourceRevisionId and recalculates the flag.
Images
Image jobs render catalog products into a room photo. Upload the room photo first, and make sure each product has at least one photo.
| Mode | What it does | Needs |
|---|---|---|
place | Adds the selected products to the room. Every variant shows all of them. | productIds |
replace | Replaces one object in the room with the selected product. | productIds, and targetDescription or a mask |
suggest | Chooses three combinations of available, photographed products that suit a style, and renders each one. | style; always 3 variants |
Products per scene are limited by model: 8 for GPT Image 2.5 Sunburst and Nano Banana 2, 4 for MAI-Image-2.6. See Image models.
Masks
A mask is a PNG with an alpha channel and exactly the room photo's dimensions: opaque where the photo must stay, transparent over the area to change. Upload it like any image and send its ID as maskAssetId. Only GPT Image 2.5 Sunburst follows masks. A mask guides the edit; it does not guarantee a pixel-exact result.
{
"kind": "image",
"storeId": "…",
"input": {
"mode": "replace",
"baseAssetId": "…",
"maskAssetId": "…",
"productIds": ["…"],
"targetDescription": "the floor lamp beside the sofa",
"model": "openai/gpt-image-2.5-sunburst"
}
}{
"kind": "image",
"storeId": "…",
"input": {
"mode": "suggest",
"baseAssetId": "…",
"style": "Warm minimalism with natural textures",
"model": "google/gemini-3.1-flash-image",
"maxCostUsd": 3
}
}Results and review
A finished image job returns imageIds. GET /images lists each scene with its jobId, assetId, label, productIds, a recipe (model, prompt, products, placement brief and lineage) and accepted: false. recipe.placementSource says who wrote the brief: auto (the workspace writing model, named in recipe.placementModel, from the room and product photos), style (the style selection), user (your instructions) or none. For replace, recipe.target names the object that was swapped. Download the bytes with GET /assets/:assetId, which needs assets:read.
curl -sS -G https://sceneweave.levships.com/api/v1/images \
-H "Authorization: Bearer $SCENEWEAVE_API_KEY" \
--data-urlencode "storeId=$STORE_ID" \
--data-urlencode "limit=20" \
| jq --arg job "$JOB_ID" '.data.items[] | select(.jobId == $job) | {id, assetId, label, productIds, accepted}'Compare each scene with the original product photos before using it. Accept it with POST /images/:id/accept and {"accepted": true}, or record a rejection with false.
curl -sS -X POST "https://sceneweave.levships.com/api/v1/images/$IMAGE_ID/accept" \
-H "Authorization: Bearer $SCENEWEAVE_API_KEY" \
-H "Content-Type: application/json" \
-d '{"accepted": true}'Insert a scene into an article
Add a sceneImage node with the scene's assetId to the article content and save it with PATCH /articles/:id. Only accepted scenes can be inserted (400 IMAGE_NOT_ACCEPTED). The scene's products are added to the article's productIds.
{
"type": "sceneImage",
"attrs": {
"assetId": "…",
"alt": "Cobalt glaze vase on an oak sideboard beside a window",
"caption": "Illustrative scene"
}
}Calendar
A schedule records when you plan to release one saved version of an article. Sceneweave does not publish anything; use schedules to drive your own publishing. Creating and removing schedules needs calendar:write; listing them needs calendar:read.
POST/schedules
| Field | Type | Notes |
|---|---|---|
articleId | string | Required. |
revisionId | string | Required. A saved version of that article, otherwise 400 INVALID_REFERENCE. |
scheduledAt | integer | Required. Planned release time in Unix milliseconds. |
timeZone | string | IANA time zone for display, such as Asia/Tokyo. Default UTC. |
- An article has at most one planned release. Creating a schedule replaces the previous one.
- The schedule records the article's locale and the saved version's title.
- Scheduling neither requires nor changes approval. Compare
revisionIdwith the article'sapprovedRevisionIdif you only release approved versions. GET /scheduleslists planned releases andDELETE /schedules/:idremoves one.
curl -sS https://sceneweave.levships.com/api/v1/schedules \
-H "Authorization: Bearer $SCENEWEAVE_API_KEY" \
-H "Content-Type: application/json" \
-d "$(jq -n --arg a "$ARTICLE_ID" --arg r "$REVISION_ID" \
'{articleId: $a, revisionId: $r, scheduledAt: 1790557200000, timeZone: "Asia/Tokyo"}')"curl -sS -X DELETE "https://sceneweave.levships.com/api/v1/schedules/$SCHEDULE_ID" \
-H "Authorization: Bearer $SCENEWEAVE_API_KEY"Webhooks
Webhooks tell your platform when a job finishes or an article is approved, so you do not have to poll. A workspace owner adds an endpoint in Settings → API & connections → Add endpoint and chooses its events. The signing secret, whsec_ followed by 43 characters, is shown once. API keys cannot manage webhooks.
Events
| Event | Sent when | data |
|---|---|---|
job.succeeded | A job finished and its result was saved. | jobId, kind, result (as on the job) |
job.failed | A job failed after its last attempt. | jobId, kind, error |
article.approved | A saved version was approved. | articleId, revisionId, locale |
Cancelling a job does not send an event.
Requests
Each delivery is a JSON POST with User-Agent: Sceneweave-Webhooks/1.0, a Sceneweave-Id header carrying the event ID (the same on every retry) and a Sceneweave-Signature header. The body is {id, event, createdAt, data}, where createdAt is when the event happened, in Unix milliseconds. Treat the event ID as opaque.
POST /events/sceneweave HTTP/1.1
Host: your-platform.example.com
Content-Type: application/json
User-Agent: Sceneweave-Webhooks/1.0
Sceneweave-Id: job.succeeded:…:1790000420000
Sceneweave-Signature: t=1790000421,v1=3f5c…
{
"id": "job.succeeded:…:1790000420000",
"event": "job.succeeded",
"createdAt": 1790000420000,
"data": {
"jobId": "…",
"kind": "article",
"result": {
"articleId": "…"
}
}
}Verify signatures
- Read the raw request body as bytes before parsing it. Re-serialized JSON will not match the signature.
- Split
Sceneweave-Signatureintot, a Unix timestamp in seconds, andv1, a hex digest. - Compute HMAC-SHA256 over
<t>.<raw body>, keyed with the whole secret including itswhsec_prefix, and hex-encode it. - Compare it with
v1in constant time, and reject timestamps more than five minutes from your clock. Every delivery attempt is signed with a fresh timestamp.
// Verifies and handles Sceneweave webhooks. Node.js 18+, no dependencies.
// Run: SCENEWEAVE_WEBHOOK_SECRET=whsec_… node sceneweave-webhooks.mjs
import { createHmac, timingSafeEqual } from "node:crypto";
import { createServer } from "node:http";
const secret = process.env.SCENEWEAVE_WEBHOOK_SECRET; // shown once when the endpoint is added
if (!secret) throw new Error("Set SCENEWEAVE_WEBHOOK_SECRET first.");
const toleranceSeconds = 5 * 60;
const processed = new Set(); // use a durable store in production
function verifySignature(rawBody, header, now = Date.now()) {
const fields = Object.fromEntries(
String(header ?? "").split(",").map((part) => {
const index = part.indexOf("=");
return [part.slice(0, index).trim(), part.slice(index + 1).trim()];
}),
);
const timestamp = Number(fields.t);
if (!Number.isInteger(timestamp) || !/^[a-f0-9]{64}$/.test(fields.v1 ?? "")) return false;
if (Math.abs(now / 1000 - timestamp) > toleranceSeconds) return false;
// HMAC-SHA256 over "<t>.<raw body>", keyed with the whole secret, including "whsec_".
const expected = createHmac("sha256", secret).update(`${fields.t}.`).update(rawBody).digest();
return timingSafeEqual(expected, Buffer.from(fields.v1, "hex"));
}
function handleEvent(event) {
if (event.event === "job.succeeded") console.log(`Job ${event.data.jobId} (${event.data.kind}) succeeded`, event.data.result);
if (event.event === "job.failed") console.log(`Job ${event.data.jobId} (${event.data.kind}) failed: ${event.data.error}`);
if (event.event === "article.approved") console.log(`Article ${event.data.articleId} approved (${event.data.locale}, revision ${event.data.revisionId})`);
}
const server = createServer((request, response) => {
if (request.method !== "POST") return void response.writeHead(405).end();
const chunks = [];
request.on("data", (chunk) => chunks.push(chunk));
request.on("end", () => {
const rawBody = Buffer.concat(chunks); // verify the exact bytes before parsing them
if (!verifySignature(rawBody, request.headers["sceneweave-signature"])) return void response.writeHead(401).end();
const event = JSON.parse(rawBody.toString("utf8"));
// Deliveries can repeat or arrive out of order: deduplicate by ID, then read current state from the API.
if (!processed.has(event.id)) {
processed.add(event.id);
handleEvent(event);
}
response.writeHead(204).end(); // any 2xx within 15 seconds acknowledges the delivery
});
});
server.listen(Number(process.env.PORT ?? 3000), () => console.log(`Listening on http://localhost:${server.address().port}`));To try it locally, start the server and send a signed test event:
BODY='{"id":"job.succeeded:test:1790000420000","event":"job.succeeded","createdAt":1790000420000,"data":{"jobId":"test","kind":"article","result":{"articleId":"test"}}}'
T=$(date +%s)
SIG=$(printf '%s.%s' "$T" "$BODY" | openssl dgst -sha256 -hmac "$SCENEWEAVE_WEBHOOK_SECRET" -hex | awk '{print $NF}')
curl -sS -i http://localhost:3000 \
-H "Content-Type: application/json" \
-H "Sceneweave-Id: job.succeeded:test:1790000420000" \
-H "Sceneweave-Signature: t=$T,v1=$SIG" \
--data "$BODY"Frameworks that parse JSON for you need the raw body instead, for example express.raw({ type: "application/json" }) in Express or await request.arrayBuffer() in a Web-standard route handler.
Delivery and retries
- Any
2xxresponse within 15 seconds acknowledges a delivery. Anything else, including a timeout or a redirect, counts as a failure. - Failed deliveries are retried with exponential backoff, roughly 1, 2, 4, 8, 16, 32 and 60 minutes apart, for up to eight attempts in total. A background worker sends deliveries every minute.
- Endpoints must be public HTTPS URLs on the default port. Sceneweave resolves the hostname before every delivery and refuses private or reserved addresses. Redirects are not followed.
- Removing an endpoint drops its pending deliveries. Settings shows each endpoint's latest delivery time and status.
Handle events idempotently
- Events can arrive more than once and out of order. Store each
Sceneweave-Idand skip IDs you have already processed. - Acknowledge quickly and do slow work afterwards.
- Treat an event as a signal. Read the current state with
GET /jobs/:idorGET /articles/:idbefore acting on it.
Endpoint reference
Every endpoint under https://sceneweave.levships.com/api/v1. The access column shows the scope an API key needs; owner and session endpoints do not accept API keys.
| Method | Path | Access | Description |
|---|---|---|---|
| Workspace | |||
| GET | /workspace | Any key or session | Read settings, usage, models and recent records |
| PUT | /workspace | Owner session | Update workspace settings and default models |
| GET | /workspaces | Signed-in session | List workspaces available to the signed-in user |
| Models | |||
| GET | /models | Any key or session | List models, capabilities and defaults |
| Catalog | |||
| GET | /stores | catalog:read | List stores |
| POST | /stores | catalog:write | Create a store, or update the one with the same externalId |
| PATCH | /stores/:id | catalog:write | Update a store |
| GET | /products | catalog:read | List products |
| POST | /products | catalog:write | Create a product, or update the one with the same store and externalId |
| POST | /products/import | catalog:write | Import up to 200 products atomically |
| PATCH | /products/:id | catalog:write | Update product facts, preserving manual metadata |
| Assets | |||
| POST | /assets | assets:write | Upload an image in one multipart request |
| POST | /assets/upload-url | assets:write | Begin a private upload bound to the file's size and SHA-256 |
| POST | /assets/complete | assets:write | Verify the stored bytes and finalize the upload |
| GET | /assets/:id | assets:read | Download private image bytes |
| Jobs | |||
| GET | /jobs | jobs:read | List jobs |
| POST | /jobs | jobs:write | Queue an asynchronous job |
| GET | /jobs/:id | jobs:read | Read a job's status, model, cost and result |
| POST | /jobs/:id/retry | jobs:write | Retry a failed or cancelled job as a new job |
| POST | /jobs/:id/cancel | jobs:write | Cancel a queued or running job |
| Ideas | |||
| GET | /ideas | content:read | List article ideas |
| POST | /ideas/:id/status | content:write | Shortlist, reject or commission an idea |
| Articles | |||
| GET | /articles | content:read | List English articles and Japanese translations |
| POST | /articles | content:write | Create a manual draft without generation |
| GET | /articles/:id | content:read | Read an article with its versions, messages, proposals and translations |
| PATCH | /articles/:id | content:write | Edit an article, optionally saving a version |
| POST | /articles/:id/approve | content:write | Approve the current saved version |
| POST | /articles/:id/restore | content:write | Restore a saved version as a new version |
| POST | /articles/:id/proposals/:proposalId | content:write | Accept or reject a proposal |
| GET | /articles/:id/export | content:read | Download escaped HTML or the article record as JSON |
| Images | |||
| GET | /images | content:read | List generated scenes |
| POST | /images/:id/accept | content:write | Accept or reject a generated scene |
| Calendar | |||
| GET | /schedules | calendar:read | List planned releases |
| POST | /schedules | calendar:write | Plan a release for one saved version |
| DELETE | /schedules/:id | calendar:write | Remove a planned release |
| Integrations | |||
| POST | /keys | Owner session | Create a scoped API key |
| DELETE | /keys/:id | Owner session | Revoke an API key immediately |
| POST | /webhooks | Owner session | Register a signed HTTPS webhook |
| DELETE | /webhooks/:id | Owner session | Disable a webhook |
| Team | |||
| GET | /members | Owner session | List team members and pending access grants |
| POST | /members | Owner session | Grant editor or viewer access by email |
| PATCH | /members/:id | Owner session | Change a teammate's role |
| DELETE | /members/:id | Owner session | Remove a teammate |
| DELETE | /invitations/:id | Owner session | Revoke a pending access grant |
| GET | /audit | Owner session | Read the latest 100 workspace audit events |
| Meta | |||
| GET | /openapi | Public | Read this OpenAPI document |
OpenAPI
The complete contract is an OpenAPI 3.1 document at /api/v1/openapi. It needs no authentication and may be cached for five minutes. It describes every request and response body, the scope each operation needs and the three webhook events. Import it into an API client or a code generator.
curl -sS https://sceneweave.levships.com/api/v1/openapi -o sceneweave-openapi.jsonChangelog
Later the same day
- Ephemeral jobs: ideas, articles and scenes from inline products and photos in one
POST /jobs, with results inoutputsand automatic deletion afterretentionHours. GET /jobs/{id}?expand=outputsreturns any finished job's ideas, article (JSON and HTML) or scenes inline.- Place and replace scenes are planned automatically by the workspace writing model unless you send your own brief;
input.autoPlacementcontrols it and scene recipes recordplacementSource. - New errors:
410 EPHEMERAL_EXPIREDand422 IMAGE_UNAVAILABLE. OpenAPI document 1.2.0.
Earlier
GET /modelslists writing, image and metadata models with their capabilities, availability and the Sceneweave defaults.GET /workspaceincludes the same catalog asmodels.- Jobs accept
input.model, and every job pins its resolved model asmodel. - GPT-5.6 Terra (
openai/gpt-5.6-terra) is the default writing model for workspaces without their own choice. Workspace settings addarticleModelOverrideandimageModelOverride, andPUT /workspaceacceptsnullto follow the Sceneweave default. - GPT-5.6 Luna (
openai/gpt-5.6-luna), the lowest-cost writing model, is available for writing and metadata jobs. - MAI-Image-2.6 (
microsoft/mai-image-2.6) joins the image models: place, replace and suggest, up to four products per scene, without masks. POST /jobs/:id/retryqueues a failed or cancelled job again, optionally on another model. Retries carryretryOfJobId.- New errors:
422 UNSUPPORTED_MODEL,422 MODEL_UNAVAILABLEand409 INVALID_STATE. - OpenAPI document 1.1.0 describes request and response bodies, per-operation scopes and webhook events.