Skip to content

Developers · API v1

The Sceneweave API

Connect your platform's catalog to Sceneweave's research, writing and image workflows. Upload private photos, queue generation jobs, follow their progress and read reviewed results over a scoped JSON API.

Base URL
https://sceneweave.levships.com/api/v1
Authentication
Bearer API key sw_live_…

Updated . See the changelog.

On this page · Overview

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

  1. A workspace owner creates a scoped API key in Settings → API & connections.
  2. Your platform imports stores and products and uploads private product and room photos.
  3. You queue jobs: ideas, articles, revisions, Japanese translations, images and product metadata.
  4. You poll GET /jobs/:id or receive a webhook, then read the saved results.
  5. 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:

ShellList models with an API key
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_LIMITED with Retry-After: 60.
  • A request outside the key's scopes returns 403 FORBIDDEN with a message that names the missing scope, for example This API key requires jobs:write.

Scopes

API key scopes
ScopeAllows
catalog:readList stores and products; stores and products in GET /workspace.
catalog:writeCreate, update and import stores and products. metadata jobs also need it.
assets:readDownload private images with GET /assets/:id; assets in GET /workspace.
assets:writeUpload images with either upload flow.
jobs:readRead and list jobs. Replaying a request with its Idempotency-Key also needs it.
jobs:writeQueue and cancel jobs.
content:readList ideas, articles and images; read and export articles.
content:writeCreate and edit articles, save, restore and approve versions, resolve proposals, set idea status and review images. revision and translation jobs also need it.
calendar:readList planned releases.
calendar:writeCreate 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

ShellSet up your shell
# 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 above

2. Upload a photo

The two-step upload sends the bytes straight to storage. Private uploads explains each call.

ShellTwo-step upload
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

ShellCreate or update a 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.

ShellQueue an article job
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")
JSON202 Accepted
{
  "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

ShellPoll until the job finishes
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"
JSONA finished job (abbreviated)
{
  "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

ShellRead and export 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.html

The 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.

TypeScriptsceneweave-quickstart.ts
// 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.

  1. POST /jobs

    room, products and photos inline

  2. GET /jobs/{id}

    poll until it finishes

  3. data.outputs

    ideas, article or scenes

Catalog jobs and ephemeral jobs
Catalog jobsEphemeral jobs
Before the first jobCreate a store, sync products and upload photosNothing
Products and photosReferenced by IDSent in the request, photos by URL or inline
ResultsSaved records, read by ID from resultReturned in data.outputs
KeptUntil you delete them1–720 hours, then deleted
Best forEditorial work reviewed in the studio, daily ideas, repeat generationTight 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).

JSONscene-request.json
{
  "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
  }
}
ShellGenerate, wait and download in one script
# 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"
done

When 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.

