# Quickstart: Source to Playable Table Read

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`, and `artifact: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 needs `publishing:read`, `publishing:write`, `publishing:publish`, `publishing:connect`, and `publishing:analytics`, a stored app login needs `app:connect`, and the account itself needs `account:read` and `account:write`. `GET /api/v1/capabilities` prints all fifteen under `scopes` (it needs no scope to read). `source:read` is 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 returns `insufficient_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.

```bash
curl -s https://sleeperhit.studio/api/v1/credits \
  -H "Authorization: Bearer $SLEEPERHIT_API_KEY" | jq .credits.balance
```

```js
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:
```json
{
  "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.

```bash
curl -s https://sleeperhit.studio/api/v1/capabilities | jq .
```

```js
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.

```bash
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"}'
```

```js
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:
```json
{
  "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.

```bash
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>"}'
```

```js
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`.

```bash
# 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
```

```js
// 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.

```bash
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:

```js
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.

```bash
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."}'
```

```js
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.

```js
// 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.

```bash
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>"]
  }'
```

```js
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.

```bash
curl -s https://sleeperhit.studio/api/v1/story-plans/$PLAN_ID \
  -H "Authorization: Bearer $SLEEPERHIT_API_KEY" | jq .plan.status
```

```js
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:

```json
{
  "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`.

```bash
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.

```bash
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>"}'
```

```js
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

```bash
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}'
```

```js
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:

```json
{
  "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.

```js
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.

```bash
# 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:**

```bash
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:**

```bash
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:

```bash
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:

```bash
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:**

```bash
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):**

```bash
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:**

```bash
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.

```js
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.

```js
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](/api-reference#error-codes) for the full table.
