Skip to content

Get started

Quickstart: create Studio audio with the LyricWinter API

Create an API key, add a story, generate multi-voice audio, and fetch playback with the LyricWinter REST API in about ten minutes.

BetaUpdated

This quickstart turns a short story into multi-voice audio with the LyricWinter REST API. You will create an API key, add a story, start one Create Audio workflow, wait for it to finish, and fetch playable audio. It takes about ten minutes, most of it waiting for audio.

Before you begin#

  • A LyricWinter account with words in its balance. Creating audio draws from the same balance as the Studio app. See pricing.
  • curl and a terminal. The last section also shows the same flow in JavaScript and Python.

Step 1: Create an API key#

  1. Open your LyricWinter dashboard and expand Developers.
  2. Create a key with the studio:read, studio:write, and account:read scopes.
  3. Copy the key. It starts with lw_ and is shown only once.

Export it so the examples below can use it:

Terminal
export LYRICWINTER_API_KEY="lw_..."

Check that the key works:

Terminal
curl https://lyricwinter.com/api/v1/me \
  -H "Authorization: Bearer $LYRICWINTER_API_KEY"

Every successful response is wrapped in { "data": ..., "request_id": "..." }. See Authentication for scopes and key management.

Step 2: Create a story#

POST /studio/sections creates a standalone Studio story with one section of text. Studio mutations carry two UUIDs:

  • actor_session_id identifies your client session. Generate one per process and reuse it.
  • client_mutation_id identifies this one change. Reuse it only to retry the exact same request.
Terminal
export ACTOR_SESSION_ID=$(uuidgen | tr 'A-Z' 'a-z')

curl -X POST https://lyricwinter.com/api/v1/studio/sections \
  -H "Authorization: Bearer $LYRICWINTER_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "schema_version": 1,
    "actor_session_id": "'"$ACTOR_SESSION_ID"'",
    "client_mutation_id": "'"$(uuidgen | tr 'A-Z' 'a-z')"'",
    "title": "The Lighthouse",
    "raw_text": "The storm had not let up for three days. \"Someone has to light the lamp,\" Mira said. Her brother shook his head. \"Not in this wind.\""
  }'

The response is 201 Created. It also includes the section's full document. Save data.project_id and data.section.id:

JSON
{
  "data": {
    "project_id": "8f14e45f-ceea-4e7a-9c2d-3b1a5f2e7c10",
    "section": {
      "id": "c9f0f895-fb98-4b91-9e3f-1d2a6b7c8d20",
      "project_id": "8f14e45f-ceea-4e7a-9c2d-3b1a5f2e7c10",
      "title": "The Lighthouse",
      "document_version": 1
    },
    "section_order_version": 1,
    "speaker_registry_version": 1,
    "idempotent_replay": false
  },
  "request_id": "req_01J9Z3K8QF4"
}

Step 3: Start Create Audio#

A create_audio workflow does everything in one run: it structures the text into narration and dialogue, attributes lines to speakers, casts a voice for each character, directs emotion and delivery, adds sound effects, and generates the audio.

Terminal
export PROJECT_ID="<data.project_id>"
export SECTION_ID="<data.section.id>"

curl -X POST "https://lyricwinter.com/api/v1/studio/projects/$PROJECT_ID/workflows" \
  -H "Authorization: Bearer $LYRICWINTER_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "schema_version": 2,
    "workflow_kind": "create_audio",
    "actor_session_id": "'"$ACTOR_SESSION_ID"'",
    "client_mutation_id": "'"$(uuidgen | tr 'A-Z' 'a-z')"'",
    "section_ids": ["'"$SECTION_ID"'"],
    "include_generated_sfx": true,
    "place_project_sounds": true
  }'

The response is 202 Accepted with the workflow run in data.run. Save data.run.id.

Step 4: Wait for the workflow to finish#

Poll GET /studio/workflows/{workflowRunId} every few seconds until data.run.status is terminal:

StatusMeaning
queued, running, cancelingStill working. Keep polling.
completedEvery step succeeded.
partially_completedSome steps failed. data.run.resumable tells you whether you can resume.
failedNo step succeeded. Check data.run.error_summary.
cancelledThe run was cancelled.
Terminal
export WORKFLOW_ID="<data.run.id>"

curl "https://lyricwinter.com/api/v1/studio/workflows/$WORKFLOW_ID" \
  -H "Authorization: Bearer $LYRICWINTER_API_KEY"

data.run.current_stage shows what is happening now, such as casting_voices or generating_audio, and data.run.completed_step_count out of data.run.step_count gives a progress fraction.

Step 5: Play the audio#

GET /studio/sections/{sectionId}/media returns one entry per document block, in order. Each block with audio has a short-lived signed playback_url:

Terminal
curl "https://lyricwinter.com/api/v1/studio/sections/$SECTION_ID/media" \
  -H "Authorization: Bearer $LYRICWINTER_API_KEY"
