API Reference
Base URL: https://sleeperhit.studio/api/v1
Auth: Authorization: Bearer sh_<18-hex-prefix>_<64-hex-secret> on every request.
Content-Type: application/json on all POST/PATCH bodies.
Errors: always { error: { code, message, requestId } }. Match on code. The requestId is mirrored in X-Request-Id.
Rate-limit headers on every response: RateLimit-Limit, RateLimit-Remaining, RateLimit-Reset. 429 responses also include Retry-After.
Authentication and scopes
Keys are created at https://sleeperhit.studio/dashboard/api. A key is shown once.
Scope table
| Scope | Required for |
|---|---|
story:read | GET capabilities, GET/list projects, sources, plans, jobs |
story:write | POST/PATCH/DELETE projects; POST sources, plans, jobs; POST approve/reject/cancel |
source:read | GET individual sources |
source:write | POST source creation; DELETE sources |
artifact:read | GET artifacts |
artifact:publish | POST refine, recast, finalize, render-video, music, and SFX mutations |
credits:read | GET credits balance |
webhook:write | Coming soon — register webhook endpoints |
Default scopes on a new key: story:read, credits:read.
Key format
sh_<18-hex-chars>_<64-hex-chars>
The prefix (sh_<18-hex>) is stable and safe to store in logs (it identifies but does not authenticate the key). The secret (the trailing 64 hex chars) is the sensitive part and must never appear in logs.
Discovery
GET /capabilities
Returns the live feature manifest. No idempotency key needed. Scope: story:read.
curl -s https://sleeperhit.studio/api/v1/capabilities \ -H "Authorization: Bearer $SLEEPERHIT_API_KEY"
Response — 200 OK:
{ "version": "v1", "status": "foundation", "auth": { "schemes": ["bearer_api_key"], "scopes": [...], "supportsIdempotency": true }, "projects": { "available": true, "crudScopes": { "read": "story:read", "write": "story:write" } }, "sources": { "types": [ { "type": "text", "availability": "available", "maxInlineBytes": 1000000 }, { "type": "markdown", "availability": "available", "maxInlineBytes": 1000000 }, { "type": "url", "availability": "available", "note": "..." }, { "type": "pdf", "availability": "available", "note": "..." } ], "retentionPolicies": ["EXTRACTED_ONLY", "STORE_ORIGINAL", "METADATA_ONLY"], "digest": { "availability": "available", "statuses": [...], "fields": [...] } }, "artifacts": { "table_read": { "outputs": ["theater_url","theater_fullscreen_url","live_audio","video"], "availability": "available", "modes": ["documentary","podcast","drama"] }, "pitch_deck": { "availability": "planned" }, "storyboard": { "availability": "planned" }, "trailer": { "availability": "planned" }, "production_video": { "availability": "planned" } }, "jobs": { "available": true, "statuses": [...], "artifactStatuses": [...] }, "docs": { "openapi": { "url": "/api/v1/openapi.json", "availability": "available" }, "examples": { "url": "/api/v1/examples", "availability": "available" }, "llms": { "url": "/llms.txt", "availability": "available" }, "llmsFull": { "url": "/llms-full.txt", "availability": "available" }, "docsSite": { "url": "https://docs.sleeperhit.studio/", "availability": "available" }, "agentIndex": { "url": "/agent-index.json", "availability": "available" }, "searchIndex": { "url": "/docs/search-index.json", "availability": "available" }, "cliCommands": { "url": "/docs/cli-commands.json", "availability": "available" }, "mcpTools": { "url": "/docs/mcp-tools.json", "availability": "available" }, "sitemap": { "url": "/sitemap.xml", "availability": "available" }, "robots": { "url": "/robots.txt", "availability": "available" } } }
GET /openapi.json
OpenAPI 3.1 spec with all operation IDs and named schemas. No auth required.
GET /examples
Machine-readable recipe catalog. Returns curl + Node snippets for every live flow. No auth required.
Credits
GET /credits
Returns the Studio Credit balance. Scope: credits:read.
curl -s https://sleeperhit.studio/api/v1/credits \ -H "Authorization: Bearer $SLEEPERHIT_API_KEY"
Response — 200 OK:
{ "credits": { "balance": 250, "monthlyBucketAllocated": 300, "monthlyBucketUsed": 50, "monthlyBucketRemaining": 250, "perpetualBalance": 0, "cycleStartedAt": "2026-05-01T00:00:00.000Z", "cycleExpiresAt": "2026-06-01T00:00:00.000Z" } }
balance = monthlyBucketRemaining + perpetualBalance.
Story Projects
POST /story-projects
Create a project workspace. Scope: story:write. Accepts Idempotency-Key.
Request body:
{ "name": "Q3 Launch Story", "description": "Optional free-form context (max 4000 chars)", "metadata": { "yourKey": "your-cross-reference-value" } }
name is required (1–200 chars). description and metadata are optional.
Response — 200 OK:
{ "project": { "id": "cmp...", "name": "Q3 Launch Story", "description": "...", "metadata": null, "archivedAt": null, "sourceCount": 0, "createdAt": "2026-05-29T10:00:00.000Z", "updatedAt": "2026-05-29T10:00:00.000Z" } }
Errors: validation_failed (bad name/description length).
GET /story-projects
List projects for the account. Scope: story:read. Cursor-paginated.
Query params:
limit— max items per page (default 20, max 100).cursor— opaque string from previous response'snextCursor.
Response — 200 OK:
{ "projects": [ ... ], "nextCursor": "opaque-cursor-or-null" }
GET /story-projects/{projectId}
Read a single project. Scope: story:read.
Response — 200 OK: Same { project } envelope.
Errors: project_not_found (404).
PATCH /story-projects/{projectId}
Update a project. Scope: story:write. All fields optional.
Request body:
{ "name": "New name", "description": "Updated description or null to clear", "metadata": { ... }, "archived": true }
Setting archived: true soft-deletes the project (sets archivedAt). The project and its resources remain readable.
Response — 200 OK: Updated { project } envelope.
Errors: project_not_found, validation_failed.
DELETE /story-projects/{projectId}
Soft-delete a project. Scope: story:write.
Response — 200 OK:
{ "id": "cmp...", "deletedAt": "2026-05-29T10:00:00.000Z" }
Errors: project_not_found.
Sources
POST /story-projects/{projectId}/sources
Attach a source to a project. Scope: source:write.
Request body:
{ "type": "text", "content": "<inline text — required for text/markdown>", "uri": "https://example.com/article", "label": "Optional human-readable name (max 200 chars)", "retentionPolicy": "EXTRACTED_ONLY", "metadata": { "anyKey": "anyValue" } }
| Field | Required when | Notes |
|---|---|---|
type | always | "text", "markdown", "url", or "pdf" |
content | type is text or markdown | Max 1,000,000 bytes (UTF-8) |
uri | type is url or pdf | Must be http or https; SSRF-blocked addresses rejected |
label | never | Human-readable name |
retentionPolicy | never | EXTRACTED_ONLY (default), STORE_ORIGINAL, METADATA_ONLY |
metadata | never | Arbitrary JSON for your cross-reference |
Response — 200 OK (text/markdown):
{ "source": { "id": "cmq...", "projectId": "cmp...", "type": "TEXT", "label": "press-release", "status": "READY", "retentionPolicy": "EXTRACTED_ONLY", "byteSize": 4123, "tokenEstimate": 1031, "contentHash": "a1b2c3...", "extractedTextPreview": "<first 4000 chars>", "digestStatus": "NOT_STARTED", "digest": null, "createdAt": "2026-05-29T10:00:00.000Z", "updatedAt": "2026-05-29T10:00:00.000Z" } }
Response — 200 OK (url/pdf): Same shape but status: "PENDING". Poll until READY or FAILED.
Errors: project_not_found, validation_failed, source_too_large (body > limit), source_fetch_failed (SSRF block, redirect limit, content-type mismatch, upstream error — returned when the sync pre-fetch validation fails for URL/PDF).
GET /story-projects/{projectId}/sources
List sources for a project. Scope: source:read. Cursor-paginated (limit, cursor).
Response — 200 OK:
{ "sources": [...], "nextCursor": "..." }
GET /story-projects/{projectId}/sources/{sourceId}
Read a single source. Scope: source:read. Use this to poll async sources (url, pdf) and their digest status.
Full source object fields:
| Field | Type | Notes |
|---|---|---|
id | string | |
projectId | string | |
type | TEXT / MARKDOWN / URL / PDF | Note: uppercase in response, lowercase in request |
label | string or null | |
uri | string or null | For url/pdf sources |
status | PENDING / EXTRACTING / READY / FAILED | |
retentionPolicy | string | |
byteSize | integer or null | Bytes of extracted text |
tokenEstimate | integer or null | |
contentHash | string or null | |
extractedTextPreview | string or null | First 4000 chars |
extractedAt | datetime or null | |
failureCode | string or null | On FAILED |
failureMessage | string or null | On FAILED |
metadata | object or null | e.g. { pageCount: 12 } for PDF |
digestStatus | NOT_STARTED / PENDING / RUNNING / READY / FAILED / SKIPPED | |
digest | digest object or null | Populated when digestStatus: READY |
digestFailureCode | string or null | |
digestFailureMessage | string or null | |
digestedAt | datetime or null | |
createdAt | datetime | |
updatedAt | datetime |
Errors: source_not_found (404).
DELETE /story-projects/{projectId}/sources/{sourceId}
Soft-delete a source. Scope: source:write.
Response — 200 OK:
{ "id": "cmq...", "deletedAt": "2026-05-29T10:00:00.000Z" }
Errors: source_not_found.
Story Plans
POST /story-projects/{projectId}/story-plans
Generate a StoryPlan asynchronously. Scope: story:write. Accepts Idempotency-Key.
The API validates the request synchronously (target shape, artifact types, source ownership, and source READY status) and returns immediately with status: PENDING. The planning worker runs in the background.
Request body:
{ "title": "Optional plan title (max 200 chars)", "target": { "audience": "enterprise buyers", "objective": "explain the Q3 product launch", "outcome": "book a demo within 7 days", "tone": "confident, specific, executive", "industry": "enterprise SaaS", "distributionContext": "LinkedIn + sales enablement deck" }, "artifactRequests": [ { "type": "table_read", "mode": "documentary", "durationSeconds": 90, "notes": "Focus on the collaboration angle" } ], "styleConstraints": { "preferredVisualStyle": "Clean, data-forward documentary", "forbiddenVisuals": ["stock photos of handshakes"], "voicePreference": "Authoritative, not salesy", "musicPolicy": "Ambient corporate, no lyrics", "brandSafety": ["no competitor mentions"] }, "sourceIds": ["cmq...", "cmq..."], "autoApprove": false }
target fields:
| Field | Required | Notes |
|---|---|---|
audience | yes | Max 200 chars |
objective | yes | Max 400 chars |
outcome | yes | Max 400 chars |
tone | no | Max 120 chars |
industry | no | Max 120 chars |
distributionContext | no | Max 300 chars |
artifactRequests fields:
| Field | Required | Notes |
|---|---|---|
type | yes | table_read, pitch_deck, storyboard, trailer, production_video (check capabilities for live types) |
mode | no | documentary, podcast, or drama for table_read |
narrationPolicy | no | auto, include, or suppress for table_read. Narrator/no-narrator is independent of mode; podcasts, documentaries, dramas, panels, audio essays, and casefile reads can each be narrated or narrator-free when that fits the piece. |
durationSeconds | no | Minimum 5 seconds; artifact adapter may clamp |
aspectRatio | no | Pattern \d+:\d+ e.g. "16:9" |
includePdf | no | For pitch_deck |
notes | no | Max 2000 chars — freeform instructions for this artifact |
Min 1, max 6 artifact requests per plan.
styleConstraints — all fields optional, all arrays max 10 items.
sourceIds — max 20. All IDs must belong to the project, be non-deleted, and have status: READY. If omitted or empty, the planner uses no source material.
autoApprove — boolean, default false.
Response — 200 OK: Returns the plan at status: PENDING with a preliminary quote:
{ "plan": { "id": "cmpl...", "projectId": "cmp...", "status": "PENDING", "title": null, "target": { ... }, "artifactRequests": [ ... ], "styleConstraints": null, "sourceIds": ["cmq..."], "autoApprove": true, "plan": null, "quote": { "currency": "studio_credits", "total": 11, "lineItems": [ { "kind": "planning", "label": "StoryPlan generation", "credits": 0 }, { "kind": "source_digest", "label": "Source digest (1 source)", "credits": 0 }, { "kind": "artifact", "label": "Table read", "credits": 11 } ], "notes": [], "computedAt": "2026-05-29T10:00:00.000Z" }, "modelUsed": null, "providerUsed": null, "failureCode": null, "failureMessage": null, "approvedAt": null, "rejectedAt": null, "rejectionReason": null, "generatedAt": null, "currentVersion": 1, "createdAt": "2026-05-29T10:00:00.000Z", "updatedAt": "2026-05-29T10:00:00.000Z" } }
Errors: project_not_found, source_not_found (any sourceId invalid), validation_failed.
GET /story-projects/{projectId}/story-plans
List plans for a project. Scope: story:read. Cursor-paginated.
Response — 200 OK:
{ "plans": [...], "nextCursor": "..." }
GET /story-plans/{planId}
Read a single plan. Scope: story:read. Use to poll the planning run.
Plan status values:
| Status | Meaning |
|---|---|
PENDING | Created; worker not yet started |
RUNNING | Worker is generating the plan |
REQUIRES_APPROVAL | Generation succeeded; waiting for explicit /approve |
APPROVED | Ready for job creation (via autoApprove or explicit /approve) |
REJECTED | Rejected via /reject; terminal |
FAILED | Generation failed; inspect failureCode / failureMessage |
SUPERSEDED | A newer plan was created for the same project; this plan can no longer drive a job |
When status is APPROVED or REQUIRES_APPROVAL, the plan.plan field contains the full plan envelope (see concepts.md#the-plan-envelope).
Errors: plan equivalent of not_found → project_not_found (the plan is treated as belonging to the same ownership check).
POST /story-plans/{planId}/approve
Approve a plan that is at REQUIRES_APPROVAL. Scope: story:write. Idempotent — approving an already-APPROVED plan is a no-op.
No request body required.
Response — 200 OK: Updated plan envelope with status: APPROVED and approvedAt populated.
Errors: validation_failed if the plan is not in an approvable state.
POST /story-plans/{planId}/reject
Reject a plan. Scope: story:write. The plan transitions to REJECTED (terminal).
Request body (optional):
{ "reason": "Tone was too aggressive" }
Response — 200 OK: Updated plan envelope with status: REJECTED.
Story Jobs
POST /story-jobs
Create a job to generate artifacts from an APPROVED plan. Scope: story:write. Accepts Idempotency-Key (strongly recommended — this operation reserves credits).
Request body:
{ "storyPlanId": "cmpl...", "projectId": "cmp...", "artifactRequests": [ { "type": "table_read", "mode": "documentary", "narrationPolicy": "include" } ] }
| Field | Required | Notes |
|---|---|---|
storyPlanId | yes | The plan must be APPROVED |
projectId | no | Must match the plan's project if supplied; optional cross-check |
artifactRequests | no | Defaults to the plan's own requests; overrides if supplied (max 6) |
Credits are reserved at create time. If the reservation fails, the API returns 402 with insufficient_credits and details.required / details.available.
Response — 200 OK:
{ "job": { "id": "cmj...", "projectId": "cmp...", "storyPlanId": "cmpl...", "status": "RESERVED", "artifactRequests": [ ... ], "quote": { ... }, "creditReservationId": "cmcr...", "progress": null, "modelUsed": null, "failureCode": null, "failureMessage": null, "currentVersion": 1, "startedAt": null, "completedAt": null, "artifacts": [], "createdAt": "2026-05-29T10:00:00.000Z", "updatedAt": "2026-05-29T10:00:00.000Z" } }
Errors:
story_job_not_found— the referenced plan does not exist or is not owned by you.story_job_state_invalid— the plan is notAPPROVED.insufficient_credits— credit reservation failed.validation_failed— bad artifact request.
GET /story-jobs
List jobs. Scope: story:read. Cursor-paginated.
Query params:
projectId— filter by project.storyPlanId— filter by plan.limit,cursor— pagination.
Response — 200 OK:
{ "jobs": [...], "nextCursor": "..." }
GET /story-jobs/{jobId}
Read and poll a single job. Scope: story:read.
Job status values:
| Status | Meaning |
|---|---|
PENDING | Created; not yet reserved |
RESERVED | Credits reserved; worker starting |
RUNNING | Worker is generating artifacts |
PARTIAL | At least one artifact READY, others still running |
READY | All artifacts at terminal status |
FAILED | Job-level failure; see failureCode |
CANCELED | Canceled; credits released |
The job carries an artifacts[] array. Each artifact has its own status and manifest when ready.
Polling interval: 5–15 seconds recommended.
Response — 200 OK (when READY):
{ "job": { "id": "cmj...", "status": "READY", "completedAt": "2026-05-29T10:08:00.000Z", "artifacts": [ { "id": "cma...", "type": "table_read", "status": "READY", "manifest": { "type": "table_read", "status": "ready", "shareToken": "<jwt>", "theaterUrl": "https://sleeperhit.studio/share/table-read/<token>", "theaterFullscreenUrl": "https://sleeperhit.studio/share/table-read/<token>?view=fullscreen", "audio": { "mode": "live_on_demand", "liveUrl": "https://sleeperhit.studio/share/table-read-audio/<token>", "startLiveUrl": "https://sleeperhit.studio/api/share/table-read/start-live", "recordingUrl": null }, "video": { "status": "not_rendered", "renderUrl": "/api/v1/artifacts/cma.../render-video", "url": null }, "expiresAt": "2026-06-29T10:00:00.000Z" } } ] } }
Errors: story_job_not_found.
POST /story-jobs/{jobId}/cancel
Cancel a non-terminal job and release reserved credits. Scope: story:write. Idempotent.
Cancelable while status is PENDING, RESERVED, or RUNNING.
No request body.
Response — 200 OK: Updated job envelope with status: CANCELED.
Errors: story_job_not_found, story_job_state_invalid (job already in terminal state — details.status and details.allowed describe the issue).
GET /story-jobs/{jobId}/artifacts
List artifacts for a job. Scope: artifact:read.
Response — 200 OK:
{ "artifacts": [ ... ] }
Artifacts
GET /artifacts/{artifactId}
Read a single artifact and its manifest. Scope: artifact:read.
Query params:
revision— optional integer. Fetch a specific historical revision of the manifest (v1 = initial generation; each refine increments). Omit for the current (latest) revision.currentVersionandrefineCountalways reflect the latest state regardless of the revision queried.
Artifact fields:
| Field | Type | Notes |
|---|---|---|
id | string | Stable across refines |
storyJobId | string | |
type | string | e.g. table_read |
status | PENDING / RUNNING / READY / FAILED | |
manifest | object or null | Populated when READY. Poll this for audio.finalize.status and video.status. |
currentVersion | integer | Starts at 1; increments with each successful refine |
refineCount | integer | Number of refines applied; 0 for initial generation |
tableReadShareLinkId | string or null | Internal share link reference |
failureCode | string or null | On FAILED |
failureMessage | string or null | |
createdAt | datetime | |
updatedAt | datetime |
Errors: artifact_not_found.
POST /artifacts/{artifactId}/render-video
Trigger the opt-in MP4 render for a table read artifact. Scope: artifact:publish. Accepts Idempotency-Key. Incurs a separate Studio Credit charge.
The artifact must be READY with a live share link (tableReadShareLinkId populated). Returns artifact_not_ready if called too early.
No request body.
Response — 200 OK:
{ "artifactId": "cma...", "render": { "status": "rendering", ... } }
To track progress, poll GET /artifacts/{artifactId} and check manifest.video.status and manifest.video.url.
Video statuses: not_rendered → queued → rendering → complete.
The same render is also reachable via POST /artifacts/{artifactId}/finalize with { "mode": "video" }.
Errors: artifact_not_found, artifact_not_ready.
POST /artifacts/{artifactId}/refine
Conversationally revise a table read artifact in place. Scope: artifact:publish. Requires Idempotency-Key.
Produces a new revision of the same artifact — the artifact id, share token, and all three player URLs (theaterUrl, theaterFullscreenUrl, audio.liveUrl) remain unchanged. Reserves TABLE_READ_REFINE (8 credits) at job create time.
Request body:
{ "instruction": "rename the host to Dana and drop the narrator; make the skeptic warmer", "scope": "screenplay" }
| Field | Required | Notes |
|---|---|---|
instruction | yes | Free-form revision instruction. |
scope | no | "auto" (default) / "screenplay" / "plan". "screenplay" rewrites cast + screenplay only. "plan" re-grounds against sources first — slower, use for major structural changes. "auto" lets the model self-route. |
Response — 200 OK:
{ "storyJob": { "id": "cmj...", "status": "RESERVED", ... } }
Poll GET /api/v1/story-jobs/{jobId} until terminal (READY / FAILED), exactly like a standard artifact generation job.
On READY, GET /artifacts/{artifactId} reflects the updated currentVersion and refineCount. Fetch a prior revision with GET /artifacts/{artifactId}?revision=N (v1 = initial generation).
Errors: validation_failed (missing instruction or invalid scope), artifact_not_found, artifact_not_ready (artifact not yet in a refineable state), insufficient_credits (8 credits required).
POST /artifacts/{artifactId}/finalize
Durably render a table read artifact as an MP3 or MP4. Scope: artifact:publish. Requires Idempotency-Key.
Request body:
{ "mode": "audio" }
| Field | Required | Notes |
|---|---|---|
mode | no | "audio" (default) or "video". |
mode: "audio" — 6 credits (TABLE_READ_FINALIZE_AUDIO)
Freezes a completed Pipecat/Daily table-read recording into a permanent full-mix MP3 with voices, music, and sound effects. If no completed recording exists yet, the call returns needs_recording and does not reserve credits.
Poll GET /artifacts/{artifactId} and check manifest.audio.finalize.status:
| Stage | Description |
|---|---|
needs_recording | Start and record the live read before finalizing |
queued | Job accepted; worker starting |
rendering | Mixing the recorded performance with music and sound effects |
uploading | Uploading to durable storage |
complete | Done — manifest.audio.recordingUrl and manifest.audio.recordingDurationMs populated |
failed | Failed — inspect manifest.audio.finalize.error |
On complete, manifest.audio.recordingUrl is the durable MP3 download URL and manifest.audio.recordingDurationMs is the duration in milliseconds.
mode: "video" — 12 credits (TABLE_READ_FINALIZE_VIDEO)
Queues the Remotion MP4 export (the same render as POST /render-video).
Poll GET /artifacts/{artifactId} and check manifest.video.status and manifest.video.url.
Replay-safe: Re-calling finalize when already complete or in-flight returns the current status without re-charging credits.
Response — 200 OK:
{ "artifactId": "cma...", "mode": "audio", "finalize": { "status": "queued" } }
Errors: validation_failed (invalid mode), artifact_not_found, artifact_not_ready, insufficient_credits.
POST /artifacts/{artifactId}/voice
Recast a single character voice on a table read artifact. Scope: artifact:publish. Requires Idempotency-Key.
This mutates the live read in place without creating a new revision, spending credits, or changing the artifact id/share URLs.
Request body:
{ "character": "NARRATOR", "voiceId": "voice_123", "voiceName": "Warm Documentary Host", "gender": "neutral", "provider": "cartesia" }
Required fields: character, voiceId, voiceName. gender and provider are optional.
Response — 200 OK:
{ "artifactId": "cma...", "character": "NARRATOR", "voiceMap": { "NARRATOR": { "voiceId": "voice_123", "voiceName": "Warm Documentary Host" } } }
The current cast is also readable on manifest.audio.voiceMap from GET /artifacts/{artifactId}.
POST /artifacts/{artifactId}/coverage
Generate professional script coverage for the screenplay backing a table read. Scope: story:write. Requires Idempotency-Key.
Coverage is an async side report. It does not change the artifact, revision, or share URLs.
Request body:
{ "focusPrompt": "Focus on whether the midpoint turn is earned." }
focusPrompt is optional.
Response — 200 OK:
{ "reportId": "cmcov...", "status": "generating" }
Poll GET /artifacts/{artifactId}/coverage?reportId=<reportId> until coverage.status is complete or failed.
GET /artifacts/{artifactId}/coverage
Read the latest coverage report, or pass ?reportId=<id> for a specific report. Scope: artifact:read.
Returns { "coverage": null } if coverage has not been requested yet. When status: "complete", coverage.report carries the structured coverage payload.
POST /artifacts/{artifactId}/music
Generate or regenerate adaptive per-scene music for a table read, or tune one scene. Scope: artifact:publish.
This mutates the same live read in place without creating a new artifact revision. Share URLs remain stable.
Request body:
{ "musicCoveragePercent": 0.35 }
The body is optional. musicCoveragePercent accepts either a fraction (0.35) or whole percent (35) and is clamped into 0..1.
For scene-level edits, provide sceneIndex plus one or more music fields:
{ "sceneIndex": 2, "prompt": "heavier bass pulse under the reveal", "weight": 0.85 }
Use that shape for requests like "make scene 2 heavier bass" or "mute this scene" ({ "sceneIndex": 2, "enabled": false }). Scene edits return immediately and mark any finalized MP3/video stale until explicit finalize.
Response — 200 OK:
{ "artifactId": "cma...", "status": "generating", "coveragePercent": 0.35, "totalScenes": 8 }
Scene update responses return status: "ready" plus the updated adaptiveSceneDirection.
Poll GET /artifacts/{artifactId}/music until music.status is ready.
GET /artifacts/{artifactId}/music
Read adaptive music readiness. Scope: artifact:read.
Statuses: none, generating, ready.
POST /artifacts/{artifactId}/sfx
Add, update, or remove timed sound-effect cues on a table read. Scope: artifact:publish. Requires Idempotency-Key.
This mutates the same live read in place without creating a new artifact revision. Share URLs remain stable.
Add request body:
{ "op": "add", "entryIndex": 12, "label": "door knock", "prompt": "three crisp knocks on a wooden door", "volume": 0.55, "triggerOffset": "before" }
Remove request body:
{ "op": "remove", "id": "sfx_123" }
Update request body:
{ "op": "update", "id": "sfx_123", "label": "heavier door knock", "prompt": "three heavier knocks on a resonant wooden door", "volume": 0.75, "triggerOffset": "with", "regenerate": true }
Add and update return the cue. soundUrl may be null and isDraft: true when reusable sound generation is not ready yet.
Response — 200 OK:
{ "artifactId": "cma...", "cue": { "id": "sfx_123", "label": "door knock", "entryIndex": 12, "soundUrl": null, "isDraft": true } }
GET /artifacts/{artifactId}/sfx
List sound-effect cues for a table read. Scope: artifact:read.
Response — 200 OK:
{ "sfx": { "artifactId": "cma...", "cues": [] } }
Error codes
All 23 stable error codes, with recovery guidance:
| Code | HTTP | Description | Recovery |
|---|---|---|---|
authentication_required | 401 | No bearer key in the Authorization header | Add the header |
invalid_api_key | 401 | Key is malformed, unknown, or secret does not match | Rotate the key |
api_key_revoked | 401 | Key has been revoked | Issue a new key |
api_key_expired | 401 | Key has passed its expiration timestamp | Issue a new key |
account_disabled | 403 | The account that owns this key is disabled | Contact support |
ip_not_allowed | 403 | Request IP not in the key's allowlist | Add the IP in Dashboard or use a different key |
insufficient_scope | 403 | Key lacks the required scope | Ask account owner to add the scope |
rate_limited | 429 | Per-key + per-IP rate limit exceeded | Wait Retry-After seconds |
idempotency_key_required | 422 | Endpoint requires Idempotency-Key header | Add the header |
idempotency_conflict | 409 | Same key used with different parameters | Generate a new key for a new operation, or poll for the original |
validation_failed | 422 | Request payload failed validation | Check error.message for the offending field |
project_not_found | 404 | Project does not exist, is deleted, or is owned by another account | Check the project ID |
source_not_found | 404 | Source does not exist, is deleted, or is owned by another account | Check the source ID |
source_type_unsupported | 422 | Source type not yet supported | Check /api/v1/capabilities.sources.types |
source_too_large | 422 | Payload exceeds size limit | See error.details.maxBytes |
source_fetch_failed | 422 | URL/PDF fetch failed (SSRF block, redirect limit, size, content-type) | Check error.message; ensure URL is public and content-type matches |
story_job_not_found | 404 | Job does not exist, is deleted, or is owned by another account | Check the job ID |
story_job_state_invalid | 409 | Job is in a state that does not allow the operation | See error.details.status and error.details.allowed |
artifact_not_found | 404 | Artifact does not exist or owned by another account | Check the artifact ID |
artifact_not_ready | 409 | Artifact is not yet in a state where this operation is valid | Poll until status: READY |
artifact_generation_failed | 422 | The artifact adapter failed | Inspect artifact failureCode / failureMessage; retry or contact support |
insufficient_credits | 402 | Not enough Studio Credits to reserve the job | See error.details.required / error.details.available; add credits before retrying |
internal_error | 500 | Unexpected server failure | Retry with the same Idempotency-Key; contact support if persistent |
Error envelope
{ "error": { "code": "insufficient_credits", "message": "The account does not have enough Studio Credits to reserve this job.", "requestId": "7f9d4b6a-4f0a-4a8c-9c2e-d5b3b8e6a0f1" } }
requestId is also set in the X-Request-Id response header. Include it in any support report.
Response headers
Every response includes:
| Header | Description |
|---|---|
X-Request-Id | UUID matching error.requestId when errors occur; always present |
RateLimit-Limit | Max requests per window for this route family |
RateLimit-Remaining | Requests left in the current window |
RateLimit-Reset | Seconds until the window resets |
Retry-After | (429 only) Seconds to wait before retrying |
Idempotency-Replayed | true when the response is a replay of a prior idempotent request |
Idempotency
Any POST that reserves credits or queues generation accepts Idempotency-Key. Supply a UUID stable for the logical operation:
Idempotency-Key: 550e8400-e29b-41d4-a716-446655440000
Retrying with the same key and the same body replays the original response. Retrying with a different body returns idempotency_conflict.
Generate a new key only when you intend a genuinely new operation. Persist the key alongside your local job record before sending the request.
Rate limits
Default tier: 120 requests per minute (per key + IP per route family). Elevated keys: 600 per minute.
On 429, wait exactly Retry-After seconds before retrying. Do not implement exponential backoff on top of Retry-After — the header already gives the correct wait time.
Pagination
List endpoints use cursor pagination:
GET /story-projects?limit=20&cursor=<opaque>
nextCursor in the response is an opaque string. Pass it as cursor on the next request. When nextCursor is null, you have reached the last page. The default page size is 20; the maximum is 100.