JSONGET /jobs/{id} once the scenes are ready (abbreviated)
{
  "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

Ephemeral job request
FieldTypeNotes
kindstringideas, article or image.
inputobjectThe 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.productsobject[]Required. 1–20 products, described below. Every product in a place or replace scene needs at least one image.
ephemeral.baseImageimageRequired for image jobs, and only for them: the room or interior photo.
ephemeral.storeobjectOptional name, voice and audience that shape ideas and articles. Default: an unnamed store with no voice guide.
ephemeral.retentionHoursintegerHow long the request's records and results are kept: 1–720 hours. Default 168 (7 days).
ephemeral.products[]
FieldTypeNotes
namestringRequired.
refstringYour own ID, such as a SKU. Echoed back as ref wherever the product appears in the outputs. Unique within the request.
descriptionstringWhat the writer and image model may rely on: materials, size, maker, condition. Facts not given here are never invented.
urlstringPublic HTTPS product page. Articles link product mentions to it.
imagesimage[]Up to 4. Each is {"url": "https://…"} or {"dataUrl": "data:image/png;base64,…"}.
metadataobjectKnown 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.

JSONAn article from inline products
{
  "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"
          }
        ]
      }
    ]
  }
}
JSONdata.outputs for an article (abbreviated)
{
  "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.

JSONArticle ideas for a product line
{
  "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
  }
}
JSONdata.outputs for ideas (abbreviated)
{
  "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.

TypeScriptplace-product.ts
// 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 outputs automatically. Any other job returns the same shape with GET /jobs/{id}?expand=outputs; pass expand=none to 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 return outputs.proposal, the suggested change.
  • outputs.images: each scene's download url (send your API key with it), its products and the placement brief with placementSource.
  • Reading outputs needs content:read as well as jobs:read.
ShellResults inline for any job
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: true and expiresAt, 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, and POST /jobs/{id}/retry returns 410 EPHEMERAL_EXPIRED.
  • Until then, temporary records never appear in GET /products, /ideas, /articles, /images or the studio catalog. The jobs appear in job lists as temporary API requests.

Limits and permissions

Ephemeral job limits
LimitValue
Products per request1–20
Images per productUp to 4
Image size20 MB by URL; inline data counts toward the 2 MB request limit
Retention1–720 hours, default 168
Scopesjobs:write to create, jobs:read to poll, content:read for outputs. Keys restricted to specific stores cannot create ephemeral jobs.
Everything elseSame 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 returns 400 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, scheduledAt and expiresAt. The one exception is the t value in webhook signatures, which is in seconds.

Responses

  • Success: {"data": …}. Errors: {"error": {"code": "…", "message": "…"}}. Branch on code; message is readable and safe to show to editors.
  • Status codes: 200 for reads and updates, 201 for created records and uploads, 202 for queued jobs.
  • Responses are sent with Cache-Control: no-store. The public OpenAPI document is the exception: it may be cached for five minutes.
JSON403 Forbidden
{
  "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.

ShellPage through products
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
JSON200 OK
{
  "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:

JSON400 Bad Request
{
  "error": {
    "code": "INVALID_REQUEST",
    "message": "input.maxCostUsd: The spending cap cannot exceed this job's default limit of $8."
  }
}
Error codes
CodeStatusMeaningWhat to do
INVALID_REQUEST400A 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_JSON400The request body is not a JSON object.Send a JSON object with Content-Type: application/json.
INVALID_IMAGE400The 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_UPLOAD400The 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_SCHEMA400The product metadata schema cannot be compiled or is larger than 50,000 characters.Send a valid JSON Schema (draft-07) with inline definitions.
INVALID_METADATA400Product metadata does not satisfy its schema. The message includes the validation errors.Correct the metadata or the schema.
INVALID_REFERENCE400A 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_DOCUMENT400Article 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_ACCEPTED400The article references a generated image that has not been accepted.Accept the image with POST /images/:id/accept, then save the article.
MISSING_IMAGE400A metadata job targets a product without photos.Add imageAssetIds or imageUrls to the product first.
INVALID_URL400The 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.
UNAUTHORIZED401The API key is missing, malformed, expired or revoked, or the session has ended.Send Authorization: Bearer sw_live_… with an active key.
FORBIDDEN403The 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_DISABLED403Generation 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_EXCEEDED403The requested monthly budget is above the workspace's approved allowance.Choose a budget within the approved allowance.
NOT_FOUND404The endpoint does not exist, or the record is not in this workspace.Check the method, path and ID.
REVISION_CONFLICT409The 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_DRAFT409Approval requires the current draft to be a saved version.Save a version (saveVersion: true), then approve with the new currentRevisionId.
TRANSLATION_STALE409The English original changed after this translation's source version.Translate the latest saved English version and accept the resulting proposal.
PRODUCT_UNAVAILABLE409The article references a product marked unavailable.Remove the reference or mark the product available.
PROPOSAL_RESOLVED409The proposal was already accepted or rejected.Reload the article to see its current state.
IDEMPOTENCY_CONFLICT409The Idempotency-Key was already used for a different job request in this workspace.Use a new key for a new request.
INVALID_STATE409Only 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_EXPIRED409The upload is older than 15 minutes or was started by a different key or user.Start a new upload.
EPHEMERAL_EXPIRED410The 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_LARGE413The JSON body exceeds 2 MB, or a direct upload exceeds 20 MB.Split large imports; use the two-step upload for large files.
UNSUPPORTED_MODEL422The 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_UNAVAILABLE422The requested model is not available on this service right now.Choose an available model from GET /models.
IMAGE_UNAVAILABLE422An 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_LIMITED429The API key made more than 120 requests in one minute.Wait for Retry-After (60 seconds) before retrying.
BUDGET_EXCEEDED429The 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_LIMIT429The workspace has reached its 2,000-image or 1 GB storage allowance.Contact the workspace owner. Retrying will not free space.
SERVICE_ERROR500An unexpected error occurred.Retry with backoff. For POST /jobs, reuse the same Idempotency-Key.
UPLOAD_FAILED502Storage did not accept or return the image.Retry the upload.
ASSET_UNAVAILABLE502The private image bytes could not be read from storage.Retry shortly.
SERVICE_UNAVAILABLE503The workspace service is temporarily unavailable.Retry with backoff.

Retrying

  • Retry RATE_LIMITED after the Retry-After header, and 5xx responses with exponential backoff.
  • Every 429 carries Retry-After: 60, including BUDGET_EXCEEDED and STORAGE_LIMIT. For those two, waiting only helps if running jobs release their reservations or someone raises the limit.
  • Do not retry other 4xx errors unchanged.
  • Retry POST /jobs only with the same Idempotency-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

Store fields
FieldTypeNotes
namestringRequired. Up to 500 characters.
externalIdstringYour platform's store ID, up to 250 characters. Creating a store with an existing externalId updates that store.
urlstringPublic HTTPS URL of the storefront.
voicestringVoice and style guidance for this store's writing, up to 12,000 characters.
audiencestringWho the store writes for, up to 4,000 characters.
dailyIdeasEnabledbooleanQueue one ideas job a day. Default false. Needs an active generation budget, otherwise 403 GENERATION_DISABLED.
dailyIdeasHourinteger0–23, default 8. The daily job is queued at or after this hour in the workspace time zone.
ShellCreate or update a store
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

Product fields
FieldTypeNotes
storeIdstringRequired when creating. A product cannot move between stores.
namestringRequired. Up to 500 characters.
externalIdstringYour product ID, up to 250 characters. Creating a product with an existing externalId in the same store updates it.
descriptionstringUp to 20,000 characters.
urlstringPublic HTTPS listing URL. Article product references link to it.
imageAssetIdsstring[]Up to 20 private assets from the same store, or uploaded without a store. See Private uploads.
imageUrlsstring[]Up to 20 public HTTPS photo URLs, fetched when a job needs them.
availablebooleanDefault true. Unavailable products are left out of new ideas, articles and images, and block approval of articles that reference them.
metadataobjectAttributes you know. Every key you send is added to manualFields, marked manual in provenance, and preserved when enrichment runs.
metadataSchemaobjectJSON Schema for metadata. See below.
metadataSchemaVersionstringYour label for the schema version, up to 80 characters. Default "1".
manualFieldsstring[]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.

ShellImport products
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 $ref URLs are not loaded.
  • Allow null for facts you might not know, so enrichment can leave them unknown instead of guessing.
JSONPATCH /products/:id
{
  "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.

Upload limits
LimitValue
FormatsPNG, JPEG, WebP, GIF. Sceneweave checks the bytes and dimensions, not the filename.
File sizeUp to 20 MB. Room photos, product photos and masks used by jobs must be under 12 MB.
DimensionsUp to 100 megapixels and 25,000 px per side.
Workspace allowance2,000 images or 1 GB in total (429 STORAGE_LIMIT).
Upload windowComplete a two-step upload within 15 minutes, with the same key.

Two-step upload (recommended)

  1. POST /assets/upload-url with filename, contentType, the exact size in bytes, the lowercase hex sha256 of the file and an optional storeId. The response is 201 with uploadId and a one-time uploadUrl.
  2. POST the raw bytes to uploadUrl with only the image Content-Type header. Do not send your API key there. The response is {"storageId": "…"}.
  3. POST /assets/complete with uploadId and storageId. Sceneweave re-reads the stored bytes, checks the hash, type, size and dimensions, and returns the Asset with 201. Completing the same upload again returns the same asset.
ShellTwo-step upload
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")
JSON201 Created
{
  "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.

ShellDirect multipart upload
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.

ShellDownload private image bytes
curl -sS "https://sceneweave.levships.com/api/v1/assets/$ASSET_ID" \
  -H "Authorization: Bearer $SCENEWEAVE_API_KEY" \
  -o vase-copy.jpg

Jobs

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: stage names the current step and progress rises toward 100.
  • succeeded: result references the saved records.
  • failed: error explains what went wrong.
  • cancelled: stopped with POST /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.

ShellQueue an article job
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-Key header with 1–200 printable ASCII characters and no spaces, such as a UUID or your own record ID. Anything else returns 400 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 need jobs: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.

ShellPoll until the job finishes
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

ShellCancel a job
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 returns 422 MODEL_UNAVAILABLE, and one that does not suit the job's kind or inputs returns 422 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 maxCostUsd when 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-Key works as for POST /jobs: repeating the same retry with the same key returns the same new job.
ShellRetry a failed job on another model
# 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 original

Reading results

Job results by kind
KindresultRead it with
ideasideaIdsGET /ideas?storeId=…. Each idea also carries its jobId.
articlearticleIdGET /articles/:id. New articles arrive with status review.
revisionarticleId, proposalIdThe proposal in GET /articles/:id. See Proposals.
translationarticleId, plus proposalId when a translation already existedThe Japanese article in GET /articles/:id. See Japanese translations.
imageimageIdsGET /images?storeId=…, then GET /assets/:assetId for the bytes.
metadataproductIdGET /products?storeId=…: the product's metadata and provenance.
Job fields
FieldTypeNotes
idstringJob ID.
kindstringideas, article, revision, translation, image or metadata.
storeIdstringStore the job works in.
inputobjectAlways {} for API keys: prompts, briefs and intermediate work are not returned.
modelstringModel pinned when the job was queued.
retryOfJobIdstringOnly on a retry: the failed or cancelled job it repeats. See Retry.
ephemeral, expiresAt, expiredboolean, integerOnly on ephemeral jobs: when the request's records are deleted, and whether that has happened.
outputsobjectFinished results inline: automatic for ephemeral jobs, or with ?expand=outputs. See Outputs.
statusstringqueued, running, succeeded, failed or cancelled.
stagestringReadable label for the current step, such as Researching sources.
progressinteger0–100.
attemptintegerAttempts started so far, at most 3.
resultobjectReferences to the saved records. See the table above.
errorstringReadable failure message. Final only when status is failed.
estimatedCostUsdnumberReservation held against the monthly budget: input.maxCostUsd or the default limit.
costUsdnumberRecorded model and research cost.
createdAt, updatedAt, completedAtintegerUnix 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.

ideas input
FieldTypeNotes
countintegerHow many candidates to propose, 1–10. Default 5.
directionstringTheme or editorial direction to follow.
productIdsstring[]Up to 100 products from the store to focus on. Default: the store's available products.
JSONideas
{
  "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.

article input
FieldTypeNotes
titlestringWorking title. Required unless ideaId supplies it.
briefstringWhat to cover: audience, angle, points to include and length. Articles default to 900–1,300 words.
ideaIdstringAn idea from the same store. Fills a missing title, brief and product list from the idea, and marks it commissioned.
productIdsstring[]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.

revision input
FieldTypeNotes
articleIdstringRequired. The article to revise.
messagestringRequired. The change you want, in plain language.
expectedRevisionIdstring | nullRequired. The article's current currentRevisionId.
expectedVersionintegerRequired. The article's current version. A stale pair returns 409 REVISION_CONFLICT before anything is queued.
JSONrevision
{
  "kind": "revision",
  "input": {
    "articleId": "…",
    "message": "Tighten the introduction and add a short care section.",
    "expectedRevisionId": "…",
    "expectedVersion": 7
  }
}

translation

translation input
FieldTypeNotes
articleIdstringRequired. The English original.
sourceRevisionIdstringRequired. A saved version of that article.
glossaryany JSONOptional terminology guidance, for example an object mapping English terms to preferred Japanese.
JSONtranslation
{
  "kind": "translation",
  "input": {
    "articleId": "…",
    "sourceRevisionId": "…",
    "glossary": {
      "glaze": "釉薬",
      "stoneware": "炻器"
    }
  }
}

image

See Images for the modes and model limits.

image input
FieldTypeNotes
modestringRequired: place, replace or suggest.
baseAssetIdstringRequired. The room or interior photo.
productIdsstring[]Required for place and replace, up to the model's products per scene. For suggest, an optional shortlist to choose from.
stylestringInterior style to match. Required for suggest. Up to 2,000 characters.
instructionsstringUp 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.
autoPlacementbooleanWhether 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.
targetDescriptionstringreplace: the object to replace. Defaults to instructions.
maskAssetIdstringreplace: a painted mask. Only models that follow masks.
parentImageIdstringA generated image this request refines, kept as lineage.
variantsinteger1–3 renderings, default 1. suggest always makes 3.
JSONimage · place
{
  "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

metadata input
FieldTypeNotes
productIdstringRequired. The product must have at least one photo, otherwise 400 MISSING_IMAGE.
schemaobjectJSON Schema for this run. Defaults to the product's metadataSchema, then Sceneweave's default schema.
JSONmetadata
{
  "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.

JSON200 OK (one entry per list)
{
  "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.

Writing models
ModelRelative costNotes
GPT-5.6 TerraDefaultopenai/gpt-5.6-terraMediumBalanced research and writing quality at a moderate cost. The Sceneweave default.
GPT-5.6 Lunaopenai/gpt-5.6-lunaLowerThe fastest, lowest-cost option for drafts, idea lists and routine edits.
GPT-6 Astraopenai/gpt-6-astraHigherOpenAI's most capable model for demanding research and long-form writing.
Claude Fable 5.1anthropic/claude-fable-5.1HigherAnthropic's frontier model with a distinct editorial voice.
Claude Opus 5anthropic/claude-opus-5MediumAnthropic's careful long-form writer.

Image models

Image models
ModelModesMasksProducts per sceneRelative cost
GPT Image 2.5 SunburstDefaultopenai/gpt-image-2.5-sunburstPrecise editing. The only model that follows painted replacement masks.place, replace, suggestYes8Higher
Nano Banana 2google/gemini-3.1-flash-imageFast multi-reference compositions. Replacements follow the written description.place, replace, suggestNo8Medium
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, suggestNo4Lower

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

  1. input.model on the job, when you send it.
  2. Otherwise the workspace default: defaultArticleModel for ideas, article, revision and translation jobs, defaultImageModel for image jobs.
  3. 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

Model errors
SituationResponse
input.model is not a model for this kind of job400 INVALID_REQUEST naming input.model and the supported IDs
input.model cannot do what you asked: a mask, too many products or an unsupported mode400 INVALID_REQUEST naming input.model and the limit
You did not send input.model and the workspace default cannot do what you asked422 UNSUPPORTED_MODEL
The model sent to POST /jobs/:id/retry does not suit the job422 UNSUPPORTED_MODEL
input.model is not available on this service right now422 MODEL_UNAVAILABLE
JSON400 Bad Request
{
  "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.

ShellRead the workspace's effective defaults
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.

JSONPUT /workspace (owner session)
{
  "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.

Default job limits
KindDefault 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 returns 400 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.
JSONA capped image job
{
  "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 /jobs returns 403 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 /jobs returns 429 BUDGET_EXCEEDED. Months follow the UTC calendar.
  • GET /workspace reports usage: spentUsd, reservedUsd, monthlyBudgetUsd, activeJobs, totalJobs and byKind.
ShellCheck this month's usage
curl -sS https://sceneweave.levships.com/api/v1/workspace \
  -H "Authorization: Bearer $SCENEWEAVE_API_KEY" | jq .data.usage

Articles 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

Article fields
FieldTypeNotes
title, excerptstringTitle up to 500 characters; excerpt up to 5,000.
localestringen for originals, ja for Japanese translations.
statusstringdraft, review or approved. Generated articles start in review and manual drafts in draft. Any content change returns an article to draft.
contentobjectTiptap JSON. See Document format.
productIdsstring[]Products the article references, including those in its content and accepted scenes.
sourcesobject[]Research sources: url, title, evidence and optional claims.
versionintegerIncrements on every content change, saved or not.
currentRevisionIdstring | nullThe latest saved version.
approvedRevisionIdstring | nullThe approved saved version. Kept as history after later edits.
sourceArticleId, sourceRevisionId, translationStalestring, booleanJapanese 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, product and image.
  • Marks: bold, italic, strike, code, underline, link, textStyle and highlight. Links must be public HTTPS URLs.
  • Products: an inline productReference with attrs.productId from the article's store and a caption.
  • Images: a block sceneImage with attrs.assetId (a private asset, or an accepted scene's assetId), alt and caption. External image URLs are rejected.
  • Limits: 600 KB serialized, 24 levels deep and 20,000 nodes. Anything else returns 400 INVALID_DOCUMENT.
JSONcontent
{
  "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: version increments and currentRevisionId stays the same.
  • With "saveVersion": true, the result is also saved as an immutable version and currentRevisionId points to it. An optional reason of up to 500 characters is stored with it.
  • Any content change sets status to draft. The previously approved version stays in approvedRevisionId as history.
  • Changing an English article marks its Japanese translations stale.
  • Always take expectedRevisionId and expectedVersion from the latest response.
ShellEdit and save a version
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.

TypeScriptHandle a conflict
// 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: PATCH with only the two expected values and "saveVersion": true.
  • Restore: POST /articles/:id/restore with revisionId and the two expected values saves a new version with the old content. History is never rewritten.
  • Approve: POST /articles/:id/approve with the two expected values approves the current saved version and sends the article.approved webhook.

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).

ShellApprove the current saved version
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")"
JSONPOST /articles/:id/restore
{
  "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 current version. 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 with PATCH, or queue a new revision.
  • Reject with {"action": "reject"}. A proposal can be resolved once; after that, 409 PROPOSAL_RESOLVED.
ShellAccept a proposal
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.

  1. Save the English article as a version ("saveVersion": true). Edits that were never saved are not translated.
  2. Queue a translation job with articleId and sourceRevisionId, usually the article's currentRevisionId. It needs content:write as well as jobs:write.
  3. The first translation creates a Japanese article (locale: "ja") with sourceArticleId and sourceRevisionId. It has its own versions and is edited and approved like any article.
  4. 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.
ShellTranslate the latest saved version
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.

Image modes
ModeWhat it doesNeeds
placeAdds the selected products to the room. Every variant shows all of them.productIds
replaceReplaces one object in the room with the selected product.productIds, and targetDescription or a mask
suggestChooses 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.

JSONimage · replace with a mask
{
  "kind": "image",
  "storeId": "…",
  "input": {
    "mode": "replace",
    "baseAssetId": "…",
    "maskAssetId": "…",
    "productIds": ["…"],
    "targetDescription": "the floor lamp beside the sofa",
    "model": "openai/gpt-image-2.5-sunburst"
  }
}
JSONimage · suggest
{
  "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.

ShellFind a job's images
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.

ShellAccept a scene
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.

JSONsceneImage node
{
  "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

Schedule fields
FieldTypeNotes
articleIdstringRequired.
revisionIdstringRequired. A saved version of that article, otherwise 400 INVALID_REFERENCE.
scheduledAtintegerRequired. Planned release time in Unix milliseconds.
timeZonestringIANA 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 revisionId with the article's approvedRevisionId if you only release approved versions.
  • GET /schedules lists planned releases and DELETE /schedules/:id removes one.
ShellPlan a release
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"}')"
ShellRemove a planned release
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

Webhook events
EventSent whendata
job.succeededA job finished and its result was saved.jobId, kind, result (as on the job)
job.failedA job failed after its last attempt.jobId, kind, error
article.approvedA 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.

HTTPA delivery
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

  1. Read the raw request body as bytes before parsing it. Re-serialized JSON will not match the signature.
  2. Split Sceneweave-Signature into t, a Unix timestamp in seconds, and v1, a hex digest.
  3. Compute HMAC-SHA256 over <t>.<raw body>, keyed with the whole secret including its whsec_ prefix, and hex-encode it.
  4. Compare it with v1 in constant time, and reject timestamps more than five minutes from your clock. Every delivery attempt is signed with a fresh timestamp.
JavaScriptsceneweave-webhooks.mjs
// 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:

ShellSend yourself 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 2xx response 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-Id and 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/:id or GET /articles/:id before 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.

All endpoints
MethodPathAccessDescription
Workspace
GET/workspaceAny key or sessionRead settings, usage, models and recent records
PUT/workspaceOwner sessionUpdate workspace settings and default models
GET/workspacesSigned-in sessionList workspaces available to the signed-in user
Models
GET/modelsAny key or sessionList models, capabilities and defaults
Catalog
GET/storescatalog:readList stores
POST/storescatalog:writeCreate a store, or update the one with the same externalId
PATCH/stores/:idcatalog:writeUpdate a store
GET/productscatalog:readList products
POST/productscatalog:writeCreate a product, or update the one with the same store and externalId
POST/products/importcatalog:writeImport up to 200 products atomically
PATCH/products/:idcatalog:writeUpdate product facts, preserving manual metadata
Assets
POST/assetsassets:writeUpload an image in one multipart request
POST/assets/upload-urlassets:writeBegin a private upload bound to the file's size and SHA-256
POST/assets/completeassets:writeVerify the stored bytes and finalize the upload
GET/assets/:idassets:readDownload private image bytes
Jobs
GET/jobsjobs:readList jobs
POST/jobsjobs:writeQueue an asynchronous job
GET/jobs/:idjobs:readRead a job's status, model, cost and result
POST/jobs/:id/retryjobs:writeRetry a failed or cancelled job as a new job
POST/jobs/:id/canceljobs:writeCancel a queued or running job
Ideas
GET/ideascontent:readList article ideas
POST/ideas/:id/statuscontent:writeShortlist, reject or commission an idea
Articles
GET/articlescontent:readList English articles and Japanese translations
POST/articlescontent:writeCreate a manual draft without generation
GET/articles/:idcontent:readRead an article with its versions, messages, proposals and translations
PATCH/articles/:idcontent:writeEdit an article, optionally saving a version
POST/articles/:id/approvecontent:writeApprove the current saved version
POST/articles/:id/restorecontent:writeRestore a saved version as a new version
POST/articles/:id/proposals/:proposalIdcontent:writeAccept or reject a proposal
GET/articles/:id/exportcontent:readDownload escaped HTML or the article record as JSON
Images
GET/imagescontent:readList generated scenes
POST/images/:id/acceptcontent:writeAccept or reject a generated scene
Calendar
GET/schedulescalendar:readList planned releases
POST/schedulescalendar:writePlan a release for one saved version
DELETE/schedules/:idcalendar:writeRemove a planned release
Integrations
POST/keysOwner sessionCreate a scoped API key
DELETE/keys/:idOwner sessionRevoke an API key immediately
POST/webhooksOwner sessionRegister a signed HTTPS webhook
DELETE/webhooks/:idOwner sessionDisable a webhook
Team
GET/membersOwner sessionList team members and pending access grants
POST/membersOwner sessionGrant editor or viewer access by email
PATCH/members/:idOwner sessionChange a teammate's role
DELETE/members/:idOwner sessionRemove a teammate
DELETE/invitations/:idOwner sessionRevoke a pending access grant
GET/auditOwner sessionRead the latest 100 workspace audit events
Meta
GET/openapiPublicRead 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.

ShellDownload the specification
curl -sS https://sceneweave.levships.com/api/v1/openapi -o sceneweave-openapi.json

Open the OpenAPI document

Changelog

Later the same day

  • Ephemeral jobs: ideas, articles and scenes from inline products and photos in one POST /jobs, with results in outputs and automatic deletion after retentionHours.
  • GET /jobs/{id}?expand=outputs returns 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.autoPlacement controls it and scene recipes record placementSource.
  • New errors: 410 EPHEMERAL_EXPIRED and 422 IMAGE_UNAVAILABLE. OpenAPI document 1.2.0.

Earlier

  • GET /models lists writing, image and metadata models with their capabilities, availability and the Sceneweave defaults. GET /workspace includes the same catalog as models.
  • Jobs accept input.model, and every job pins its resolved model as model.
  • GPT-5.6 Terra (openai/gpt-5.6-terra) is the default writing model for workspaces without their own choice. Workspace settings add articleModelOverride and imageModelOverride, and PUT /workspace accepts null to 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/retry queues a failed or cancelled job again, optionally on another model. Retries carry retryOfJobId.
  • New errors: 422 UNSUPPORTED_MODEL, 422 MODEL_UNAVAILABLE and 409 INVALID_STATE.
  • OpenAPI document 1.1.0 describes request and response bodies, per-operation scopes and webhook events.