JSON
{
  "data": {
    "section_id": "c9f0f895-fb98-4b91-9e3f-1d2a6b7c8d20",
    "playback_duration_ms": 14820,
    "blocks": [
      {
        "block_id": "45c48cce-2e2d-4fbd-8a1c-5e6f7a8b9c30",
        "freshness": "current",
        "activity": "idle",
        "playback_url": "https://storage.googleapis.com/…",
        "playback_url_expires_at": "2026-09-30T17:15:00.000Z",
        "content_type": "audio/mpeg"
      }
    ]
  },
  "request_id": "req_01J9Z3K8QF4"
}

Play the blocks in order for the full section, or compile gapless playback with GET /studio/sections/{sectionId}/playback-manifest.

Step 6: Export a file (optional)#

Exports render the section into one file: mp3, wav, m4b, epub (synchronized), or srt. An export is pinned to the versions you pass, so read them first:

  1. GET /studio/sections/{sectionId}/document returns data.document.document_version and data.document.speaker_registry_version.
  2. GET /studio/projects/{projectId}/mastering-profile returns data.mastering_version.
Terminal
curl -X POST https://lyricwinter.com/api/v1/studio/exports \
  -H "Authorization: Bearer $LYRICWINTER_API_KEY" \
  -H "Idempotency-Key: $(uuidgen)" \
  -H "Content-Type: application/json" \
  -d '{
    "schema_version": 1,
    "section_id": "'"$SECTION_ID"'",
    "document_version": 7,
    "speaker_registry_version": 3,
    "mastering_version": 1,
    "format": "mp3"
  }'

Poll GET /studio/exports/{exportId} until data.status is completed, then download data.artifact.download_url.

Put it together#

The same flow as one script:

create-audio.mjs
const API = "https://lyricwinter.com/api/v1";
const headers = {
  Authorization: `Bearer ${process.env.LYRICWINTER_API_KEY}`,
  "Content-Type": "application/json",
};
const actorSessionId = crypto.randomUUID();

async function call(method, path, body) {
  const response = await fetch(`${API}${path}`, { method, headers, body: body && JSON.stringify(body) });
  const payload = await response.json();
  if (!response.ok) throw new Error(`${payload.error.code}: ${payload.error.message} (${payload.request_id})`);
  return payload.data;
}

const story = await call("POST", "/studio/sections", {
  schema_version: 1,
  actor_session_id: actorSessionId,
  client_mutation_id: crypto.randomUUID(),
  title: "The Lighthouse",
  raw_text: 'The storm had not let up for three days. "Someone has to light the lamp," Mira said.',
});

const { run } = await call("POST", `/studio/projects/${story.project_id}/workflows`, {
  schema_version: 2,
  workflow_kind: "create_audio",
  actor_session_id: actorSessionId,
  client_mutation_id: crypto.randomUUID(),
  section_ids: [story.section.id],
});

const terminal = new Set(["completed", "partially_completed", "failed", "cancelled"]);
let status = run.status;
while (!terminal.has(status)) {
  await new Promise((resolve) => setTimeout(resolve, 3000));
  const workflow = await call("GET", `/studio/workflows/${run.id}`);
  status = workflow.run.status;
  console.log(status, workflow.run.current_stage ?? "");
}

const media = await call("GET", `/studio/sections/${story.section.id}/media`);
console.log(media.blocks.map((block) => block.playback_url).filter(Boolean));
create_audio.py
import os, time, uuid
import requests

API = "https://lyricwinter.com/api/v1"
HEADERS = {"Authorization": f"Bearer {os.environ['LYRICWINTER_API_KEY']}"}
actor_session_id = str(uuid.uuid4())

def call(method, path, body=None):
    response = requests.request(method, f"{API}{path}", headers=HEADERS, json=body, timeout=60)
    payload = response.json()
    if not response.ok:
        raise RuntimeError(f"{payload['error']['code']}: {payload['error']['message']} ({payload.get('request_id')})")
    return payload["data"]

story = call("POST", "/studio/sections", {
    "schema_version": 1,
    "actor_session_id": actor_session_id,
    "client_mutation_id": str(uuid.uuid4()),
    "title": "The Lighthouse",
    "raw_text": 'The storm had not let up for three days. "Someone has to light the lamp," Mira said.',
})

run = call("POST", f"/studio/projects/{story['project_id']}/workflows", {
    "schema_version": 2,
    "workflow_kind": "create_audio",
    "actor_session_id": actor_session_id,
    "client_mutation_id": str(uuid.uuid4()),
    "section_ids": [story["section"]["id"]],
})["run"]

status = run["status"]
while status not in {"completed", "partially_completed", "failed", "cancelled"}:
    time.sleep(3)
    status = call("GET", f"/studio/workflows/{run['id']}")["run"]["status"]
    print(status)

media = call("GET", f"/studio/sections/{story['section']['id']}/media")
print([block["playback_url"] for block in media["blocks"] if block["playback_url"]])

Next steps#