This walkthrough goes from a fresh API key to a live, shareable table read URL. It covers every step in the happy path: check credit (which proves the key), discover capabilities,s, 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. - A key created on the Developers page has full access by default — every scope — so it works end-to-end on the first call. To narrow it, choose Advanced: customize scopes while creating it. This walkthrough needs seven of them:
story:read,story:write,source:read,source:write,credits:read,artifact:read, andartifact:publish(optional refine, voice recast, finalize, render-video, music, and SFX mutations). The API has fifteen, and the other eight are for work this walkthrough never does — narrowing a key to the seven is a fine choice for the script below, but a key without them cannot do that work and its scopes cannot be widened afterwards: publishing a show needspublishing:read,publishing:write,publishing:publish,publishing:connect, andpublishing:analytics, a stored app login needsapp:connect, and the account itself needsaccount:readandaccount:write.GET /api/v1/capabilitiesprints all fifteen underscopes(it needs no scope to read).source:readis required by the flow itself, not by an option: the URL and PDF sources below are fetched in a background worker, so the poll that tells you whether one is ready (GET …/sources/{sourceId}) reads it. A key's scopes cannot be edited after creation: if one is missing the API returnsinsufficient_scope, and the fix is to make a new key with the scopes it needs and revoke the old one. - Enough Studio Credits to cover the quoted artifact work. Planning and source digests are included; credits are reserved when you create or refine an artifact, while finalizing its MP3 or MP4 is free.
Step 1 — Check your credit balance (this proves your key)
This is the first call that checks the key, on purpose. /credits is keyed, so a mistyped, revoked or expired key answers
401 invalid_api_key here — at the start, where it costs one line to fix — instead of three steps later.
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.balance = carriedBalance + monthlyBucketRemaining + perpetualBalance — one sum, three terms.
// It is never the last two alone: that number is short by exactly the credits that carried over.
Response shape:
{
"credits": {
"balance": 350,
"monthlyBucketAllocated": 300,
"monthlyBucketUsed": 50,
"monthlyBucketRemaining": 250,
"carriedBalance": 50,
"carried": [{ "credits": 50, "expiresAt": "2026-06-01T00:00:00.000Z" }],
"perpetualBalance": 50,
"cycleStartedAt": "2026-05-01T00:00:00.000Z",
"cycleExpiresAt": "2026-06-01T00:00:00.000Z",
"carryCap": 600,
"carriesAtRenewal": 250,
"breakdown": "250 left this month · 50 carried over · up to 250 carry into next month · resets 2026-05-31 · 50 top-up, never expires"
}
}
balance is those three terms added — carriedBalance (plan credits that rolled over from an earlier month, spent first and
soonest to expire first), this month's monthlyBucketRemaining, and perpetualBalance (top-ups, which never expire).
carried lists each rolled-over batch and when it expires, breakdown splits the one number in the words the Billing page
shows, and carryCap / carriesAtRenewal say how much of this month's credits can carry into the next one. Budget against
balance: a run is refused only when it costs more than that.
Step 2 — Confirm capabilities (public, no key)
Before every run, fetch the live capability manifest to confirm the features you need are available.
This route is public: it needs no key, and it answers 200 even to a bogus one — so it proves nothing about your
credentials, which is exactly why Step 1 is the keyed call. Send it with no Authorization header.
curl -s https://sleeperhit.studio/api/v1/capabilities | jq .
const res = await fetch('https://sleeperhit.studio/api/v1/capabilities')
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 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","kind":"story"}'
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',
kind: 'story',
}),
})
if (!projectRes.ok) throw new Error(`project create ${projectRes.status}`)
const { project } = await projectRes.json()
const projectId = project.id
Send kind too. A project is a story or a product, and its kind decides which coverage checkpoints it passes in Step 6: a story project buys premise and then full, a product project buys one product report. A create with no kind takes your account's default kind — product for a Business account — and this walkthrough plans, casts and performs a story, so it names story. The answer says which kind the project got (project.kind), and the gate and coverage reads below follow it.
Response:
{
"project": {
"id": "cmp...",
"name": "Q3 Launch Story",
"description": "Product launch table read",
"kind": "story",
"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 "Idempotency-Key: $(uuidgen)" \
-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}`,
'Idempotency-Key': randomUUID(),
'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 "Idempotency-Key: $(uuidgen)" \
-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}`,
'Idempotency-Key': randomUUID(),
'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 "Idempotency-Key: $(uuidgen)" \
-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 the coverage project.kind takes (below). |
premise_coverage | Story projects only. POST /story-projects/{projectId}/series-bible/coverage with { "phase": "premise" }, then poll GET …/coverage?phase=premise. |
planning | Story projects only: premise coverage passed and the Bible has no episode map. Save one — the plan expands it. |
full_coverage | POST …/coverage with { "phase": "full" } for a story project (only after the map), or { "phase": "product" } for a product project, then poll GET …/coverage with that same phase. |
ready | canPlan is true; go on to Step 7. |
Which coverage a project buys is its kind, never its name or what it is made as: a story project buys premise then full, and a product project buys one product report — its gate is premise → full_coverage → ready, with no episode-map stage. Sending the other kind's phase is refused 400 validation_failed, and the message names the phase to send: phase: "product" for a product project, "premise" or "full" for a story one. A POST with no phase at all takes the project's own checkpoint (product for a product project, premise for a story project), so the helper below derives it from project.kind instead of spelling one in.
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 that same 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.
// The checkpoint the gate is waiting on, in the project's OWN kind's vocabulary: a product project buys
// one `product` report, a story project buys `premise` then `full`. Naming a story phase on a product
// project (or the reverse) is a 400 validation_failed, so the kind decides, never the stage alone.
function coveragePhase(stage, kind) {
if (kind === 'product') return 'product'
return stage === 'full_coverage' ? 'full' : 'premise'
}
async function passCoverage(projectId, kind, stage) {
const phase = coveragePhase(stage, kind)
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, project.kind, workspaceGate.stage)
}
}
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') {
// Branch on `failureClass` and `retryable`, never on `failureMessage`: that prose is the upstream vendor's
// and is reworded without notice. `failureRecovery` is present when the plan failed BEFORE producing
// anything, and names the free way on — resume THIS plan under the same id and brief.
// Creating a second plan for the same brief pays for planning it again.
if (!approvedPlan.failureRecovery) {
throw new Error(`plan failed (${approvedPlan.failureClass ?? approvedPlan.failureCode}; retryable=${approvedPlan.retryable})`)
}
console.log(approvedPlan.failureRecovery)
const resumed = await fetch(`https://sleeperhit.studio/api/v1/story-plans/${planId}/resume`, {
method: 'POST',
headers: {
Authorization: `Bearer ${process.env.SLEEPERHIT_API_KEY}`,
'Idempotency-Key': crypto.randomUUID(),
},
}).then((res) => res.json())
// Same plan id, now PENDING again: poll it and continue from here. Resume costs no credits.
console.log(`resumed ${resumed.plan.id} at ${resumed.plan.status}`)
} else 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 an id, a name and a provider), pin one per Series Bible character with PATCH /story-projects/{projectId}/cast-voice, passing that voice's id as voiceId (and its name as voiceName); this is free and every later episode inherits it. One call per character — there is no characters array on this call, and the cast canon document (PATCH .../cast-canon) takes its body under content.
curl -s -X PATCH https://sleeperhit.studio/api/v1/story-projects/$PROJECT_ID/cast-voice \
-H "Authorization: Bearer $SLEEPERHIT_API_KEY" \
-H "Content-Type: application/json" \
-d '{"character":"Mara","voiceId":"<voice-id>","voiceName":"Warm Documentary Host","provider":"cartesia"}'
The answer names the voice now pinned, the previous one it replaced, and any reads still speaking that person with another voice. Repeat it for every character the plan will voice; a narrated read needs one for "NARRATOR" too.
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.
A price and a spend are two calls. The first prices the revision and reserves nothing; the same body with confirmed: true creates the job.
# 1. Price it — this reserves and enqueues NOTHING. Show `note` to the writer.
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"}'
# -> { "quotedOnly": true, "credits": 8, "costLabel": "Table read refinement",
# "balanceAfter": 42, "sufficient": true, "note": "...", "lines": [{ "label": "Table read refinement", "credits": 8 }] }
# 2. Say go — the same body plus confirmed: true
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","confirmed":true}'
# -> { "storyJob": { "id": "cmj...", "status": "RESERVED", ... } }
The quote answers quotedOnly: true, credits, costLabel, balanceAfter, sufficient, note and the priced lines; reading storyJob off it is a bug. The confirmed call answers { storyJob } — poll GET /story-jobs/{jobId} until READY. Scope required: artifact:publish. Cost: 8 credits (TABLE_READ_REFINE), reserved only on the confirmed call.
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 "Idempotency-Key: $(uuidgen)" \
-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 "Idempotency-Key: $(uuidgen)" \
-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 "Idempotency-Key: $(uuidgen)" \
-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 (free) — 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)"
The MP4 is free on every surface, like the MP3, and renders only when you ask for it — the default artifact flow never produces one.
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. Prove the key and read the balance. /credits is keyed, so a bad key answers 401 invalid_api_key
// here, at the top, instead of failing the first write three steps down.
const { credits } = await api('/credits')
if (credits.balance <= 0) throw new Error(`no Studio Credits left: ${credits.breakdown}`)
// 2. Confirm capabilities. This route is public: no key needed, and it answers 200 even to a bogus
// one, so it is not the call that proves anything about credentials.
const caps = await api('/capabilities')
if (caps.artifacts.table_read?.availability !== 'available') throw new Error('table_read not available')
// 3. Create project. `kind` decides the coverage checkpoints it passes (a story buys premise then
// full, a product buys one `product` report): with no kind named, a Business account's default is
// `product` and its first coverage call below would be refused.
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', kind: 'story' }),
})
// 4. 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.',
}),
})
// 5. 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}`)
})
function coveragePhase(stage, kind) {
// The project's own kind names the checkpoint: `product` for a product project, premise/full for a story.
if (kind === 'product') return 'product'
return stage === 'full_coverage' ? 'full' : 'premise'
}
async function passCoverage(kind, stage) {
const phase = coveragePhase(stage, kind)
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(project.kind, workspaceGate.stage)
}
// 6. 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],
}),
})
// 7. 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')
// 8. 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}`)
// 9. 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 }),
})
// 10. 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}`)
// 11. 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 | Make a new key with the missing scopes on the Developers page (full access by default), then revoke the old one |
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.