Sleeper Hit Studio

API Reference

Endpoint reference for the Story API.

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

ScopeRequired for
story:readGET capabilities, GET/list projects, sources, plans, jobs
story:writePOST/PATCH/DELETE projects; POST sources, plans, jobs; POST approve/reject/cancel
source:readGET individual sources
source:writePOST source creation; DELETE sources
artifact:readGET artifacts
artifact:publishPOST refine, recast, finalize, render-video, music, and SFX mutations
credits:readGET credits balance
webhook:writeComing 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's nextCursor.

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" } }
FieldRequired whenNotes
typealways"text", "markdown", "url", or "pdf"
contenttype is text or markdownMax 1,000,000 bytes (UTF-8)
uritype is url or pdfMust be http or https; SSRF-blocked addresses rejected
labelneverHuman-readable name
retentionPolicyneverEXTRACTED_ONLY (default), STORE_ORIGINAL, METADATA_ONLY
metadataneverArbitrary 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:

FieldTypeNotes
idstring
projectIdstring
typeTEXT / MARKDOWN / URL / PDFNote: uppercase in response, lowercase in request
labelstring or null
uristring or nullFor url/pdf sources
statusPENDING / EXTRACTING / READY / FAILED
retentionPolicystring
byteSizeinteger or nullBytes of extracted text
tokenEstimateinteger or null
contentHashstring or null
extractedTextPreviewstring or nullFirst 4000 chars
extractedAtdatetime or null
failureCodestring or nullOn FAILED
failureMessagestring or nullOn FAILED
metadataobject or nulle.g. { pageCount: 12 } for PDF
digestStatusNOT_STARTED / PENDING / RUNNING / READY / FAILED / SKIPPED
digestdigest object or nullPopulated when digestStatus: READY
digestFailureCodestring or null
digestFailureMessagestring or null
digestedAtdatetime or null
createdAtdatetime
updatedAtdatetime

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:

FieldRequiredNotes
audienceyesMax 200 chars
objectiveyesMax 400 chars
outcomeyesMax 400 chars
tonenoMax 120 chars
industrynoMax 120 chars
distributionContextnoMax 300 chars

artifactRequests fields:

FieldRequiredNotes
typeyestable_read, pitch_deck, storyboard, trailer, production_video (check capabilities for live types)
modenodocumentary, podcast, or drama for table_read
narrationPolicynoauto, 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.
durationSecondsnoMinimum 5 seconds; artifact adapter may clamp
aspectRationoPattern \d+:\d+ e.g. "16:9"
includePdfnoFor pitch_deck
notesnoMax 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:

StatusMeaning
PENDINGCreated; worker not yet started
RUNNINGWorker is generating the plan
REQUIRES_APPROVALGeneration succeeded; waiting for explicit /approve
APPROVEDReady for job creation (via autoApprove or explicit /approve)
REJECTEDRejected via /reject; terminal
FAILEDGeneration failed; inspect failureCode / failureMessage
SUPERSEDEDA 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_foundproject_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" } ] }
FieldRequiredNotes
storyPlanIdyesThe plan must be APPROVED
projectIdnoMust match the plan's project if supplied; optional cross-check
artifactRequestsnoDefaults 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 not APPROVED.
  • 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:

StatusMeaning
PENDINGCreated; not yet reserved
RESERVEDCredits reserved; worker starting
RUNNINGWorker is generating artifacts
PARTIALAt least one artifact READY, others still running
READYAll artifacts at terminal status
FAILEDJob-level failure; see failureCode
CANCELEDCanceled; 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. currentVersion and refineCount always reflect the latest state regardless of the revision queried.

Artifact fields:

FieldTypeNotes
idstringStable across refines
storyJobIdstring
typestringe.g. table_read
statusPENDING / RUNNING / READY / FAILED
manifestobject or nullPopulated when READY. Poll this for audio.finalize.status and video.status.
currentVersionintegerStarts at 1; increments with each successful refine
refineCountintegerNumber of refines applied; 0 for initial generation
tableReadShareLinkIdstring or nullInternal share link reference
failureCodestring or nullOn FAILED
failureMessagestring or null
createdAtdatetime
updatedAtdatetime

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

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" }
FieldRequiredNotes
instructionyesFree-form revision instruction.
scopeno"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" }
FieldRequiredNotes
modeno"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:

StageDescription
needs_recordingStart and record the live read before finalizing
queuedJob accepted; worker starting
renderingMixing the recorded performance with music and sound effects
uploadingUploading to durable storage
completeDone — manifest.audio.recordingUrl and manifest.audio.recordingDurationMs populated
failedFailed — 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:

CodeHTTPDescriptionRecovery
authentication_required401No bearer key in the Authorization headerAdd the header
invalid_api_key401Key is malformed, unknown, or secret does not matchRotate the key
api_key_revoked401Key has been revokedIssue a new key
api_key_expired401Key has passed its expiration timestampIssue a new key
account_disabled403The account that owns this key is disabledContact support
ip_not_allowed403Request IP not in the key's allowlistAdd the IP in Dashboard or use a different key
insufficient_scope403Key lacks the required scopeAsk account owner to add the scope
rate_limited429Per-key + per-IP rate limit exceededWait Retry-After seconds
idempotency_key_required422Endpoint requires Idempotency-Key headerAdd the header
idempotency_conflict409Same key used with different parametersGenerate a new key for a new operation, or poll for the original
validation_failed422Request payload failed validationCheck error.message for the offending field
project_not_found404Project does not exist, is deleted, or is owned by another accountCheck the project ID
source_not_found404Source does not exist, is deleted, or is owned by another accountCheck the source ID
source_type_unsupported422Source type not yet supportedCheck /api/v1/capabilities.sources.types
source_too_large422Payload exceeds size limitSee error.details.maxBytes
source_fetch_failed422URL/PDF fetch failed (SSRF block, redirect limit, size, content-type)Check error.message; ensure URL is public and content-type matches
story_job_not_found404Job does not exist, is deleted, or is owned by another accountCheck the job ID
story_job_state_invalid409Job is in a state that does not allow the operationSee error.details.status and error.details.allowed
artifact_not_found404Artifact does not exist or owned by another accountCheck the artifact ID
artifact_not_ready409Artifact is not yet in a state where this operation is validPoll until status: READY
artifact_generation_failed422The artifact adapter failedInspect artifact failureCode / failureMessage; retry or contact support
insufficient_credits402Not enough Studio Credits to reserve the jobSee error.details.required / error.details.available; add credits before retrying
internal_error500Unexpected server failureRetry 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:

HeaderDescription
X-Request-IdUUID matching error.requestId when errors occur; always present
RateLimit-LimitMax requests per window for this route family
RateLimit-RemainingRequests left in the current window
RateLimit-ResetSeconds until the window resets
Retry-After(429 only) Seconds to wait before retrying
Idempotency-Replayedtrue 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.