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.
curland a terminal. The last section also shows the same flow in JavaScript and Python.
Step 1: Create an API key#
- Open your LyricWinter dashboard and expand Developers.
- Create a key with the
studio:read,studio:write, andaccount:readscopes. - Copy the key. It starts with
lw_and is shown only once.
Export it so the examples below can use it:
export LYRICWINTER_API_KEY="lw_..."Check that the key works:
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_ididentifies your client session. Generate one per process and reuse it.client_mutation_ididentifies this one change. Reuse it only to retry the exact same request.
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:
{
"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.
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:
| Status | Meaning |
|---|---|
queued, running, canceling | Still working. Keep polling. |
completed | Every step succeeded. |
partially_completed | Some steps failed. data.run.resumable tells you whether you can resume. |
failed | No step succeeded. Check data.run.error_summary. |
cancelled | The run was cancelled. |
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:
curl "https://lyricwinter.com/api/v1/studio/sections/$SECTION_ID/media" \
-H "Authorization: Bearer $LYRICWINTER_API_KEY"{
"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:
GET /studio/sections/{sectionId}/documentreturnsdata.document.document_versionanddata.document.speaker_registry_version.GET /studio/projects/{projectId}/mastering-profilereturnsdata.mastering_version.
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.
Step 7: Share the story (optional)#
Send {} to POST /studio/sections/{sectionId}/share and use data.share.url. When Stories publishing is available to your account, this publishes the chapter at the story's /stories/<id>-<slug> link and makes a Private story Unlisted. A story already set to Unlisted or Public keeps that visibility. POST /studio/projects/{projectId}/share publishes the project's current chapters with saved story text at the same story link and skips blank drafts. If none have words, it returns not shared; sharing a single blank section still returns an error.
New chapters start as drafts, even after a project is shared. Share the new chapter, share the project again, or publish it in Studio when it is ready. DELETE on the section or project share unpublishes those chapters while keeping the story link and visibility. Before Stories publishing opens for your account, these endpoints keep creating and revoking the older listening links. See Sharing.
Put it together#
The same flow as one script:
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));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#
- Learn how projects, sections, documents, and versions fit together in Studio concepts.
- Handle conflicts, retries, and errors with Errors and retries.
- Browse every endpoint in the API reference.
- Let an AI agent do all of this for you with the MCP server.