This walkthrough goes from a fresh API key to a live, shareable table read URL. It covers every step in the happy path: discover capabilities, check credits, create a project, attach sources, write the Series Bible and pass its coverage gate, generate and approve a plan, cast the voices, run a job, and read the artifact manifest.
All examples use https://sleeperhit.studio as the base URL and $SLEEPERHIT_API_KEY as the environment variable holding your key.
Prerequisites
- An API key from
https://sleeperhit.studio/dashboard/developers. - The key must have scopes:
story:read,story:write,source:write,credits:read,artifact:read. Addartifact:publishfor optional refine, voice recast, finalize, render-video, music, and SFX mutations. If any scope is missing the API returnsinsufficient_scope— ask the account owner to add it. - Enough Studio Credits to cover the quoted artifact work. Planning and source digests are included; credits are reserved when you create/refine/finalize artifacts.
Step 1 — Confirm capabilities
Before every run, fetch the live capability manifest to confirm the features you need are available.
curl -s https://sleeperhit.studio/api/v1/capabilities \ -H "Authorization: Bearer $SLEEPERHIT_API_KEY" | jq .
const res = await fetch('https://sleeperhit.studio/api/v1/capabilities', { headers: { Authorization: `Bearer ${process.env.SLEEPERHIT_API_KEY}` }, }) if (!res.ok) throw new Error(`capabilities ${res.status}`) const caps = await res.json() // confirm table_read is available if (caps.artifacts.table_read?.availability !== 'available') { throw new Error('table_read not available in this environment') }
Key fields to check:
artifacts.table_read.availability— must be"available".sources.types— lists which source types (text,markdown,url,pdf) are"available".plans.autoApprove— whether eligible non-table artifacts may skip approval. Table-read plans always require exact human review and approval.
Cache the response for at most one minute.
Step 2 — Check your credit balance
Read the balance before triggering any generation that could fail with insufficient_credits. The plan will carry a quote you can compare against this.
curl -s https://sleeperhit.studio/api/v1/credits \ -H "Authorization: Bearer $SLEEPERHIT_API_KEY" | jq .credits.balance
const credRes = await fetch('https://sleeperhit.studio/api/v1/credits', { headers: { Authorization: `Bearer ${process.env.SLEEPERHIT_API_KEY}` }, }) const { credits } = await credRes.json() console.log('Available balance:', credits.balance) // credits.monthlyBucketRemaining + credits.perpetualBalance
Response shape:
{ "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", "breakdown": "250 of 300 from this month's plan (resets 2026-06-01)" } }
Step 3 — Create a project
A project is a named workspace that groups sources, plans, and jobs together. Use an Idempotency-Key so retries are safe.
curl -s https://sleeperhit.studio/api/v1/story-projects \ -H "Authorization: Bearer $SLEEPERHIT_API_KEY" \ -H "Idempotency-Key: $(uuidgen)" \ -H "Content-Type: application/json" \ -d '{"name":"Q3 Launch Story","description":"Product launch table read"}'
import { randomUUID } from 'node:crypto' const projectRes = await fetch('https://sleeperhit.studio/api/v1/story-projects', { method: 'POST', headers: { Authorization: `Bearer ${process.env.SLEEPERHIT_API_KEY}`, 'Idempotency-Key': randomUUID(), 'Content-Type': 'application/json', }, body: JSON.stringify({ name: 'Q3 Launch Story', description: 'Product launch table read', }), }) if (!projectRes.ok) throw new Error(`project create ${projectRes.status}`) const { project } = await projectRes.json() const projectId = project.id
Response:
{ "project": { "id": "cmp...", "name": "Q3 Launch Story", "description": "Product launch table read", "metadata": null, "archivedAt": null, "sourceCount": 0, "createdAt": "2026-05-29T10:00:00.000Z", "updatedAt": "2026-05-29T10:00:00.000Z" } }
Step 4 — Attach sources
Sources are the raw material the planner reads. You can attach multiple sources to one project. The planner uses source digests when available and falls back to the extracted-text preview.
Option A — Inline text (synchronous, instant READY)
Best for content you already have in memory: press releases, brand guides, script drafts.
curl -s https://sleeperhit.studio/api/v1/story-projects/$PROJECT_ID/sources \ -H "Authorization: Bearer $SLEEPERHIT_API_KEY" \ -H "Content-Type: application/json" \ -d '{"type":"text","label":"press-release","content":"<paste your text here>"}'
const sourceRes = await fetch( `https://sleeperhit.studio/api/v1/story-projects/${projectId}/sources`, { method: 'POST', headers: { Authorization: `Bearer ${process.env.SLEEPERHIT_API_KEY}`, 'Content-Type': 'application/json', }, body: JSON.stringify({ type: 'text', label: 'press-release', content: pressReleaseText, }), } ) const { source } = await sourceRes.json() // source.status === 'READY' immediately for inline text const sourceId = source.id
Option B — URL (async, must poll)
The API fetches the URL in a background worker. Private IP addresses, cloud-metadata endpoints, and non-text content types are blocked (SSRF guard). Returns PENDING immediately; poll until READY or FAILED.
# Submit curl -s https://sleeperhit.studio/api/v1/story-projects/$PROJECT_ID/sources \ -H "Authorization: Bearer $SLEEPERHIT_API_KEY" \ -H "Content-Type: application/json" \ -d '{"type":"url","uri":"https://example.com/launch-article","label":"launch article"}' # Poll curl -s https://sleeperhit.studio/api/v1/story-projects/$PROJECT_ID/sources/$SOURCE_ID \ -H "Authorization: Bearer $SLEEPERHIT_API_KEY" | jq .source.status
// Submit const urlSourceRes = await fetch( `https://sleeperhit.studio/api/v1/story-projects/${projectId}/sources`, { method: 'POST', headers: { Authorization: `Bearer ${process.env.SLEEPERHIT_API_KEY}`, 'Content-Type': 'application/json', }, body: JSON.stringify({ type: 'url', uri: 'https://example.com/launch-article', label: 'launch article' }), } ) const { source } = await urlSourceRes.json() // Poll until terminal async function pollSource(projectId, sourceId) { while (true) { const res = await fetch( `https://sleeperhit.studio/api/v1/story-projects/${projectId}/sources/${sourceId}`, { headers: { Authorization: `Bearer ${process.env.SLEEPERHIT_API_KEY}` } } ) const { source } = await res.json() if (source.status === 'READY' || source.status === 'FAILED') return source await new Promise((r) => setTimeout(r, 3000)) } } const readySource = await pollSource(projectId, source.id) if (readySource.status === 'FAILED') { throw new Error(`source failed: ${readySource.failureCode} — ${readySource.failureMessage}`) }
Option C — PDF (async, must poll)
Submit the public URL of a PDF. Same SSRF and redirect guards as URLs; the upstream Content-Type must be application/pdf. The worker extracts text and records metadata.pageCount. Poll the same way as URL sources.
curl -s https://sleeperhit.studio/api/v1/story-projects/$PROJECT_ID/sources \ -H "Authorization: Bearer $SLEEPERHIT_API_KEY" \ -H "Content-Type: application/json" \ -d '{"type":"pdf","uri":"https://example.com/whitepaper.pdf","label":"whitepaper"}'
Step 5 — Wait for source digests (optional but recommended)
After a source reaches READY, the API runs a structured digest pass and populates digestStatus. When digestStatus is READY, the digest field carries a summary, thesis, typed claims with attributions, evidence, gaps, and rights notes.
The planner requires each sourceId to be READY. It uses digests when available, which produces higher-quality plans. If you skip the digest wait, the planner falls back to the raw extracted-text preview.
Poll digestStatus on the source:
async function waitForDigest(projectId, sourceId) { while (true) { const res = await fetch( `https://sleeperhit.studio/api/v1/story-projects/${projectId}/sources/${sourceId}`, { headers: { Authorization: `Bearer ${process.env.SLEEPERHIT_API_KEY}` } } ) const { source } = await res.json() const done = ['READY', 'FAILED', 'SKIPPED'].includes(source.digestStatus) if (done) return source await new Promise((r) => setTimeout(r, 4000)) } }
SKIPPED is normal when the sidecar is not configured or the source has too little text, and by design for a full-context producer item (its plan reads it whole) — the source is still usable for planning.
Step 6 — Write the Series Bible before you plan
Planning is gated on the project's development stage, not on sources alone. POST /story-projects/{projectId}/story-plans answers 409 project_precondition_failed until the project's workspaceGate says canPlan: true, and a series has to pass two Series Bible coverage checkpoints to get there. A guide that jumped from sources straight to a plan met that 409 at the plan step with nothing on the page explaining it.
The Bible is the durable canon every later step reads: format, episode map, characters, style, audio direction. Write it from the sources you just attached — free — then pass the checkpoints it unlocks.
6a. Generate the Bible from the project
POST /story-projects/{projectId}/series-bible answers at once with status: "generating"; a queued run writes the Bible (a minute or two for a long source), and the saved document is replaced when the run is ready. The Bible already saved stays readable meanwhile.
curl -s -X POST https://sleeperhit.studio/api/v1/story-projects/$PROJECT_ID/series-bible \ -H "Authorization: Bearer $SLEEPERHIT_API_KEY" \ -H "Idempotency-Key: $(uuidgen)" \ -H "Content-Type: application/json" \ -d '{"instructions":"A documentary table read of the attached source, one host and one expert voice."}'
async function waitForBible(projectId) { await fetch(`https://sleeperhit.studio/api/v1/story-projects/${projectId}/series-bible`, { method: 'POST', headers: { Authorization: `Bearer ${process.env.SLEEPERHIT_API_KEY}`, 'Idempotency-Key': crypto.randomUUID(), 'Content-Type': 'application/json', }, body: JSON.stringify({ instructions: 'A documentary table read of the attached source.' }), }) while (true) { const res = await fetch(`https://sleeperhit.studio/api/v1/story-projects/${projectId}/series-bible`, { headers: { Authorization: `Bearer ${process.env.SLEEPERHIT_API_KEY}` }, }) const { document, generation } = await res.json() if (generation?.status === 'failed') throw new Error(`series bible: ${generation.error}`) if (generation?.status === 'ready') return document await new Promise((r) => setTimeout(r, 5000)) } }
A second POST while a run is being written returns that run (alreadyRunning: true), never a second one. instructions is optional. If the run fails, generation.error says why; POST again.
6b. Pass the coverage checkpoints the gate waits on
The gate is one read: GET /story-projects/{projectId} answers { project }, and the gate is project.workspaceGate (with stage, reason, missingFields, canPlan) — not a top-level field of the answer. Coverage is scored per checkpoint, 0–10, and passes at 7.0 (gateThreshold) or by the writer's recorded acknowledgement.
workspaceGate.stage | What to do |
|---|---|
premise | The Bible is incomplete. missingFields names what to add (PATCH /story-projects/{projectId}/series-bible), then score premise coverage. |
premise_coverage | POST /story-projects/{projectId}/series-bible/coverage with { "phase": "premise" }, then poll GET …/coverage?phase=premise. |
planning | Premise coverage passed and the Bible has no episode map. Save one — the plan expands it. |
full_coverage | POST …/coverage with { "phase": "full" } (only after the map), then poll GET …/coverage?phase=full. |
ready | canPlan is true; go on to Step 7. |
A coverage POST queues the run and answers at once (status: "generating"); the GET answers { coverage, gateOverride }, and coverage.status is complete or failed. A complete report under the bar is never treated as passed: coverage.gatePassed is false, coverage.overallScore says by how much, and coverage.report.priorityFixes names the moves. Fix the Bible and score it again, or — as the writer, having seen the scores — continue with POST …/coverage/acknowledge, sending phase, the reportId you read and a 3–500 character reason; read gateOverride first, it says whether that report can be acknowledged and why not.
async function passCoverage(projectId, phase) { await fetch(`https://sleeperhit.studio/api/v1/story-projects/${projectId}/series-bible/coverage`, { method: 'POST', headers: { Authorization: `Bearer ${process.env.SLEEPERHIT_API_KEY}`, 'Idempotency-Key': crypto.randomUUID(), 'Content-Type': 'application/json', }, body: JSON.stringify({ phase }), }) while (true) { const res = await fetch(`https://sleeperhit.studio/api/v1/story-projects/${projectId}/series-bible/coverage?phase=${phase}`, { headers: { Authorization: `Bearer ${process.env.SLEEPERHIT_API_KEY}` }, }) const { coverage } = await res.json() if (coverage.status === 'failed') throw new Error(coverage.failureMessage) if (coverage.status === 'complete') { if (!coverage.gatePassed) { throw new Error(`${phase} coverage scored ${coverage.overallScore} under ${coverage.gateThreshold}: fix the Bible, or acknowledge the report as the writer`) } return coverage } await new Promise((r) => setTimeout(r, 5000)) } } async function waitForGate(projectId) { while (true) { const res = await fetch(`https://sleeperhit.studio/api/v1/story-projects/${projectId}`, { headers: { Authorization: `Bearer ${process.env.SLEEPERHIT_API_KEY}` }, }) const { project } = await res.json() const { workspaceGate } = project if (workspaceGate.canPlan) return workspaceGate if (workspaceGate.stage === 'premise') throw new Error(`Series Bible incomplete: ${workspaceGate.missingFields.join(', ')}`) if (workspaceGate.stage === 'planning') throw new Error('The Bible has no episode map yet: save one episode on the Bible, then plan') await passCoverage(projectId, workspaceGate.stage === 'full_coverage' ? 'full' : 'premise') } }
Saving the episode map before premise coverage has passed is refused with the same 409 project_precondition_failed (details.stage is premise_coverage); the rest of the Bible saves either way, and the map is what the planner expands into seasons and episodes.
A one-off has no coverage stage. When the Bible is one film (a short, a feature — its format says so and nothing in the canon says series), the server runs no series coverage for it: the gate is premise → planning → ready, so after the Bible lands only the episode map can hold it. The table above still walks you out of it.
Step 7 — Generate a StoryPlan
The plan is the central abstraction: a source-grounded narrative with thesis, beats, visual system, audio system, and per-artifact render intent. It also carries a deterministic Studio Credit quote.
Table-read plans always stop for exact human review. Leave autoApprove false or omit it, poll to REQUIRES_APPROVAL, show the returned plan to the user, and approve only after they explicitly accept it. Other artifact types may use autoApprove only when the capability manifest says they are eligible.
curl -s https://sleeperhit.studio/api/v1/story-projects/$PROJECT_ID/story-plans \ -H "Authorization: Bearer $SLEEPERHIT_API_KEY" \ -H "Idempotency-Key: $(uuidgen)" \ -H "Content-Type: application/json" \ -d '{ "target": { "audience": "enterprise buyers", "objective": "introduce the Q3 product launch", "outcome": "book a demo within 7 days", "tone": "confident, specific" }, "artifactRequests": [ { "type": "table_read", "mode": "documentary" } ], "sourceIds": ["<source-id-1>"] }'
const planRes = await fetch( `https://sleeperhit.studio/api/v1/story-projects/${projectId}/story-plans`, { method: 'POST', headers: { Authorization: `Bearer ${process.env.SLEEPERHIT_API_KEY}`, 'Idempotency-Key': randomUUID(), 'Content-Type': 'application/json', }, body: JSON.stringify({ target: { audience: 'enterprise buyers', objective: 'introduce the Q3 product launch', outcome: 'book a demo within 7 days', tone: 'confident, specific', }, artifactRequests: [{ type: 'table_read', mode: 'documentary' }], sourceIds: [sourceId], }), } ) const { plan } = await planRes.json() const planId = plan.id
The plan is created synchronously but the planning run is async. The response immediately returns the plan record at status: PENDING. A project whose gate is still shut is refused here with 409 project_precondition_failed — details.stage is the step of Step 6 that is left — and nothing is created.
Step 8 — Poll the plan until approved
Poll every 3–5 seconds until the status is terminal.
Terminal states: REQUIRES_APPROVAL, APPROVED, REJECTED, FAILED, SUPERSEDED.
A table-read plan lands at REQUIRES_APPROVAL. Show the exact returned plan to the user and POST to /approve with { "userConfirmed": true } only after they explicitly accept it. Eligible non-table plans may land at APPROVED when autoApprove is enabled.
curl -s https://sleeperhit.studio/api/v1/story-plans/$PLAN_ID \ -H "Authorization: Bearer $SLEEPERHIT_API_KEY" | jq .plan.status
async function pollPlan(planId) { const terminal = new Set(['REQUIRES_APPROVAL', 'APPROVED', 'REJECTED', 'FAILED', 'SUPERSEDED']) while (true) { const res = await fetch(`https://sleeperhit.studio/api/v1/story-plans/${planId}`, { headers: { Authorization: `Bearer ${process.env.SLEEPERHIT_API_KEY}` }, }) const { plan } = await res.json() if (terminal.has(plan.status)) return plan await new Promise((r) => setTimeout(r, 4000)) } } const approvedPlan = await pollPlan(planId) if (approvedPlan.status === 'FAILED') { throw new Error(`plan failed: ${approvedPlan.failureCode} — ${approvedPlan.failureMessage}`) } if (approvedPlan.status === 'REQUIRES_APPROVAL') { console.dir(approvedPlan.plan, { depth: null }) const userConfirmed = await collectExplicitUserApproval(approvedPlan) if (!userConfirmed) throw new Error('The user did not approve this plan.') await fetch(`https://sleeperhit.studio/api/v1/story-plans/${planId}/approve`, { method: 'POST', headers: { Authorization: `Bearer ${process.env.SLEEPERHIT_API_KEY}`, 'Content-Type': 'application/json', 'Idempotency-Key': crypto.randomUUID(), }, body: JSON.stringify({ userConfirmed: true }), }) }
Once approved, inspect the quote field:
{ "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 } ], "computedAt": "2026-05-29T10:05:00.000Z" } }
This quote is deterministic from the plan. Credits are not spent here — they are reserved in the next step.
Step 9 — Cast the voices
A table read performs a cast; it does not make one. POST /story-jobs refuses a table_read with 409 cast_precondition_failed until every character the plan's screenplay will voice has a voice — and the refusal names them in details.missing. Check before you spend: GET /story-projects/{projectId} answers { project }, whose project.tableReadReadiness gives ready, a reason, and every member with what they are missing.
The cast canon is where those voices live. If your account already has saved voices (GET /voices — the ones you designed or cloned, each with id, name and provider), pin one per Series Bible character; this is free and every later episode inherits it:
curl -s -X PATCH https://sleeperhit.studio/api/v1/story-projects/$PROJECT_ID/cast-canon \ -H "Authorization: Bearer $SLEEPERHIT_API_KEY" \ -H "Content-Type: application/json" \ -d '{"characters":[{"name":"Mara","voiceId":"<voice-id>","voiceName":"Warm Documentary Host"}]}'
A fresh account has no saved voice yet. Design one per character from the Bible's own voice line: POST /story-projects/{projectId}/cast-voice/design with character and a description (20+ characters) prices the candidates and draws nothing, then the same call with confirmed: true draws them (every candidate is charged); keep one with POST /story-projects/{projectId}/cast-voice/choose (designId, candidate), which pins it on the cast canon and adds it to your voice library. Chosen voices are free to use from then on.
Two alternatives that need no saved voice: pass the voices with the job's table_read request instead (artifactRequests: [{ type: 'table_read', voiceMap: { "Mara": { "voiceId": "…" } } }] — a canon voice wins for the same person), or let the Cast surface draw a voice and portraits for the whole project cast with POST /story-projects/{projectId}/cast: sent without scriptUploadId and without confirmed it prices the cast and draws nothing, then the same call with confirmed: true and that quoteId reserves the Studio Credits and casts it. Pictures are never a read requirement — only a missing voice holds a read shut.
Step 10 — Create a story job
The job reserves the credits and queues the artifact generation.
curl -s https://sleeperhit.studio/api/v1/story-jobs \ -H "Authorization: Bearer $SLEEPERHIT_API_KEY" \ -H "Idempotency-Key: $(uuidgen)" \ -H "Content-Type: application/json" \ -d '{"storyPlanId":"<approved-plan-id>"}'
const jobRes = await fetch('https://sleeperhit.studio/api/v1/story-jobs', { method: 'POST', headers: { Authorization: `Bearer ${process.env.SLEEPERHIT_API_KEY}`, 'Idempotency-Key': randomUUID(), 'Content-Type': 'application/json', }, body: JSON.stringify({ storyPlanId: planId }), }) if (jobRes.status === 402) { const { error } = await jobRes.json() // error.code === 'insufficient_credits' // error details carry required vs available throw new Error(`insufficient credits: need ${error.details?.required}, have ${error.details?.available}`) } if (!jobRes.ok) throw new Error(`job create ${jobRes.status}`) const { job } = await jobRes.json() const jobId = job.id
The job starts at RESERVED (credits held) and immediately moves toward RUNNING.
Step 11 — Poll the job until terminal
curl -s https://sleeperhit.studio/api/v1/story-jobs/$JOB_ID \ -H "Authorization: Bearer $SLEEPERHIT_API_KEY" | jq '{status:.job.status,artifacts:.job.artifacts[].status}'
async function pollJob(jobId) { const terminal = new Set(['READY', 'PARTIAL', 'FAILED', 'CANCELED']) while (true) { const res = await fetch(`https://sleeperhit.studio/api/v1/story-jobs/${jobId}`, { headers: { Authorization: `Bearer ${process.env.SLEEPERHIT_API_KEY}` }, }) const { job } = await res.json() if (terminal.has(job.status)) return job await new Promise((r) => setTimeout(r, 5000)) } } const completedJob = await pollJob(jobId) if (completedJob.status === 'FAILED') { throw new Error(`job failed: ${completedJob.failureCode}`) }
Poll every 5–15 seconds. PARTIAL means at least one artifact is ready while others are still running — you can read ready artifacts immediately. READY means all artifacts are done. A job may move from RUNNING back to RESERVED with progress.stage: "provider_backoff"; this is nonterminal core-owned recovery with the same job id, credits, and checkpoint, so keep polling without adding a client-side retry circuit.
Step 12 — Read the artifact manifest
When the job is READY (or PARTIAL), each artifact in job.artifacts[] with status: "READY" carries a manifest. For a table_read artifact:
{ "artifact": { "id": "cma...", "type": "table_read", "status": "READY", "manifest": { "type": "table_read", "status": "ready", "shareToken": "<expiring JWT>", "theaterUrl": "https://sleeperhit.studio/share/table-read/<token>?view=framed", "theaterFullscreenUrl": "https://sleeperhit.studio/share/table-read/<token>?view=fullscreen", "audio": { "mode": "durable_recording", "liveUrl": "https://sleeperhit.studio/share/table-read-audio/<token>", "recordingUrl": null, "recordingDurationMs": null, "adaptiveMusic": false, "finalize": { "status": "not_finalized", "finalizeUrl": "/api/v1/artifacts/cma.../finalize", "error": null } }, "video": { "status": "not_rendered", "renderUrl": "/api/v1/artifacts/cma.../render-video", "url": null }, "expiresAt": "2026-06-29T10:00:00.000Z" } } }
The three ready-to-open player URLs:
| URL | Experience |
|---|---|
manifest.theaterUrl | Framed visual theater with attribution chrome |
manifest.theaterFullscreenUrl | Immersive, chrome-free fullscreen — best for embeds |
manifest.audio.liveUrl | Audio-only player, no visual stage |
A read is a durable recording ("mode": "durable_recording"), not a live session: the audio player plays the MP3 rendered for it, and nothing starts on play. The shareToken (not your API key) authorizes all three URLs — you can safely give them to end-users.
const tableReadArtifact = completedJob.artifacts.find((a) => a.type === 'table_read') const { manifest, id: artifactId } = tableReadArtifact // Hand these to your end user: console.log('Theater:', manifest.theaterUrl) console.log('Fullscreen embed:', manifest.theaterFullscreenUrl) console.log('Audio only:', manifest.audio.liveUrl) // A durable MP3 to keep or deliver: finalize (free), then poll manifest.audio.finalize.status. await fetch(`https://sleeperhit.studio/api/v1/artifacts/${artifactId}/finalize`, { method: 'POST', headers: { Authorization: `Bearer ${process.env.SLEEPERHIT_API_KEY}`, 'Content-Type': 'application/json', 'Idempotency-Key': crypto.randomUUID(), }, body: JSON.stringify({ mode: 'audio' }), }) // On complete, manifest.audio.recordingUrl carries the MP3. See "Optional: finalize an MP3 or MP4" below.
Optional: refine the artifact
If the initial result needs adjustments — different cast, different tone, renamed characters — POST a free-form instruction to refine it in place. The artifact id and share URLs are unchanged.
curl -s -X POST https://sleeperhit.studio/api/v1/artifacts/$ARTIFACT_ID/refine \ -H "Authorization: Bearer $SLEEPERHIT_API_KEY" \ -H "Idempotency-Key: $(uuidgen)" \ -H "Content-Type: application/json" \ -d '{"instruction":"make the narrator warmer and drop the skeptic character","scope":"screenplay"}'
Returns { storyJob }. Poll GET /story-jobs/{jobId} until READY. Scope required: artifact:publish. Cost: 8 credits (TABLE_READ_REFINE).
Optional: recast a voice, add music, or add SFX
These post-creation controls mutate the same live table read. They do not create a new revision and do not change the artifact id or share URLs.
Recast one character voice:
curl -s -X POST https://sleeperhit.studio/api/v1/artifacts/$ARTIFACT_ID/voice \ -H "Authorization: Bearer $SLEEPERHIT_API_KEY" \ -H "Idempotency-Key: $(uuidgen)" \ -H "Content-Type: application/json" \ -d '{"character":"NARRATOR","voiceId":"voice_123","voiceName":"Warm Documentary Host"}'
Regenerate adaptive soundtrack:
curl -s -X POST https://sleeperhit.studio/api/v1/artifacts/$ARTIFACT_ID/music \ -H "Authorization: Bearer $SLEEPERHIT_API_KEY" \ -H "Content-Type: application/json" \ -d '{"musicCoveragePercent":0.35}'
Poll GET /artifacts/{artifactId}/music until music.status is ready.
Tune one scene without changing the artifact/share URLs:
curl -s -X POST https://sleeperhit.studio/api/v1/artifacts/$ARTIFACT_ID/music \ -H "Authorization: Bearer $SLEEPERHIT_API_KEY" \ -H "Content-Type: application/json" \ -d '{"sceneIndex":2,"prompt":"heavier bass pulse under the reveal","weight":0.85}'
Replace the whole-read scoring palette:
curl -s -X POST https://sleeperhit.studio/api/v1/artifacts/$ARTIFACT_ID/music \ -H "Authorization: Bearer $SLEEPERHIT_API_KEY" \ -H "Content-Type: application/json" \ -d '{"scope":"screenplay","mode":"replace","prompt":"industrial electronic pulse, distorted analog synth","summary":"Reznor/Ross-style electronic palette"}'
Add a timed sound effect cue:
curl -s -X POST https://sleeperhit.studio/api/v1/artifacts/$ARTIFACT_ID/sfx \ -H "Authorization: Bearer $SLEEPERHIT_API_KEY" \ -H "Idempotency-Key: $(uuidgen)" \ -H "Content-Type: application/json" \ -d '{"op":"add","entryIndex":12,"label":"door knock","prompt":"three crisp knocks on a wooden door","triggerOffset":"before","offsetMs":-150,"generatedDurationS":1.2,"enabled":true}'
Scope required for all three mutations: artifact:publish.
Optional: finalize an MP3 or MP4
To get a durable file (for download or delivery), use the finalize endpoint.
Audio MP3 (free):
curl -s -X POST https://sleeperhit.studio/api/v1/artifacts/$ARTIFACT_ID/finalize \ -H "Authorization: Bearer $SLEEPERHIT_API_KEY" \ -H "Idempotency-Key: $(uuidgen)" \ -H "Content-Type: application/json" \ -d '{"mode":"audio"}'
Poll GET /artifacts/{artifactId} for manifest.audio.finalize.status (queued → rendering → uploading → complete). The MP3 is rendered from the read itself — its voices, music and sound effects — so no browser recording is needed first, and it costs no credits. On complete, manifest.audio.recordingUrl (MP3) and manifest.audio.recordingDurationMs are populated.
Video MP4 (12 credits) — also available via the legacy render-video endpoint:
curl -s -X POST https://sleeperhit.studio/api/v1/artifacts/$ARTIFACT_ID/render-video \ -H "Authorization: Bearer $SLEEPERHIT_API_KEY" \ -H "Idempotency-Key: $(uuidgen)"
const renderRes = await fetch( `https://sleeperhit.studio/api/v1/artifacts/${tableReadArtifact.id}/render-video`, { method: 'POST', headers: { Authorization: `Bearer ${process.env.SLEEPERHIT_API_KEY}`, 'Idempotency-Key': randomUUID(), }, } ) const { artifactId, render } = await renderRes.json() // render.status will move from 'rendering' -> 'complete' // poll GET /artifacts/{artifactId} for manifest.video.status and manifest.video.url
Complete Node.js example
The following is a minimal, self-contained Node.js script that runs the full flow. If your account has no saved voice, the script designs one for each Series Bible character (price first, then confirmed: true, then it keeps candidate 1), so it reaches the job on a fresh account; with a saved voice it reuses that one for everyone.
import { randomUUID } from 'node:crypto' import { createInterface } from 'node:readline/promises' import { stdin, stdout } from 'node:process' const BASE = 'https://sleeperhit.studio/api/v1' const KEY = process.env.SLEEPERHIT_API_KEY function headers(extra = {}) { return { Authorization: `Bearer ${KEY}`, ...extra } } async function api(path, options = {}) { const res = await fetch(`${BASE}${path}`, { ...options, headers: { ...headers(), ...options.headers }, }) if (!res.ok) { const err = await res.json().catch(() => ({})) throw new Error(`${options.method ?? 'GET'} ${path} -> ${res.status}: ${err?.error?.code ?? 'unknown'}`) } return res.json() } async function poll(fn, isTerminal, intervalMs = 5000) { while (true) { const result = await fn() if (isTerminal(result)) return result await new Promise((r) => setTimeout(r, intervalMs)) } } // 1. Confirm capabilities const caps = await api('/capabilities') if (caps.artifacts.table_read?.availability !== 'available') throw new Error('table_read not available') // 2. Create project const { project } = await api('/story-projects', { method: 'POST', headers: { 'Idempotency-Key': randomUUID(), 'Content-Type': 'application/json' }, body: JSON.stringify({ name: 'Quickstart demo', description: 'End-to-end test' }), }) // 3. Attach a source const { source } = await api(`/story-projects/${project.id}/sources`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ type: 'text', label: 'sample-content', content: 'Acme Corp is launching its next-generation platform in Q3 2026. The platform enables real-time collaboration across global teams and reduces onboarding time by 60 percent.', }), }) // 4. Write the Series Bible from the sources. The plan is refused with 409 until its gate opens: // for a series that means premise coverage, an episode map, and full coverage (7.0+ each). await api(`/story-projects/${project.id}/series-bible`, { method: 'POST', headers: { 'Idempotency-Key': randomUUID(), 'Content-Type': 'application/json' }, body: JSON.stringify({ instructions: 'A documentary table read: one host, one expert.' }), }) await poll( () => api(`/story-projects/${project.id}/series-bible`).then((r) => r.generation), (g) => g?.status !== 'generating', 5000 ).then((g) => { if (g?.status === 'failed') throw new Error(`series bible: ${g.error}`) }) async function passCoverage(phase) { await api(`/story-projects/${project.id}/series-bible/coverage`, { method: 'POST', headers: { 'Idempotency-Key': randomUUID(), 'Content-Type': 'application/json' }, body: JSON.stringify({ phase }), }) const { coverage } = await poll( () => api(`/story-projects/${project.id}/series-bible/coverage?phase=${phase}`), (r) => ['complete', 'failed'].includes(r.coverage.status), 5000 ) if (coverage.status === 'failed') throw new Error(`coverage: ${coverage.failureMessage}`) if (!coverage.gatePassed) { throw new Error(`${phase} coverage scored ${coverage.overallScore} under ${coverage.gateThreshold}: fix the Bible, or acknowledge the report as the writer`) } } while (true) { const { project: gateProject } = await api(`/story-projects/${project.id}`) const { workspaceGate } = gateProject if (workspaceGate.canPlan) break if (workspaceGate.stage === 'premise') throw new Error(`Series Bible incomplete: ${workspaceGate.missingFields.join(', ')}`) // Premise coverage passed and the Bible has no episode map: save one, then plan. if (workspaceGate.stage === 'planning') throw new Error('The Series Bible has no episode map yet') await passCoverage(workspaceGate.stage === 'full_coverage' ? 'full' : 'premise') } // 5. Generate a table-read plan; this always retains the human hold. const { plan: pendingPlan } = await api(`/story-projects/${project.id}/story-plans`, { method: 'POST', headers: { 'Idempotency-Key': randomUUID(), 'Content-Type': 'application/json' }, body: JSON.stringify({ target: { audience: 'enterprise buyers', objective: 'introduce the Q3 platform launch', outcome: 'book a demo', }, artifactRequests: [{ type: 'table_read', mode: 'documentary' }], sourceIds: [source.id], }), }) // 6. Poll to review, show the exact plan, and ask the human before approval. const reviewPlan = await poll( () => api(`/story-plans/${pendingPlan.id}`).then((r) => r.plan), (p) => ['REQUIRES_APPROVAL', 'APPROVED', 'REJECTED', 'FAILED'].includes(p.status), 4000 ) if (reviewPlan.status !== 'REQUIRES_APPROVAL') throw new Error(`plan ${reviewPlan.status}: ${reviewPlan.failureMessage}`) console.dir(reviewPlan.plan, { depth: null }) const prompt = createInterface({ input: stdin, output: stdout }) const answer = await prompt.question('Approve this exact plan? [y/N] ') prompt.close() if (!/^(y|yes)$/i.test(answer.trim())) throw new Error('Plan not approved by user.') const { plan: approvedPlan } = await api(`/story-plans/${pendingPlan.id}/approve`, { method: 'POST', headers: { 'Idempotency-Key': randomUUID(), 'Content-Type': 'application/json' }, body: JSON.stringify({ userConfirmed: true }), }) if (approvedPlan.status !== 'APPROVED') throw new Error(`plan ${approvedPlan.status}: ${approvedPlan.failureMessage}`) console.log('Plan approved. Quote:', approvedPlan.quote.total, 'credits') // 7. Cast every Series Bible character: a read performs a cast, never makes one. // A saved voice is free to reuse. A fresh account has none, so design one per character // (the price first, then confirmed: true) and keep candidate 1 — choosing is free, pins the // voice on the cast canon and saves it to your library. const { document: bible } = await api(`/story-projects/${project.id}/series-bible`) const { voices } = await api('/voices') const savedVoice = voices[0] async function designAndKeepVoice(character) { const path = `/story-projects/${project.id}/cast-voice/design` const post = (body) => api(path, { method: 'POST', headers: { 'Idempotency-Key': randomUUID(), 'Content-Type': 'application/json' }, body: JSON.stringify(body), }) // The price first: without confirmed this draws nothing and charges nothing. const quote = await post({ character }) console.log(`${character}'s voice costs ${quote.credits} credits: ${quote.note}`) const design = await post({ character, confirmed: true }) const { voice: kept } = await api(`/story-projects/${project.id}/cast-voice/choose`, { method: 'POST', headers: { 'Idempotency-Key': randomUUID(), 'Content-Type': 'application/json' }, body: JSON.stringify({ designId: design.designId, candidate: 1 }), }) return kept } const pin = (character, voice) => api(`/story-projects/${project.id}/cast-voice`, { method: 'PATCH', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ character, voiceId: voice.voiceId, voiceName: voice.voiceName ?? voice.name, provider: voice.provider }), }) let fallbackVoice = savedVoice for (const character of bible.content.characters) { if (savedVoice) { await pin(character.name, savedVoice) } else { // Choose already pinned this character's designed voice; keep the first for the narrator. fallbackVoice ??= await designAndKeepVoice(character.name) } } // A narrated read also needs a narrator voice: reuse the saved (or first designed) one. await pin('NARRATOR', fallbackVoice) const { project: readinessProject } = await api(`/story-projects/${project.id}`) const { tableReadReadiness } = readinessProject if (!tableReadReadiness.ready) throw new Error(`cast incomplete: ${tableReadReadiness.reason}`) // 8. Create job const { job: pendingJob } = await api('/story-jobs', { method: 'POST', headers: { 'Idempotency-Key': randomUUID(), 'Content-Type': 'application/json' }, body: JSON.stringify({ storyPlanId: approvedPlan.id }), }) // 9. Poll job to READY const completedJob = await poll( () => api(`/story-jobs/${pendingJob.id}`).then((r) => r.job), (j) => ['READY', 'PARTIAL', 'FAILED', 'CANCELED'].includes(j.status), 6000 ) if (completedJob.status === 'FAILED') throw new Error(`job failed: ${completedJob.failureCode}`) // 10. Print the manifest URLs const artifact = completedJob.artifacts.find((a) => a.type === 'table_read' && a.status === 'READY') const m = artifact.manifest console.log('Theater URL:', m.theaterUrl) console.log('Fullscreen URL:', m.theaterFullscreenUrl) console.log('Audio-only URL:', m.audio.liveUrl)
Error handling essentials
Match on error.code, not the human message. The message may change; the code is stable.
| Code | What to do |
|---|---|
authentication_required / invalid_api_key | Rotate the key in your secret manager |
api_key_revoked / api_key_expired | Issue a new key |
insufficient_scope | Ask the account owner to add the required scope |
rate_limited | Wait for Retry-After seconds, then retry |
idempotency_conflict | Another request with this key is still processing — poll the status endpoint instead |
insufficient_credits | Add credits before retrying — details.required / details.available tell you the gap |
source_incomplete | Regenerate the full-context source pack; the receiver rejected an incomplete article or comment thread |
source_fetch_failed | The URL or PDF could not be fetched — check failureMessage for the reason (IP blocked, redirect limit, wrong content type, etc.) |
internal_error | Safe to retry with the same Idempotency-Key |
See api-reference.md#error-codes for the full table.