{"openapi":"3.1.0","info":{"title":"Jingle Buddy API","version":"1.0.0","description":"Generate music over HTTP: songs with sung vocals, instrumentals, advertiser jingles cut to broadcast lengths, and radio station imaging.\n\nComposing is asynchronous. Every render endpoint answers 202 with a song id and a poll URL, because the work runs on a GPU that scales to zero and the first render after an idle period pays a 30 to 90 second cold start. Poll every five seconds, or take a webhook.\n\nThe allowance is metered in SECONDS OF RENDERED AUDIO, not in songs. This document is generated from the same catalogue the reference pages read, so it cannot describe an endpoint differently from the page next to it."},"servers":[{"url":"https://0.0.0.0:3000/api/v1","description":"Jingle Buddy API v1"}],"security":[{"bearer":[]},{"apiKey":[]}],"tags":[{"name":"Songs"},{"name":"Lyrics"},{"name":"Jingles"},{"name":"Library"},{"name":"Account"}],"paths":{"/songs":{"post":{"operationId":"createSong","tags":["Songs"],"summary":"Compose a song","description":"Text to music, with sung vocals or instrumental.\n\nComposes a track from a prompt, from style tags, or from words you supply.\n\nTHREE MODES, and which one you get is decided by what you send rather than by a mode flag:\n\n- **Described.** Send `prompt` alone and the words are written for you before the render (this needs a language model configured; without one, send `lyrics` or `instrumental`).\n- **Custom.** Send `lyrics` (or `lyric_id`) and those exact words are sung. Section markers the engine understands (`[verse]`, `[chorus]`, `[bridge]`, `[tag]`) survive into the arrangement.\n- **Instrumental.** Send `instrumental: true` and no words are sung at all. This is a flag on the render rather than a hint in the caption, so it is reliable in a way \"no vocals\" in a prompt is not.\n\nFor an advertiser jingle or station imaging, use `POST /jingles/client` or `POST /jingles/radio` instead: they take the brief's facts as fields and hold the lyric to them, which is the part a free-text prompt keeps getting wrong.\n\nThis answers **202 Accepted** with a song id and a poll URL, not with audio. Composing runs on a GPU we do not hold open, so the honest answer to \"is it done\" is a second request.\n\nPoll `GET /songs/{song_id}` until `status` leaves `queued`/`rendering`. **Poll every 5 seconds, not faster.** The engine runs scale-to-zero by an explicit owner decision, so the first render after roughly fifteen idle minutes pays a 30 to 90 second cold start BEFORE the render begins: a one-second loop is a hundred pointless requests, and the rate limiter will start refusing them before the answer is even possible.\n\n`queued` and `rendering` are separate states for that reason. `queued` means nothing has touched it yet, which during a cold start is the normal and expected condition, not a stall.\n\nA webhook is the alternative to polling entirely: see the events on this endpoint.\n\nThis call spends the workspace's allowance, so it is worth retrying safely. Send an `Idempotency-Key` header (any value you choose, a UUID per logical request being the usual one) and a retry carrying the same key and the same payload answers with the FIRST call's result instead of composing and billing again. The replay is marked with `idempotent-replay: true`.\n\nA duplicate that arrives while the first is still running answers `409 idempotency_conflict` with `Retry-After`: wait and send the identical request again. Keys are forgotten after 24 hours.","x-required-scope":"music","x-webhook-events":["song.ready","song.failed"],"parameters":[{"name":"Idempotency-Key","in":"header","required":false,"description":"A value you choose that makes this request safe to retry. The first call with a given key does the work; a retry carrying the same key AND the same payload answers with that first result instead of composing and billing again, and carries `idempotent-replay: true`.\n\nWhat the key is scoped to, exactly: the method, the path (so the same key against a different song or a different endpoint is a different request), the API key that sent it (so two services sharing a workspace cannot collide on each other's job ids), and a fingerprint of the payload. **Changing the payload under the same key does not replay**: it is a different request, so it renders and it bills. You are never handed the answer to a question you did not ask, but the key is not a way to avoid paying for a second, different one.\n\n`format` is deliberately outside the fingerprint, so a retry that only changes the container replays the stored track rather than paying for a re-render.\n\nThis matters more here than it does for a speech API. A compose is seconds against a warm GPU and a minute and a half against a cold one, which is comfortably past the point where clients time out, proxies retry and people press the button again.\n\nRetained for 24 hours; after that the key is forgotten and an identical request renders and bills as new. A key whose request FAILED is released at once, because a failed render costs nothing and has to stay retryable.\n\n1 to 255 printable ASCII characters; a UUID per logical request is the usual choice. Omit the header entirely and nothing changes.","schema":{"type":"string"},"example":"6f9619ff-8b86-d011-b42d-00c04fc964ff"}],"requestBody":{"required":false,"content":{"application/json":{"schema":{"type":"object","properties":{"prompt":{"type":"string","maxLength":2000,"description":"What you want, in your own words. Kept verbatim on the song, separately from the `caption` that was actually sent to the engine: the first is what to show someone when they reopen the track, the second is the reproducibility record."},"title":{"type":"string","maxLength":120,"description":"What to call it in the library. Derived from the prompt when omitted."},"kind":{"type":"string","enum":["song","bed"],"description":"`song` is a track somebody wanted for its own sake. `bed` is an instrumental a voice read goes over, which is the same render with a different place in the library and a different default length. The two jingle kinds are not accepted here: they are what `/jingles/client` and `/jingles/radio` produce, because they need a brief to be correct.","default":"song"},"style_tags":{"oneOf":[{"type":"array","items":{"type":"string","maxLength":60},"maxItems":12},{"type":"string","description":"The same list, comma separated."}],"description":"Style words the caption is built from: `uptempo pop`, `80s synthwave`, `gospel choir`, `male vocal`. A JSON array or a comma separated string.\n\nORDER IS MEANING. The tags are folded into the caption in the order given and the first ones carry the most weight, which is also why reordering them changes the `Idempotency-Key` fingerprint: it is a different request, not a formatting difference."},"lyrics":{"type":"string","maxLength":5000,"description":"The exact words to sing. Section markers (`[verse]`, `[chorus]`, `[bridge]`, `[tag]`) are understood by the engine and shape the arrangement.\n\nSending this AND `lyric_id` is a 422 rather than a silent preference, because guessing which one the caller meant is how the wrong words get sung."},"lyric_id":{"type":"string","description":"A saved lyric to sing, from `POST /lyrics`. The words are copied onto the song at render time, so editing the lyric afterwards does not restate what an already-rendered track was sung from."},"instrumental":{"type":"boolean","description":"No vocals at all. Refused together with `lyrics` or `lyric_id`, because that combination has no meaning and silently dropping one of them is worse than a 422.","default":false},"duration_sec":{"type":"integer","minimum":10,"maximum":600,"description":"How long the finished track should be, in seconds. The engine's envelope is 10 to 600 and a request outside it is refused rather than silently clamped, because a caller who asked for a :05 station ID and received :10 has no field anywhere telling them why.\n\nTHIS IS THE BILLED UNIT. The workspace's allowance is measured in seconds of rendered audio, so a request for four takes of :60 costs 240 seconds, not one song.","default":30},"bpm":{"type":"integer","minimum":40,"maximum":220,"description":"Tempo to aim for. A request, not a guarantee: the planner refines it and the song reports what was actually used."},"key_scale":{"type":"string","maxLength":40,"description":"`C minor`, `F# major`. Free text rather than two enums, because the planner answers in text and sometimes answers modally, and round-tripping that through an enum loses it."},"seed":{"type":"integer","minimum":1,"maximum":2147483647,"description":"Pin the sampler for a reproducible take. Reported back on the song as `seed`, whether you pinned it or the engine chose one.\n\nA seed reproduces a take only for as long as everything else is unchanged: the same caption, the same duration and the same model. `model` is on every song for exactly that reason, because a track rendered a year ago must not claim to have come from today's checkpoint."},"persona_id":{"type":"string","description":"A saved style identity to render in, from `GET /personas`. A persona is a STYLE RECIPE plus an optional reference track, not a cloned voice: this engine has no speaker embedding for singing, and nothing here promises a particular person's voice."},"takes":{"type":"integer","minimum":1,"maximum":4,"description":"How many alternates to render. Each take is a separate seed and each one is billed, so `takes: 4` at `duration_sec: 60` claims 240 seconds against the allowance up front.\n\nTakes are versions of ONE song rather than four songs: they share a title, a brief and a place in a playlist, and `GET /songs/{song_id}` lists them under `versions`. That is deliberate, because a library that lists all four as peers buries everything else the workspace made.","default":1},"is_public":{"type":"boolean","description":"Publish to the explore feed. The song stays owned by this workspace either way; this only widens who may read it.","default":false}}}}}},"responses":{"202":{"description":"202 with the song id and where to poll. `seconds_claimed` is what was reserved against the allowance; a failed render refunds it.","headers":{"request-id":{"description":"Identifies this call in your workspace's request log. Quote it in a support conversation and we can find the exact request.","schema":{"type":"string"}},"x-request-latency-ms":{"description":"How long we took, end to end.","schema":{"type":"string"}},"RateLimit-Limit":{"description":"Burst capacity of the token bucket this request was measured against.","schema":{"type":"string"}},"RateLimit-Remaining":{"description":"Whole requests left in that bucket. Pace against this rather than against a 429.","schema":{"type":"string"}},"RateLimit-Reset":{"description":"Seconds until the bucket is full again.","schema":{"type":"string"}},"x-key-expires-at":{"description":"Present only on keys issued with an expiry: when this key stops working, in ISO 8601.","schema":{"type":"string"}},"Sunset":{"description":"Present in the last seven days of an expiring key's life, per RFC 8594. Alert on it, because the alternative warning is a 401 that looks exactly like a typo.","schema":{"type":"string"}},"x-song-id":{"description":"Id of the song this request created. The same value as `song_id` in the body, in a header so a client that streams or discards the body can still redeem the work.","schema":{"type":"string"}},"x-seconds-claimed":{"description":"Seconds of allowance this request reserved up front, which for `takes` greater than one is the total across every take. A render that FAILS is refunded, so this is a claim rather than a settled charge; `GET /usage` is the settled figure.","schema":{"type":"string"}},"idempotent-replay":{"description":"`true` when this response is the stored result of an earlier request carrying the same `Idempotency-Key`. Nothing ran and nothing billed. Absent on a fresh request.","schema":{"type":"string"}}},"content":{"application/json":{"schema":{"type":"object","properties":{"song_id":{"type":"string"},"status":{"type":"string"},"job_id":{"type":"string"},"poll_url":{"type":"string"},"seconds_claimed":{"type":"integer"},"estimated_wait_sec":{"type":"integer"}}},"example":{"song_id":"sg7k2m4p0001s7f3zq9r1d6t","status":"queued","job_id":"jb6c2n8t0001s7f3wd5y4q3l","poll_url":"https://jinglebuddy.example/api/v1/songs/sg7k2m4p0001s7f3zq9r1d6t","seconds_claimed":30,"estimated_wait_sec":90}}}},"401":{"description":"**missing_api_key**: No bearer token and no `x-api-key` header on the request.\n\n**invalid_api_key**: The key is unknown, revoked or past its expiry. All three answer identically so the endpoint cannot be used to enumerate keys.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"402":{"description":"**insufficient_credit**: The workspace has spent its plan allowance for the period and has no account credit left to cover the rest.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"404":{"description":"**not_found**: No such song, lyric, persona or playlist, or one that exists but is not visible to this workspace.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"409":{"description":"**idempotency_conflict**: An `Idempotency-Key` was reused. Either the first request carrying it is still running, or the record it produced has since been deleted and there is nothing left to replay.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"413":{"description":"**payload_too_large**: The request body is over this endpoint's ceiling.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"422":{"description":"**invalid_request**: The request parsed but a field is missing, out of range or the wrong type.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"429":{"description":"**rate_limited**: Too many requests from this key. `Retry-After` says how long to wait.\n\n**quota_exceeded**: The workspace has spent its allowance of rendered SECONDS for the billing period. Seconds, not songs: a :15 station ID and a three minute track cost the GPU wildly different amounts.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"500":{"description":"**internal_error**: Something broke on our side. The response never carries a stack trace.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"502":{"description":"**engine_unavailable**: The music engine refused or could not be reached.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"503":{"description":"**provider_unavailable**: The endpoint is implemented but this deployment has not satisfied the gate it depends on. `error.message` is the gate's own sentence and usually names the environment variables that would satisfy it.\n\nTwo gates are worth knowing about. Composing needs `MUSIC_ENGINE_URL` and `MUSIC_ENGINE_TOKEN`. Writing lyrics needs a language model (`LLM_PROVIDER` plus `LLM_API_KEY`), which is why `POST /lyrics` can be unavailable on a deployment where composing works perfectly.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"504":{"description":"**engine_timeout**: The engine did not answer in time. Usually a compose that landed on a GPU instance still loading the model.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}}},"get":{"operationId":"listSongs","tags":["Songs"],"summary":"List songs","description":"The workspace's catalogue, newest first, cursor-paginated.\n\nEvery song this workspace owns, whatever made it: the studio, this API, or a jingle package.\n\nCursor-paginated rather than offset-paginated, because a catalogue that is being added to while you page through it silently skips rows under an offset. Read `last_song_id` from one page and send it as `start_after_song_id` on the next.","x-required-scope":null,"parameters":[{"name":"page_size","in":"query","required":false,"description":"How many records to return.","schema":{"type":"integer","minimum":1,"maximum":100,"description":"How many records to return.","default":25}},{"name":"start_after_song_id","in":"query","required":false,"description":"The cursor: `last_song_id` from the previous page. A cursor that does not name a song in THIS workspace answers 422 rather than reading as the end of the list, which is what an empty page would otherwise look like.","schema":{"type":"string","description":"The cursor: `last_song_id` from the previous page. A cursor that does not name a song in THIS workspace answers 422 rather than reading as the end of the list, which is what an empty page would otherwise look like."}},{"name":"kind","in":"query","required":false,"description":"Only songs of this kind.","schema":{"type":"string","enum":["song","client_jingle","radio_jingle","bed"],"description":"Only songs of this kind."}},{"name":"status","in":"query","required":false,"description":"Only songs in this state. `status=failed` is the sweep worth running on a schedule: a failed render is refunded but the row stays, and it is the only record of what went wrong.","schema":{"type":"string","enum":["queued","rendering","ready","failed"],"description":"Only songs in this state. `status=failed` is the sweep worth running on a schedule: a failed render is refunded but the row stays, and it is the only record of what went wrong."}}],"responses":{"200":{"description":"`has_more` tells you whether to page again; `last_song_id` is the cursor to send.","headers":{"request-id":{"description":"Identifies this call in your workspace's request log. Quote it in a support conversation and we can find the exact request.","schema":{"type":"string"}},"x-request-latency-ms":{"description":"How long we took, end to end.","schema":{"type":"string"}},"RateLimit-Limit":{"description":"Burst capacity of the token bucket this request was measured against.","schema":{"type":"string"}},"RateLimit-Remaining":{"description":"Whole requests left in that bucket. Pace against this rather than against a 429.","schema":{"type":"string"}},"RateLimit-Reset":{"description":"Seconds until the bucket is full again.","schema":{"type":"string"}},"x-key-expires-at":{"description":"Present only on keys issued with an expiry: when this key stops working, in ISO 8601.","schema":{"type":"string"}},"Sunset":{"description":"Present in the last seven days of an expiring key's life, per RFC 8594. Alert on it, because the alternative warning is a 401 that looks exactly like a typo.","schema":{"type":"string"}}},"content":{"application/json":{"schema":{"type":"object","properties":{"songs":{"type":"array","items":{"type":"object","properties":{"song_id":{"type":"string"},"kind":{"type":"string"},"status":{"type":"string"},"title":{"type":"string"},"prompt":{"type":"string"},"caption":{"type":"string"},"style_tags":{"type":"array","items":{"type":"string"}},"lyrics":{"type":"string"},"lyric_id":{"type":"string"},"instrumental":{"type":"boolean"},"duration_sec":{"type":"integer"},"duration_ms":{"type":"integer"},"bpm":{"type":"integer"},"key_scale":{"type":"string"},"seed":{"type":"integer"},"model":{"type":"string"},"source":{"type":"string"},"size_bytes":{"type":"integer"},"render_ms":{"type":"integer"},"parent_song_id":{},"persona_id":{},"client_brief_id":{"type":"string"},"station_profile_id":{},"is_public":{"type":"boolean"},"error":{},"created_at_unix":{"type":"integer"},"audio_url":{"type":"string"}}}},"last_song_id":{"type":"string"},"has_more":{"type":"boolean"}}},"example":{"songs":[{"song_id":"sg7k2m4p0001s7f3zq9r1d6t","kind":"client_jingle","status":"ready","title":"Marco's Pizza :30","prompt":"Warm family pizzeria, singable hook, mention the phone number","caption":"uptempo pop, bright horns, female vocal, 124 bpm, C major, radio-ready mix","style_tags":["uptempo pop","bright horns","female vocal"],"lyrics":"[verse]\nMarco's Pizza on Main Street tonight...","lyric_id":"ly9d4b6y0002s7f3ln5r7w1k","instrumental":false,"duration_sec":30,"duration_ms":30120,"bpm":124,"key_scale":"C major","seed":918273645,"model":"acestep-v15-turbo","source":"api","size_bytes":723456,"render_ms":41200,"parent_song_id":null,"persona_id":null,"client_brief_id":"cb8j2p6d0005s7f3rn3c7v4a","station_profile_id":null,"is_public":false,"error":null,"created_at_unix":1786310400,"audio_url":"https://jinglebuddy.example/api/v1/songs/sg7k2m4p0001s7f3zq9r1d6t/audio"}],"last_song_id":"sg7k2m4p0001s7f3zq9r1d6t","has_more":false}}}},"401":{"description":"**missing_api_key**: No bearer token and no `x-api-key` header on the request.\n\n**invalid_api_key**: The key is unknown, revoked or past its expiry. All three answer identically so the endpoint cannot be used to enumerate keys.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"422":{"description":"**invalid_request**: The request parsed but a field is missing, out of range or the wrong type.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"429":{"description":"**rate_limited**: Too many requests from this key. `Retry-After` says how long to wait.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"500":{"description":"**internal_error**: Something broke on our side. The response never carries a stack trace.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}}}},"/songs/{song_id}":{"get":{"operationId":"getSong","tags":["Songs"],"summary":"Get a song","description":"One song in full, with its takes and stems.\n\nThe read a 202 points at. Poll this until `status` is `ready` or `failed`.\n\nIt carries three things the list does not: the full `lyrics` rather than a truncation, every take under `versions`, and every exported stem under `stems`. `signed_audio_url` is a direct, keyless link you can hand to a browser; it expires, so the expiry is published beside it rather than left to be discovered as a 403 from Google.","x-required-scope":null,"parameters":[{"name":"song_id","in":"path","required":true,"description":"Identifier of the song, as returned by `POST /songs` or `GET /songs`.","schema":{"type":"string","description":"Identifier of the song, as returned by `POST /songs` or `GET /songs`."}}],"responses":{"200":{"description":"`status` is `queued`, `rendering`, `ready` or `failed`. `audio_url` is null until the master lands, and `error` carries the reason when it never will.","headers":{"request-id":{"description":"Identifies this call in your workspace's request log. Quote it in a support conversation and we can find the exact request.","schema":{"type":"string"}},"x-request-latency-ms":{"description":"How long we took, end to end.","schema":{"type":"string"}},"RateLimit-Limit":{"description":"Burst capacity of the token bucket this request was measured against.","schema":{"type":"string"}},"RateLimit-Remaining":{"description":"Whole requests left in that bucket. Pace against this rather than against a 429.","schema":{"type":"string"}},"RateLimit-Reset":{"description":"Seconds until the bucket is full again.","schema":{"type":"string"}},"x-key-expires-at":{"description":"Present only on keys issued with an expiry: when this key stops working, in ISO 8601.","schema":{"type":"string"}},"Sunset":{"description":"Present in the last seven days of an expiring key's life, per RFC 8594. Alert on it, because the alternative warning is a 401 that looks exactly like a typo.","schema":{"type":"string"}}},"content":{"application/json":{"schema":{"type":"object","properties":{"song_id":{"type":"string"},"kind":{"type":"string"},"status":{"type":"string"},"title":{"type":"string"},"prompt":{"type":"string"},"caption":{"type":"string"},"style_tags":{"type":"array","items":{"type":"string"}},"lyrics":{"type":"string"},"lyric_id":{"type":"string"},"instrumental":{"type":"boolean"},"duration_sec":{"type":"integer"},"duration_ms":{"type":"integer"},"bpm":{"type":"integer"},"key_scale":{"type":"string"},"seed":{"type":"integer"},"model":{"type":"string"},"source":{"type":"string"},"size_bytes":{"type":"integer"},"render_ms":{"type":"integer"},"parent_song_id":{},"persona_id":{},"client_brief_id":{"type":"string"},"station_profile_id":{},"is_public":{"type":"boolean"},"error":{},"created_at_unix":{"type":"integer"},"audio_url":{"type":"string"},"versions":{"type":"array","items":{"type":"object","properties":{"version_id":{"type":"string"},"label":{"type":"string"},"seed":{"type":"integer"},"duration_ms":{"type":"integer"},"size_bytes":{"type":"integer"},"render_ms":{"type":"integer"},"chosen":{"type":"boolean"},"audio_url":{"type":"string"}}}},"stems":{"type":"array","items":{}}}},"example":{"song_id":"sg7k2m4p0001s7f3zq9r1d6t","kind":"client_jingle","status":"ready","title":"Marco's Pizza :30","prompt":"Warm family pizzeria, singable hook, mention the phone number","caption":"uptempo pop, bright horns, female vocal, 124 bpm, C major, radio-ready mix","style_tags":["uptempo pop","bright horns","female vocal"],"lyrics":"[verse]\nMarco's Pizza on Main Street tonight...","lyric_id":"ly9d4b6y0002s7f3ln5r7w1k","instrumental":false,"duration_sec":30,"duration_ms":30120,"bpm":124,"key_scale":"C major","seed":918273645,"model":"acestep-v15-turbo","source":"api","size_bytes":723456,"render_ms":41200,"parent_song_id":null,"persona_id":null,"client_brief_id":"cb8j2p6d0005s7f3rn3c7v4a","station_profile_id":null,"is_public":false,"error":null,"created_at_unix":1786310400,"audio_url":"https://jinglebuddy.example/api/v1/songs/sg7k2m4p0001s7f3zq9r1d6t/audio","versions":[{"version_id":"sv3n8c1w0004s7f3hb2j5x7m","label":"Take 1","seed":918273645,"duration_ms":30120,"size_bytes":723456,"render_ms":41200,"chosen":true,"audio_url":"https://jinglebuddy.example/api/v1/songs/sg7k2m4p0001s7f3zq9r1d6t/audio?version_id=sv3n8c1w0004s7f3hb2j5x7m"}],"stems":[]}}}},"401":{"description":"**missing_api_key**: No bearer token and no `x-api-key` header on the request.\n\n**invalid_api_key**: The key is unknown, revoked or past its expiry. All three answer identically so the endpoint cannot be used to enumerate keys.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"404":{"description":"**not_found**: No such song, lyric, persona or playlist, or one that exists but is not visible to this workspace.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"429":{"description":"**rate_limited**: Too many requests from this key. `Retry-After` says how long to wait.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"500":{"description":"**internal_error**: Something broke on our side. The response never carries a stack trace.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}}},"delete":{"operationId":"deleteSong","tags":["Songs"],"summary":"Delete a song","description":"Forget a song, its takes, its stems and its audio.\n\nRemoves the row and every object it points at: the master, every take, every stem.\n\nSCOPED, unlike the reads beside it. Reading your own catalogue needs no grant; emptying it does, because a key issued for a read-only dashboard has no business being able to destroy a workspace's masters.\n\nDELETING A SONG DOES NOT REFUND ITS SECONDS. The render happened and the GPU time was spent; the usage event outlives the song deliberately, so a month's metering cannot be rewritten by tidying the library.","x-required-scope":"library","parameters":[{"name":"song_id","in":"path","required":true,"description":"Identifier of the song, as returned by `POST /songs` or `GET /songs`.","schema":{"type":"string","description":"Identifier of the song, as returned by `POST /songs` or `GET /songs`."}}],"responses":{"200":{"description":"Answers 200 with the id that was removed. Deleting a song twice answers 404.","headers":{"request-id":{"description":"Identifies this call in your workspace's request log. Quote it in a support conversation and we can find the exact request.","schema":{"type":"string"}},"x-request-latency-ms":{"description":"How long we took, end to end.","schema":{"type":"string"}},"RateLimit-Limit":{"description":"Burst capacity of the token bucket this request was measured against.","schema":{"type":"string"}},"RateLimit-Remaining":{"description":"Whole requests left in that bucket. Pace against this rather than against a 429.","schema":{"type":"string"}},"RateLimit-Reset":{"description":"Seconds until the bucket is full again.","schema":{"type":"string"}},"x-key-expires-at":{"description":"Present only on keys issued with an expiry: when this key stops working, in ISO 8601.","schema":{"type":"string"}},"Sunset":{"description":"Present in the last seven days of an expiring key's life, per RFC 8594. Alert on it, because the alternative warning is a 401 that looks exactly like a typo.","schema":{"type":"string"}}},"content":{"application/json":{"schema":{"type":"object","properties":{"status":{"type":"string"},"song_id":{"type":"string"}}},"example":{"status":"ok","song_id":"sg7k2m4p0001s7f3zq9r1d6t"}}}},"401":{"description":"**missing_api_key**: No bearer token and no `x-api-key` header on the request.\n\n**invalid_api_key**: The key is unknown, revoked or past its expiry. All three answer identically so the endpoint cannot be used to enumerate keys.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"404":{"description":"**not_found**: No such song, lyric, persona or playlist, or one that exists but is not visible to this workspace.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"429":{"description":"**rate_limited**: Too many requests from this key. `Retry-After` says how long to wait.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"500":{"description":"**internal_error**: Something broke on our side. The response never carries a stack trace.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}}}},"/songs/{song_id}/audio":{"get":{"operationId":"getSongAudio","tags":["Songs"],"summary":"Download the audio","description":"The rendered master, as bytes.\n\nStreams the stored master. The endpoint every retry-and-resume pattern needs, and the one to use from a server: it is redeemed with the key you already hold, so there is no signed URL to expire mid-download.\n\nPass `version_id` for a specific take. Without it you get whichever take is currently `chosen`, which is what the song's own `audio_url` points at.\n\nA song that is still rendering answers `409 resource_not_ready` rather than 404, because the id is valid and a client that reads it as gone stops polling something about to succeed.","x-required-scope":null,"parameters":[{"name":"song_id","in":"path","required":true,"description":"Identifier of the song, as returned by `POST /songs` or `GET /songs`.","schema":{"type":"string","description":"Identifier of the song, as returned by `POST /songs` or `GET /songs`."}},{"name":"format","in":"query","required":false,"description":"Container for the returned audio. The engine writes both: `m4a` is AAC 192k with a fast-start atom, `wav` is 16-bit PCM at 44.1kHz.\n\n44.1kHz AND NOT 48kHz, deliberately. The engine renders at 48k and resamples on the way out, because 48k WAVs have broken downstream playout systems in this codebase before. Anything we do not recognise falls back to `m4a` rather than erroring, so check `Content-Type` if you branch on the container.","schema":{"type":"string","enum":["m4a","wav"],"description":"Container for the returned audio. The engine writes both: `m4a` is AAC 192k with a fast-start atom, `wav` is 16-bit PCM at 44.1kHz.\n\n44.1kHz AND NOT 48kHz, deliberately. The engine renders at 48k and resamples on the way out, because 48k WAVs have broken downstream playout systems in this codebase before. Anything we do not recognise falls back to `m4a` rather than erroring, so check `Content-Type` if you branch on the container.","default":"m4a"}},{"name":"version_id","in":"query","required":false,"description":"A specific take, from `versions` on the song read. Defaults to the chosen take.","schema":{"type":"string","description":"A specific take, from `versions` on the song read. Defaults to the chosen take."}}],"responses":{"200":{"description":"The raw audio, with `Content-Disposition` naming the file after the song. `Content-Type` is `audio/mp4` for m4a and `audio/wav` for wav.","headers":{"request-id":{"description":"Identifies this call in your workspace's request log. Quote it in a support conversation and we can find the exact request.","schema":{"type":"string"}},"x-request-latency-ms":{"description":"How long we took, end to end.","schema":{"type":"string"}},"RateLimit-Limit":{"description":"Burst capacity of the token bucket this request was measured against.","schema":{"type":"string"}},"RateLimit-Remaining":{"description":"Whole requests left in that bucket. Pace against this rather than against a 429.","schema":{"type":"string"}},"RateLimit-Reset":{"description":"Seconds until the bucket is full again.","schema":{"type":"string"}},"x-key-expires-at":{"description":"Present only on keys issued with an expiry: when this key stops working, in ISO 8601.","schema":{"type":"string"}},"Sunset":{"description":"Present in the last seven days of an expiring key's life, per RFC 8594. Alert on it, because the alternative warning is a 401 that looks exactly like a typo.","schema":{"type":"string"}},"x-song-id":{"description":"The song these bytes belong to.","schema":{"type":"string"}},"x-version-id":{"description":"The take these bytes are, which is the chosen one unless you asked otherwise.","schema":{"type":"string"}}},"content":{"audio/mp4":{"schema":{"type":"string","format":"binary"}},"audio/wav":{"schema":{"type":"string","format":"binary"}}}},"401":{"description":"**missing_api_key**: No bearer token and no `x-api-key` header on the request.\n\n**invalid_api_key**: The key is unknown, revoked or past its expiry. All three answer identically so the endpoint cannot be used to enumerate keys.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"404":{"description":"**not_found**: No such song, lyric, persona or playlist, or one that exists but is not visible to this workspace.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"409":{"description":"**resource_not_ready**: The id is real and this workspace owns it, but the thing you asked for does not exist yet: audio for a song still rendering, stems for a song with no master, an extend or a cover of a track that has not landed.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"429":{"description":"**rate_limited**: Too many requests from this key. `Retry-After` says how long to wait.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"500":{"description":"**internal_error**: Something broke on our side. The response never carries a stack trace.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}}}},"/songs/{song_id}/stems":{"get":{"operationId":"listStems","tags":["Songs"],"summary":"List stems","description":"What has been separated out of this track so far.\n\nStems are rendered on demand rather than with the master, because separation is a second GPU pass and most tracks are never stemmed. THE ABSENCE OF A ROW IS WHAT \"not exported yet\" MEANS: an empty list is not an error.\n\nEach stem carries a signed download link. They expire, so the expiry is published rather than left to be discovered.","x-required-scope":null,"parameters":[{"name":"song_id","in":"path","required":true,"description":"Identifier of the song, as returned by `POST /songs` or `GET /songs`.","schema":{"type":"string","description":"Identifier of the song, as returned by `POST /songs` or `GET /songs`."}}],"responses":{"200":{"description":"One entry per separated part. `url_expires_unix` is when the links stop working; re-read this endpoint for fresh ones.","headers":{"request-id":{"description":"Identifies this call in your workspace's request log. Quote it in a support conversation and we can find the exact request.","schema":{"type":"string"}},"x-request-latency-ms":{"description":"How long we took, end to end.","schema":{"type":"string"}},"RateLimit-Limit":{"description":"Burst capacity of the token bucket this request was measured against.","schema":{"type":"string"}},"RateLimit-Remaining":{"description":"Whole requests left in that bucket. Pace against this rather than against a 429.","schema":{"type":"string"}},"RateLimit-Reset":{"description":"Seconds until the bucket is full again.","schema":{"type":"string"}},"x-key-expires-at":{"description":"Present only on keys issued with an expiry: when this key stops working, in ISO 8601.","schema":{"type":"string"}},"Sunset":{"description":"Present in the last seven days of an expiring key's life, per RFC 8594. Alert on it, because the alternative warning is a 401 that looks exactly like a typo.","schema":{"type":"string"}}},"content":{"application/json":{"schema":{"type":"object","properties":{"song_id":{"type":"string"},"stems":{"type":"array","items":{"type":"object","properties":{"kind":{"type":"string"},"size_bytes":{"type":"integer"},"url":{"type":"string"},"url_expires_unix":{"type":"integer"}}}}}},"example":{"song_id":"sg7k2m4p0001s7f3zq9r1d6t","stems":[{"kind":"vocals","size_bytes":5241880,"url":"https://storage.googleapis.com/.../vocals.wav","url_expires_unix":1786314000},{"kind":"instrumental","size_bytes":5241880,"url":"https://storage.googleapis.com/.../instrumental.wav","url_expires_unix":1786314000}]}}}},"401":{"description":"**missing_api_key**: No bearer token and no `x-api-key` header on the request.\n\n**invalid_api_key**: The key is unknown, revoked or past its expiry. All three answer identically so the endpoint cannot be used to enumerate keys.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"404":{"description":"**not_found**: No such song, lyric, persona or playlist, or one that exists but is not visible to this workspace.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"429":{"description":"**rate_limited**: Too many requests from this key. `Retry-After` says how long to wait.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"500":{"description":"**internal_error**: Something broke on our side. The response never carries a stack trace.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}}},"post":{"operationId":"exportStems","tags":["Songs"],"summary":"Export stems","description":"Separate a finished track into its parts.\n\nSeparates the master into vocals, drums, bass, other and a full instrumental.\n\nA SECOND GPU PASS, SO IT SPENDS THE ALLOWANCE. The claim is the length of the track being separated, once, however many stems come back: five files out of one pass is one pass.\n\nRe-exporting the same song OVERWRITES rather than accumulating (one stem of each kind per song), which is also what makes a retried export harmless.\n\nThe song has to be `ready`. A track still rendering answers `409 resource_not_ready`.\n\nThis answers **202 Accepted** with a song id and a poll URL, not with audio. Composing runs on a GPU we do not hold open, so the honest answer to \"is it done\" is a second request.\n\nPoll `GET /songs/{song_id}` until `status` leaves `queued`/`rendering`. **Poll every 5 seconds, not faster.** The engine runs scale-to-zero by an explicit owner decision, so the first render after roughly fifteen idle minutes pays a 30 to 90 second cold start BEFORE the render begins: a one-second loop is a hundred pointless requests, and the rate limiter will start refusing them before the answer is even possible.\n\n`queued` and `rendering` are separate states for that reason. `queued` means nothing has touched it yet, which during a cold start is the normal and expected condition, not a stall.\n\nA webhook is the alternative to polling entirely: see the events on this endpoint.\n\nThis call spends the workspace's allowance, so it is worth retrying safely. Send an `Idempotency-Key` header (any value you choose, a UUID per logical request being the usual one) and a retry carrying the same key and the same payload answers with the FIRST call's result instead of composing and billing again. The replay is marked with `idempotent-replay: true`.\n\nA duplicate that arrives while the first is still running answers `409 idempotency_conflict` with `Retry-After`: wait and send the identical request again. Keys are forgotten after 24 hours.","x-required-scope":"stems","x-webhook-events":["stems.ready"],"parameters":[{"name":"song_id","in":"path","required":true,"description":"Identifier of the song, as returned by `POST /songs` or `GET /songs`.","schema":{"type":"string","description":"Identifier of the song, as returned by `POST /songs` or `GET /songs`."}},{"name":"Idempotency-Key","in":"header","required":false,"description":"A value you choose that makes this request safe to retry. The first call with a given key does the work; a retry carrying the same key AND the same payload answers with that first result instead of composing and billing again, and carries `idempotent-replay: true`.\n\nWhat the key is scoped to, exactly: the method, the path (so the same key against a different song or a different endpoint is a different request), the API key that sent it (so two services sharing a workspace cannot collide on each other's job ids), and a fingerprint of the payload. **Changing the payload under the same key does not replay**: it is a different request, so it renders and it bills. You are never handed the answer to a question you did not ask, but the key is not a way to avoid paying for a second, different one.\n\n`format` is deliberately outside the fingerprint, so a retry that only changes the container replays the stored track rather than paying for a re-render.\n\nThis matters more here than it does for a speech API. A compose is seconds against a warm GPU and a minute and a half against a cold one, which is comfortably past the point where clients time out, proxies retry and people press the button again.\n\nRetained for 24 hours; after that the key is forgotten and an identical request renders and bills as new. A key whose request FAILED is released at once, because a failed render costs nothing and has to stay retryable.\n\n1 to 255 printable ASCII characters; a UUID per logical request is the usual choice. Omit the header entirely and nothing changes.","schema":{"type":"string"},"example":"6f9619ff-8b86-d011-b42d-00c04fc964ff"}],"requestBody":{"required":false,"content":{"application/json":{"schema":{"type":"object","properties":{"kinds":{"oneOf":[{"type":"array","items":{"type":"string","maxLength":20},"maxItems":5},{"type":"string","description":"The same list, comma separated."}],"description":"Which parts to keep: `vocals`, `drums`, `bass`, `other`, `instrumental`. Defaults to all five.\n\nAsking for fewer does not cost less: separation produces every part in one pass and this only decides what is stored.","default":["vocals","drums","bass","other","instrumental"]},"version_id":{"type":"string","description":"Separate a specific take rather than the chosen one."}}}}}},"responses":{"202":{"description":"202. Poll `GET /songs/{song_id}/stems` until the parts appear.","headers":{"request-id":{"description":"Identifies this call in your workspace's request log. Quote it in a support conversation and we can find the exact request.","schema":{"type":"string"}},"x-request-latency-ms":{"description":"How long we took, end to end.","schema":{"type":"string"}},"RateLimit-Limit":{"description":"Burst capacity of the token bucket this request was measured against.","schema":{"type":"string"}},"RateLimit-Remaining":{"description":"Whole requests left in that bucket. Pace against this rather than against a 429.","schema":{"type":"string"}},"RateLimit-Reset":{"description":"Seconds until the bucket is full again.","schema":{"type":"string"}},"x-key-expires-at":{"description":"Present only on keys issued with an expiry: when this key stops working, in ISO 8601.","schema":{"type":"string"}},"Sunset":{"description":"Present in the last seven days of an expiring key's life, per RFC 8594. Alert on it, because the alternative warning is a 401 that looks exactly like a typo.","schema":{"type":"string"}},"x-song-id":{"description":"Id of the song this request created. The same value as `song_id` in the body, in a header so a client that streams or discards the body can still redeem the work.","schema":{"type":"string"}},"x-seconds-claimed":{"description":"Seconds of allowance this request reserved up front, which for `takes` greater than one is the total across every take. A render that FAILS is refunded, so this is a claim rather than a settled charge; `GET /usage` is the settled figure.","schema":{"type":"string"}},"idempotent-replay":{"description":"`true` when this response is the stored result of an earlier request carrying the same `Idempotency-Key`. Nothing ran and nothing billed. Absent on a fresh request.","schema":{"type":"string"}}},"content":{"application/json":{"schema":{"type":"object","properties":{"song_id":{"type":"string"},"status":{"type":"string"},"job_id":{"type":"string"},"poll_url":{"type":"string"},"seconds_claimed":{"type":"integer"}}},"example":{"song_id":"sg7k2m4p0001s7f3zq9r1d6t","status":"queued","job_id":"jb6c2n8t0001s7f3wd5y4q3l","poll_url":"https://jinglebuddy.example/api/v1/songs/sg7k2m4p0001s7f3zq9r1d6t/stems","seconds_claimed":30}}}},"401":{"description":"**missing_api_key**: No bearer token and no `x-api-key` header on the request.\n\n**invalid_api_key**: The key is unknown, revoked or past its expiry. All three answer identically so the endpoint cannot be used to enumerate keys.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"402":{"description":"**insufficient_credit**: The workspace has spent its plan allowance for the period and has no account credit left to cover the rest.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"404":{"description":"**not_found**: No such song, lyric, persona or playlist, or one that exists but is not visible to this workspace.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"409":{"description":"**resource_not_ready**: The id is real and this workspace owns it, but the thing you asked for does not exist yet: audio for a song still rendering, stems for a song with no master, an extend or a cover of a track that has not landed.\n\n**idempotency_conflict**: An `Idempotency-Key` was reused. Either the first request carrying it is still running, or the record it produced has since been deleted and there is nothing left to replay.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"422":{"description":"**invalid_request**: The request parsed but a field is missing, out of range or the wrong type.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"429":{"description":"**rate_limited**: Too many requests from this key. `Retry-After` says how long to wait.\n\n**quota_exceeded**: The workspace has spent its allowance of rendered SECONDS for the billing period. Seconds, not songs: a :15 station ID and a three minute track cost the GPU wildly different amounts.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"500":{"description":"**internal_error**: Something broke on our side. The response never carries a stack trace.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"502":{"description":"**engine_unavailable**: The music engine refused or could not be reached.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"503":{"description":"**provider_unavailable**: The endpoint is implemented but this deployment has not satisfied the gate it depends on. `error.message` is the gate's own sentence and usually names the environment variables that would satisfy it.\n\nTwo gates are worth knowing about. Composing needs `MUSIC_ENGINE_URL` and `MUSIC_ENGINE_TOKEN`. Writing lyrics needs a language model (`LLM_PROVIDER` plus `LLM_API_KEY`), which is why `POST /lyrics` can be unavailable on a deployment where composing works perfectly.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"504":{"description":"**engine_timeout**: The engine did not answer in time. Usually a compose that landed on a GPU instance still loading the model.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}}}},"/songs/{song_id}/extend":{"post":{"operationId":"extendSong","tags":["Songs"],"summary":"Extend a song","description":"Continue a finished track past its ending.\n\nContinues an existing track, keeping its character and carrying on from where it stops.\n\nThe result is a NEW song whose `parent_song_id` points at the original, not an edit of it. That is what makes the lineage readable and what keeps the original playable: deleting the parent later leaves the continuation standing, with a null parent that honestly reads as \"the source is gone\".\n\nYou are billed for the ADDED seconds, not for the whole result. A :30 track extended by :30 claims 30 seconds.\n\nThis answers **202 Accepted** with a song id and a poll URL, not with audio. Composing runs on a GPU we do not hold open, so the honest answer to \"is it done\" is a second request.\n\nPoll `GET /songs/{song_id}` until `status` leaves `queued`/`rendering`. **Poll every 5 seconds, not faster.** The engine runs scale-to-zero by an explicit owner decision, so the first render after roughly fifteen idle minutes pays a 30 to 90 second cold start BEFORE the render begins: a one-second loop is a hundred pointless requests, and the rate limiter will start refusing them before the answer is even possible.\n\n`queued` and `rendering` are separate states for that reason. `queued` means nothing has touched it yet, which during a cold start is the normal and expected condition, not a stall.\n\nA webhook is the alternative to polling entirely: see the events on this endpoint.\n\nThis call spends the workspace's allowance, so it is worth retrying safely. Send an `Idempotency-Key` header (any value you choose, a UUID per logical request being the usual one) and a retry carrying the same key and the same payload answers with the FIRST call's result instead of composing and billing again. The replay is marked with `idempotent-replay: true`.\n\nA duplicate that arrives while the first is still running answers `409 idempotency_conflict` with `Retry-After`: wait and send the identical request again. Keys are forgotten after 24 hours.","x-required-scope":"music","x-webhook-events":["song.ready","song.failed"],"parameters":[{"name":"song_id","in":"path","required":true,"description":"Identifier of the song, as returned by `POST /songs` or `GET /songs`.","schema":{"type":"string","description":"Identifier of the song, as returned by `POST /songs` or `GET /songs`."}},{"name":"Idempotency-Key","in":"header","required":false,"description":"A value you choose that makes this request safe to retry. The first call with a given key does the work; a retry carrying the same key AND the same payload answers with that first result instead of composing and billing again, and carries `idempotent-replay: true`.\n\nWhat the key is scoped to, exactly: the method, the path (so the same key against a different song or a different endpoint is a different request), the API key that sent it (so two services sharing a workspace cannot collide on each other's job ids), and a fingerprint of the payload. **Changing the payload under the same key does not replay**: it is a different request, so it renders and it bills. You are never handed the answer to a question you did not ask, but the key is not a way to avoid paying for a second, different one.\n\n`format` is deliberately outside the fingerprint, so a retry that only changes the container replays the stored track rather than paying for a re-render.\n\nThis matters more here than it does for a speech API. A compose is seconds against a warm GPU and a minute and a half against a cold one, which is comfortably past the point where clients time out, proxies retry and people press the button again.\n\nRetained for 24 hours; after that the key is forgotten and an identical request renders and bills as new. A key whose request FAILED is released at once, because a failed render costs nothing and has to stay retryable.\n\n1 to 255 printable ASCII characters; a UUID per logical request is the usual choice. Omit the header entirely and nothing changes.","schema":{"type":"string"},"example":"6f9619ff-8b86-d011-b42d-00c04fc964ff"}],"requestBody":{"required":false,"content":{"application/json":{"schema":{"type":"object","properties":{"seconds":{"type":"integer","minimum":5,"maximum":240,"description":"How much to add. The combined length still has to sit inside the engine's envelope, so extending a nine minute track answers 422.","default":30},"prompt":{"type":"string","maxLength":2000,"description":"Where to take it: `build to a big finish`, `drop to a soft outro`."},"lyrics":{"type":"string","maxLength":5000,"description":"Words for the new section only. Omit on an instrumental, or to continue wordlessly."},"style_tags":{"oneOf":[{"type":"array","items":{"type":"string","maxLength":60},"maxItems":12},{"type":"string","description":"The same list, comma separated."}],"description":"Style words the caption is built from: `uptempo pop`, `80s synthwave`, `gospel choir`, `male vocal`. A JSON array or a comma separated string.\n\nORDER IS MEANING. The tags are folded into the caption in the order given and the first ones carry the most weight, which is also why reordering them changes the `Idempotency-Key` fingerprint: it is a different request, not a formatting difference."},"seed":{"type":"integer","minimum":1,"maximum":2147483647,"description":"Pin the sampler for a reproducible take. Reported back on the song as `seed`, whether you pinned it or the engine chose one.\n\nA seed reproduces a take only for as long as everything else is unchanged: the same caption, the same duration and the same model. `model` is on every song for exactly that reason, because a track rendered a year ago must not claim to have come from today's checkpoint."},"title":{"type":"string","maxLength":120,"description":"Title for the continuation. Defaults to the parent's title plus a suffix."}}}}}},"responses":{"202":{"description":"202 with the NEW song's id. The parent is untouched.","headers":{"request-id":{"description":"Identifies this call in your workspace's request log. Quote it in a support conversation and we can find the exact request.","schema":{"type":"string"}},"x-request-latency-ms":{"description":"How long we took, end to end.","schema":{"type":"string"}},"RateLimit-Limit":{"description":"Burst capacity of the token bucket this request was measured against.","schema":{"type":"string"}},"RateLimit-Remaining":{"description":"Whole requests left in that bucket. Pace against this rather than against a 429.","schema":{"type":"string"}},"RateLimit-Reset":{"description":"Seconds until the bucket is full again.","schema":{"type":"string"}},"x-key-expires-at":{"description":"Present only on keys issued with an expiry: when this key stops working, in ISO 8601.","schema":{"type":"string"}},"Sunset":{"description":"Present in the last seven days of an expiring key's life, per RFC 8594. Alert on it, because the alternative warning is a 401 that looks exactly like a typo.","schema":{"type":"string"}},"x-song-id":{"description":"Id of the song this request created. The same value as `song_id` in the body, in a header so a client that streams or discards the body can still redeem the work.","schema":{"type":"string"}},"x-seconds-claimed":{"description":"Seconds of allowance this request reserved up front, which for `takes` greater than one is the total across every take. A render that FAILS is refunded, so this is a claim rather than a settled charge; `GET /usage` is the settled figure.","schema":{"type":"string"}},"idempotent-replay":{"description":"`true` when this response is the stored result of an earlier request carrying the same `Idempotency-Key`. Nothing ran and nothing billed. Absent on a fresh request.","schema":{"type":"string"}}},"content":{"application/json":{"schema":{"type":"object","properties":{"song_id":{"type":"string"},"status":{"type":"string"},"job_id":{"type":"string"},"poll_url":{"type":"string"},"seconds_claimed":{"type":"integer"},"estimated_wait_sec":{"type":"integer"}}},"example":{"song_id":"sg7k2m4p0001s7f3zq9r1d6t","status":"queued","job_id":"jb6c2n8t0001s7f3wd5y4q3l","poll_url":"https://jinglebuddy.example/api/v1/songs/sg7k2m4p0001s7f3zq9r1d6t","seconds_claimed":30,"estimated_wait_sec":90}}}},"401":{"description":"**missing_api_key**: No bearer token and no `x-api-key` header on the request.\n\n**invalid_api_key**: The key is unknown, revoked or past its expiry. All three answer identically so the endpoint cannot be used to enumerate keys.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"402":{"description":"**insufficient_credit**: The workspace has spent its plan allowance for the period and has no account credit left to cover the rest.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"404":{"description":"**not_found**: No such song, lyric, persona or playlist, or one that exists but is not visible to this workspace.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"409":{"description":"**resource_not_ready**: The id is real and this workspace owns it, but the thing you asked for does not exist yet: audio for a song still rendering, stems for a song with no master, an extend or a cover of a track that has not landed.\n\n**idempotency_conflict**: An `Idempotency-Key` was reused. Either the first request carrying it is still running, or the record it produced has since been deleted and there is nothing left to replay.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"422":{"description":"**invalid_request**: The request parsed but a field is missing, out of range or the wrong type.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"429":{"description":"**rate_limited**: Too many requests from this key. `Retry-After` says how long to wait.\n\n**quota_exceeded**: The workspace has spent its allowance of rendered SECONDS for the billing period. Seconds, not songs: a :15 station ID and a three minute track cost the GPU wildly different amounts.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"500":{"description":"**internal_error**: Something broke on our side. The response never carries a stack trace.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"502":{"description":"**engine_unavailable**: The music engine refused or could not be reached.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"503":{"description":"**provider_unavailable**: The endpoint is implemented but this deployment has not satisfied the gate it depends on. `error.message` is the gate's own sentence and usually names the environment variables that would satisfy it.\n\nTwo gates are worth knowing about. Composing needs `MUSIC_ENGINE_URL` and `MUSIC_ENGINE_TOKEN`. Writing lyrics needs a language model (`LLM_PROVIDER` plus `LLM_API_KEY`), which is why `POST /lyrics` can be unavailable on a deployment where composing works perfectly.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"504":{"description":"**engine_timeout**: The engine did not answer in time. Usually a compose that landed on a GPU instance still loading the model.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}}}},"/songs/{song_id}/cover":{"post":{"operationId":"coverSong","tags":["Songs"],"summary":"Cover a song","description":"Re-render an existing track in another style.\n\nRe-renders a track you own in a different style, keeping the words and the shape.\n\nThe classic use here is not musical taste, it is reach: one advertiser jingle covered as country, as pop and as rock is three spots for three stations off one approved lyric, and the client only has to sign off the words once.\n\nCOVERS ONLY WHAT THIS WORKSPACE OWNS. There is no third-party audio input on this API, and that is a licensing boundary rather than a missing feature.\n\nThe result is a new song with `parent_song_id` set, billed at its own length.\n\nThis answers **202 Accepted** with a song id and a poll URL, not with audio. Composing runs on a GPU we do not hold open, so the honest answer to \"is it done\" is a second request.\n\nPoll `GET /songs/{song_id}` until `status` leaves `queued`/`rendering`. **Poll every 5 seconds, not faster.** The engine runs scale-to-zero by an explicit owner decision, so the first render after roughly fifteen idle minutes pays a 30 to 90 second cold start BEFORE the render begins: a one-second loop is a hundred pointless requests, and the rate limiter will start refusing them before the answer is even possible.\n\n`queued` and `rendering` are separate states for that reason. `queued` means nothing has touched it yet, which during a cold start is the normal and expected condition, not a stall.\n\nA webhook is the alternative to polling entirely: see the events on this endpoint.\n\nThis call spends the workspace's allowance, so it is worth retrying safely. Send an `Idempotency-Key` header (any value you choose, a UUID per logical request being the usual one) and a retry carrying the same key and the same payload answers with the FIRST call's result instead of composing and billing again. The replay is marked with `idempotent-replay: true`.\n\nA duplicate that arrives while the first is still running answers `409 idempotency_conflict` with `Retry-After`: wait and send the identical request again. Keys are forgotten after 24 hours.","x-required-scope":"music","x-webhook-events":["song.ready","song.failed"],"parameters":[{"name":"song_id","in":"path","required":true,"description":"Identifier of the song, as returned by `POST /songs` or `GET /songs`.","schema":{"type":"string","description":"Identifier of the song, as returned by `POST /songs` or `GET /songs`."}},{"name":"Idempotency-Key","in":"header","required":false,"description":"A value you choose that makes this request safe to retry. The first call with a given key does the work; a retry carrying the same key AND the same payload answers with that first result instead of composing and billing again, and carries `idempotent-replay: true`.\n\nWhat the key is scoped to, exactly: the method, the path (so the same key against a different song or a different endpoint is a different request), the API key that sent it (so two services sharing a workspace cannot collide on each other's job ids), and a fingerprint of the payload. **Changing the payload under the same key does not replay**: it is a different request, so it renders and it bills. You are never handed the answer to a question you did not ask, but the key is not a way to avoid paying for a second, different one.\n\n`format` is deliberately outside the fingerprint, so a retry that only changes the container replays the stored track rather than paying for a re-render.\n\nThis matters more here than it does for a speech API. A compose is seconds against a warm GPU and a minute and a half against a cold one, which is comfortably past the point where clients time out, proxies retry and people press the button again.\n\nRetained for 24 hours; after that the key is forgotten and an identical request renders and bills as new. A key whose request FAILED is released at once, because a failed render costs nothing and has to stay retryable.\n\n1 to 255 printable ASCII characters; a UUID per logical request is the usual choice. Omit the header entirely and nothing changes.","schema":{"type":"string"},"example":"6f9619ff-8b86-d011-b42d-00c04fc964ff"}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","properties":{"style_tags":{"oneOf":[{"type":"array","items":{"type":"string","maxLength":60},"maxItems":12},{"type":"string","description":"The same list, comma separated."}],"description":"Style words the caption is built from: `uptempo pop`, `80s synthwave`, `gospel choir`, `male vocal`. A JSON array or a comma separated string.\n\nORDER IS MEANING. The tags are folded into the caption in the order given and the first ones carry the most weight, which is also why reordering them changes the `Idempotency-Key` fingerprint: it is a different request, not a formatting difference.\n\nRequired here: a cover with no new style is just a second render of the same thing, which `POST /songs` with a different seed already does more cheaply."},"prompt":{"type":"string","maxLength":2000,"description":"Anything the tags do not cover: `slower, acoustic, single guitar`."},"duration_sec":{"type":"integer","minimum":10,"maximum":600,"description":"How long the finished track should be, in seconds. The engine's envelope is 10 to 600 and a request outside it is refused rather than silently clamped, because a caller who asked for a :05 station ID and received :10 has no field anywhere telling them why.\n\nTHIS IS THE BILLED UNIT. The workspace's allowance is measured in seconds of rendered audio, so a request for four takes of :60 costs 240 seconds, not one song.\n\nDefaults to the parent's length, which is what a like-for-like cover of a :30 spot needs."},"persona_id":{"type":"string","description":"A saved style identity to render in, from `GET /personas`. A persona is a STYLE RECIPE plus an optional reference track, not a cloned voice: this engine has no speaker embedding for singing, and nothing here promises a particular person's voice."},"instrumental":{"type":"boolean","description":"Cover the parent as an instrumental. Useful for turning an approved jingle into a bed a live read can sit over."},"seed":{"type":"integer","minimum":1,"maximum":2147483647,"description":"Pin the sampler for a reproducible take. Reported back on the song as `seed`, whether you pinned it or the engine chose one.\n\nA seed reproduces a take only for as long as everything else is unchanged: the same caption, the same duration and the same model. `model` is on every song for exactly that reason, because a track rendered a year ago must not claim to have come from today's checkpoint."},"title":{"type":"string","maxLength":120,"description":"Title for the cover. Defaults to the parent's title plus the first style tag."}},"required":["style_tags"]}}}},"responses":{"202":{"description":"202 with the new song's id.","headers":{"request-id":{"description":"Identifies this call in your workspace's request log. Quote it in a support conversation and we can find the exact request.","schema":{"type":"string"}},"x-request-latency-ms":{"description":"How long we took, end to end.","schema":{"type":"string"}},"RateLimit-Limit":{"description":"Burst capacity of the token bucket this request was measured against.","schema":{"type":"string"}},"RateLimit-Remaining":{"description":"Whole requests left in that bucket. Pace against this rather than against a 429.","schema":{"type":"string"}},"RateLimit-Reset":{"description":"Seconds until the bucket is full again.","schema":{"type":"string"}},"x-key-expires-at":{"description":"Present only on keys issued with an expiry: when this key stops working, in ISO 8601.","schema":{"type":"string"}},"Sunset":{"description":"Present in the last seven days of an expiring key's life, per RFC 8594. Alert on it, because the alternative warning is a 401 that looks exactly like a typo.","schema":{"type":"string"}},"x-song-id":{"description":"Id of the song this request created. The same value as `song_id` in the body, in a header so a client that streams or discards the body can still redeem the work.","schema":{"type":"string"}},"x-seconds-claimed":{"description":"Seconds of allowance this request reserved up front, which for `takes` greater than one is the total across every take. A render that FAILS is refunded, so this is a claim rather than a settled charge; `GET /usage` is the settled figure.","schema":{"type":"string"}},"idempotent-replay":{"description":"`true` when this response is the stored result of an earlier request carrying the same `Idempotency-Key`. Nothing ran and nothing billed. Absent on a fresh request.","schema":{"type":"string"}}},"content":{"application/json":{"schema":{"type":"object","properties":{"song_id":{"type":"string"},"status":{"type":"string"},"job_id":{"type":"string"},"poll_url":{"type":"string"},"seconds_claimed":{"type":"integer"},"estimated_wait_sec":{"type":"integer"}}},"example":{"song_id":"sg7k2m4p0001s7f3zq9r1d6t","status":"queued","job_id":"jb6c2n8t0001s7f3wd5y4q3l","poll_url":"https://jinglebuddy.example/api/v1/songs/sg7k2m4p0001s7f3zq9r1d6t","seconds_claimed":30,"estimated_wait_sec":90}}}},"401":{"description":"**missing_api_key**: No bearer token and no `x-api-key` header on the request.\n\n**invalid_api_key**: The key is unknown, revoked or past its expiry. All three answer identically so the endpoint cannot be used to enumerate keys.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"402":{"description":"**insufficient_credit**: The workspace has spent its plan allowance for the period and has no account credit left to cover the rest.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"404":{"description":"**not_found**: No such song, lyric, persona or playlist, or one that exists but is not visible to this workspace.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"409":{"description":"**resource_not_ready**: The id is real and this workspace owns it, but the thing you asked for does not exist yet: audio for a song still rendering, stems for a song with no master, an extend or a cover of a track that has not landed.\n\n**idempotency_conflict**: An `Idempotency-Key` was reused. Either the first request carrying it is still running, or the record it produced has since been deleted and there is nothing left to replay.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"422":{"description":"**invalid_request**: The request parsed but a field is missing, out of range or the wrong type.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"429":{"description":"**rate_limited**: Too many requests from this key. `Retry-After` says how long to wait.\n\n**quota_exceeded**: The workspace has spent its allowance of rendered SECONDS for the billing period. Seconds, not songs: a :15 station ID and a three minute track cost the GPU wildly different amounts.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"500":{"description":"**internal_error**: Something broke on our side. The response never carries a stack trace.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"502":{"description":"**engine_unavailable**: The music engine refused or could not be reached.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"503":{"description":"**provider_unavailable**: The endpoint is implemented but this deployment has not satisfied the gate it depends on. `error.message` is the gate's own sentence and usually names the environment variables that would satisfy it.\n\nTwo gates are worth knowing about. Composing needs `MUSIC_ENGINE_URL` and `MUSIC_ENGINE_TOKEN`. Writing lyrics needs a language model (`LLM_PROVIDER` plus `LLM_API_KEY`), which is why `POST /lyrics` can be unavailable on a deployment where composing works perfectly.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"504":{"description":"**engine_timeout**: The engine did not answer in time. Usually a compose that landed on a GPU instance still loading the model.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}}}},"/lyrics":{"post":{"operationId":"writeLyrics","tags":["Lyrics"],"summary":"Write lyrics","description":"Words to sing, written to a brief and a length.\n\nWrites a set of words and saves them, without rendering anything.\n\nSEPARATE FROM COMPOSING ON PURPOSE. A client jingle goes through six drafts before anybody spends a GPU second on it, and the draft has to survive between sessions with nothing rendered at all. Redeem the `lyric_id` at `POST /songs` when the words are approved.\n\nIT SPENDS NO SECONDS AND IT IS NOT FREE. No GPU runs, so nothing lands on the seconds allowance, but every call spends real money with a language-model vendor. That is why `lyrics` is a scope of its own and why it is NOT ticked by default when a key is issued.\n\n`duration_sec` is the field that actually decides the shape. A :15 is one line and a tag; a :60 has room for two verses. Written words that do not fit the length are the single most common reason a jingle has to be re-rendered.\n\nNeeds a language model configured on the deployment. Without one this answers `503 provider_unavailable` while composing keeps working.","x-required-scope":"lyrics","parameters":[{"name":"Idempotency-Key","in":"header","required":false,"description":"A value you choose that makes this request safe to retry. The first call with a given key does the work; a retry carrying the same key AND the same payload answers with that first result instead of composing and billing again, and carries `idempotent-replay: true`.\n\nWhat the key is scoped to, exactly: the method, the path (so the same key against a different song or a different endpoint is a different request), the API key that sent it (so two services sharing a workspace cannot collide on each other's job ids), and a fingerprint of the payload. **Changing the payload under the same key does not replay**: it is a different request, so it renders and it bills. You are never handed the answer to a question you did not ask, but the key is not a way to avoid paying for a second, different one.\n\n`format` is deliberately outside the fingerprint, so a retry that only changes the container replays the stored track rather than paying for a re-render.\n\nThis matters more here than it does for a speech API. A compose is seconds against a warm GPU and a minute and a half against a cold one, which is comfortably past the point where clients time out, proxies retry and people press the button again.\n\nRetained for 24 hours; after that the key is forgotten and an identical request renders and bills as new. A key whose request FAILED is released at once, because a failed render costs nothing and has to stay retryable.\n\n1 to 255 printable ASCII characters; a UUID per logical request is the usual choice. Omit the header entirely and nothing changes.","schema":{"type":"string"},"example":"6f9619ff-8b86-d011-b42d-00c04fc964ff"}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","properties":{"prompt":{"type":"string","minLength":3,"maxLength":2000,"description":"What the words are for, in your own words."},"title":{"type":"string","maxLength":120,"description":"What to file it under. Derived from the prompt when omitted."},"kind":{"type":"string","enum":["song","client_jingle","radio_jingle"],"description":"What the words are FOR, which changes how they are written rather than what is stored. A `client_jingle` puts the offer and the call to action where they will be heard; a `radio_jingle` sings the call letters and the dial position and repeats them.","default":"song"},"must_say":{"oneOf":[{"type":"array","items":{"type":"string","maxLength":120},"maxItems":10},{"type":"string","description":"The same list, comma separated."}],"description":"Phrases that have to appear verbatim: a phone number, a legal line, a cleared comparison.\n\nTHIS IS A CHECKLIST, NOT A HINT. The words are checked against it after they are written and the response reports any phrase that did not land, so a caller can refuse the draft before it costs a render. An advertiser jingle that does not say the phone number is a failed deliverable however good it sounds."},"duration_sec":{"type":"integer","minimum":10,"maximum":600,"description":"How long the finished track will be. Decides how many lines there is room for, which is the whole point of passing it.","default":30},"style_tags":{"oneOf":[{"type":"array","items":{"type":"string","maxLength":60},"maxItems":12},{"type":"string","description":"The same list, comma separated."}],"description":"Style words, so the phrasing suits the music it will be sung over."},"structure":{"oneOf":[{"type":"array","items":{"type":"string","maxLength":16},"maxItems":8},{"type":"string","description":"The same list, comma separated."}],"description":"Sections to write, in order: `verse`, `chorus`, `bridge`, `tag`. Chosen for you from `duration_sec` when omitted."},"language":{"type":"string","maxLength":10,"description":"BCP 47 tag for the language to write in.","default":"en"}},"required":["prompt"]}}}},"responses":{"201":{"description":"201 with the saved lyric. `missing_phrases` is present and non-empty only when a `must_say` entry did not make it in, which is a draft to reject rather than an error.","headers":{"request-id":{"description":"Identifies this call in your workspace's request log. Quote it in a support conversation and we can find the exact request.","schema":{"type":"string"}},"x-request-latency-ms":{"description":"How long we took, end to end.","schema":{"type":"string"}},"RateLimit-Limit":{"description":"Burst capacity of the token bucket this request was measured against.","schema":{"type":"string"}},"RateLimit-Remaining":{"description":"Whole requests left in that bucket. Pace against this rather than against a 429.","schema":{"type":"string"}},"RateLimit-Reset":{"description":"Seconds until the bucket is full again.","schema":{"type":"string"}},"x-key-expires-at":{"description":"Present only on keys issued with an expiry: when this key stops working, in ISO 8601.","schema":{"type":"string"}},"Sunset":{"description":"Present in the last seven days of an expiring key's life, per RFC 8594. Alert on it, because the alternative warning is a 401 that looks exactly like a typo.","schema":{"type":"string"}}},"content":{"application/json":{"schema":{"type":"object","properties":{"lyric_id":{"type":"string"},"title":{"type":"string"},"body":{"type":"string"},"structure_tags":{"type":"array","items":{"type":"string"}},"generated_by_ai":{"type":"boolean"},"model":{"type":"string"},"prompt":{"type":"string"},"created_at_unix":{"type":"integer"},"missing_phrases":{"type":"array","items":{}}}},"example":{"lyric_id":"ly9d4b6y0002s7f3ln5r7w1k","title":"Marco's Pizza :30","body":"[verse]\nMarco's Pizza on Main Street tonight\n[chorus]\nCall five five five, oh one two three...","structure_tags":["verse","chorus","tag"],"generated_by_ai":true,"model":"claude-sonnet-5","prompt":"Warm family pizzeria, must say the phone number","created_at_unix":1786310400,"missing_phrases":[]}}}},"401":{"description":"**missing_api_key**: No bearer token and no `x-api-key` header on the request.\n\n**invalid_api_key**: The key is unknown, revoked or past its expiry. All three answer identically so the endpoint cannot be used to enumerate keys.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"402":{"description":"**insufficient_credit**: The workspace has spent its plan allowance for the period and has no account credit left to cover the rest.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"409":{"description":"**idempotency_conflict**: An `Idempotency-Key` was reused. Either the first request carrying it is still running, or the record it produced has since been deleted and there is nothing left to replay.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"413":{"description":"**payload_too_large**: The request body is over this endpoint's ceiling.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"422":{"description":"**invalid_request**: The request parsed but a field is missing, out of range or the wrong type.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"429":{"description":"**rate_limited**: Too many requests from this key. `Retry-After` says how long to wait.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"500":{"description":"**internal_error**: Something broke on our side. The response never carries a stack trace.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"503":{"description":"**provider_unavailable**: The endpoint is implemented but this deployment has not satisfied the gate it depends on. `error.message` is the gate's own sentence and usually names the environment variables that would satisfy it.\n\nTwo gates are worth knowing about. Composing needs `MUSIC_ENGINE_URL` and `MUSIC_ENGINE_TOKEN`. Writing lyrics needs a language model (`LLM_PROVIDER` plus `LLM_API_KEY`), which is why `POST /lyrics` can be unavailable on a deployment where composing works perfectly.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}}}},"/jingles/client":{"post":{"operationId":"createClientJingle","tags":["Jingles"],"summary":"Client jingle","description":"An advertiser's jingle, from the brief, cut to broadcast lengths.\n\nTakes an advertiser's facts and returns a sung jingle, at every length you ask for.\n\nTHE DIFFERENCE FROM `POST /songs` IS THE BRIEF, and it is the whole reason this endpoint exists. The business name, the offer, the phone number and the call to action are FIELDS here, not hopeful sentences in a prompt, so the lyric writer works against a checklist and the finished words are checked back against it. A prompt-driven jingle that drops the phone number sounds fine and cannot be aired.\n\nONE CALL, SEVERAL SONGS. `lengths: [15, 30, 60]` composes the same jingle three times at three lengths and answers with three song ids: a :60 truncated to :30 is not a :30, and the shorter cuts have to be written shorter rather than edited shorter.\n\nBILLED AS THE SUM. Three lengths at two takes each is 15+30+60 doubled, so 210 seconds, and `seconds_claimed` says so before you commit. THIS IS WHY `jingles` IS ITS OWN SCOPE: one press here can spend twenty times what one press of `POST /songs` does.\n\nThe brief is saved, so the next flight for the same advertiser is `brief_id` and a new offer.\n\nThis answers **202 Accepted** with a song id and a poll URL, not with audio. Composing runs on a GPU we do not hold open, so the honest answer to \"is it done\" is a second request.\n\nPoll `GET /songs/{song_id}` until `status` leaves `queued`/`rendering`. **Poll every 5 seconds, not faster.** The engine runs scale-to-zero by an explicit owner decision, so the first render after roughly fifteen idle minutes pays a 30 to 90 second cold start BEFORE the render begins: a one-second loop is a hundred pointless requests, and the rate limiter will start refusing them before the answer is even possible.\n\n`queued` and `rendering` are separate states for that reason. `queued` means nothing has touched it yet, which during a cold start is the normal and expected condition, not a stall.\n\nA webhook is the alternative to polling entirely: see the events on this endpoint.\n\nThis call spends the workspace's allowance, so it is worth retrying safely. Send an `Idempotency-Key` header (any value you choose, a UUID per logical request being the usual one) and a retry carrying the same key and the same payload answers with the FIRST call's result instead of composing and billing again. The replay is marked with `idempotent-replay: true`.\n\nA duplicate that arrives while the first is still running answers `409 idempotency_conflict` with `Retry-After`: wait and send the identical request again. Keys are forgotten after 24 hours.","x-required-scope":"jingles","x-webhook-events":["jingle.ready","song.ready","song.failed"],"parameters":[{"name":"Idempotency-Key","in":"header","required":false,"description":"A value you choose that makes this request safe to retry. The first call with a given key does the work; a retry carrying the same key AND the same payload answers with that first result instead of composing and billing again, and carries `idempotent-replay: true`.\n\nWhat the key is scoped to, exactly: the method, the path (so the same key against a different song or a different endpoint is a different request), the API key that sent it (so two services sharing a workspace cannot collide on each other's job ids), and a fingerprint of the payload. **Changing the payload under the same key does not replay**: it is a different request, so it renders and it bills. You are never handed the answer to a question you did not ask, but the key is not a way to avoid paying for a second, different one.\n\n`format` is deliberately outside the fingerprint, so a retry that only changes the container replays the stored track rather than paying for a re-render.\n\nThis matters more here than it does for a speech API. A compose is seconds against a warm GPU and a minute and a half against a cold one, which is comfortably past the point where clients time out, proxies retry and people press the button again.\n\nRetained for 24 hours; after that the key is forgotten and an identical request renders and bills as new. A key whose request FAILED is released at once, because a failed render costs nothing and has to stay retryable.\n\n1 to 255 printable ASCII characters; a UUID per logical request is the usual choice. Omit the header entirely and nothing changes.","schema":{"type":"string"},"example":"6f9619ff-8b86-d011-b42d-00c04fc964ff"}],"requestBody":{"required":false,"content":{"application/json":{"schema":{"type":"object","properties":{"brief_id":{"type":"string","description":"Reuse a saved advertiser brief. Any field you also send overrides that brief's value FOR THIS RENDER ONLY and does not edit the saved record, which is what makes \"same client, new offer\" one call.\n\nDeliberately absent from the sample below: the sample shows the from-scratch path, because a cuid in a request example is indistinguishable from a working id and answers 404 for everyone who copies it."},"business_name":{"type":"string","minLength":1,"maxLength":120,"description":"The advertiser. Required unless `brief_id` supplies it."},"industry":{"type":"string","maxLength":80,"description":"`pizzeria`, `car dealership`, `dental practice`. Steers the musical register."},"tagline":{"type":"string","maxLength":200,"description":"The line they already own, if they have one."},"phone":{"type":"string","maxLength":40,"description":"Sung as written, so write it the way it should be SUNG: `five five five, oh one two three` sings correctly and `555-0123` does not reliably."},"offer":{"type":"string","maxLength":200,"description":"`two for one Tuesdays`, `20% off through August`."},"cta":{"type":"string","maxLength":200,"description":"The call to action: `call now`, `visit us on Main Street`."},"must_say":{"oneOf":[{"type":"array","items":{"type":"string","maxLength":120},"maxItems":10},{"type":"string","description":"The same list, comma separated."}],"description":"Anything else that has to be said verbatim: a legal line, a cleared comparison. Checked against the written words, and any phrase that did not land is reported on the response rather than discovered on air."},"lengths":{"oneOf":[{"type":"array","items":{"type":"integer","minimum":10,"maximum":600},"maxItems":4},{"type":"string","description":"The same list, comma separated."}],"description":"Broadcast lengths to produce, in seconds. `15, 30, 60` are the ones a traffic log takes; :45 is accepted because clients ask for it. Each entry must sit inside the engine's 10 to 600 second envelope.","default":[30]},"tone":{"type":"string","maxLength":120,"description":"`warm and family owned`, `loud and urgent`."},"city":{"type":"string","maxLength":80,"description":"Where they are, when the copy should say so."},"website":{"type":"string","maxLength":200,"description":"The advertiser's web address, saved on the brief. Sung only when the copy calls for it, and written the way it should be SUNG for the same reason `phone` is: `marcos pizza dot com` sings and `https://marcospizza.com/` does not."},"notes":{"type":"string","maxLength":2000,"description":"Anything about this advertiser that does not fit a field: a pronunciation, what they sell that the trade does not say, what the last flight got wrong. Steers the writing; nothing here is a guaranteed line, and a phrase that MUST be said belongs in `must_say`."},"style_tags":{"oneOf":[{"type":"array","items":{"type":"string","maxLength":60},"maxItems":12},{"type":"string","description":"The same list, comma separated."}],"description":"Style words the caption is built from: `uptempo pop`, `80s synthwave`, `gospel choir`, `male vocal`. A JSON array or a comma separated string.\n\nORDER IS MEANING. The tags are folded into the caption in the order given and the first ones carry the most weight, which is also why reordering them changes the `Idempotency-Key` fingerprint: it is a different request, not a formatting difference.\n\nOn a jingle these decide the music and NOT the words: the brief fields decide the words."},"takes":{"type":"integer","minimum":1,"maximum":4,"description":"How many alternates to render. Each take is a separate seed and each one is billed, so `takes: 4` at `duration_sec: 60` claims 240 seconds against the allowance up front.\n\nTakes are versions of ONE song rather than four songs: they share a title, a brief and a place in a playlist, and `GET /songs/{song_id}` lists them under `versions`. That is deliberate, because a library that lists all four as peers buries everything else the workspace made.","default":1},"save_brief":{"type":"boolean","description":"Keep the brief for next time. Send `false` for a one-off pitch you do not want cluttering the client list.","default":true}}}}}},"responses":{"202":{"description":"202 with one entry per length. Poll each song id, or take the `jingle.ready` webhook, which fires once when every song in the package has landed.","headers":{"request-id":{"description":"Identifies this call in your workspace's request log. Quote it in a support conversation and we can find the exact request.","schema":{"type":"string"}},"x-request-latency-ms":{"description":"How long we took, end to end.","schema":{"type":"string"}},"RateLimit-Limit":{"description":"Burst capacity of the token bucket this request was measured against.","schema":{"type":"string"}},"RateLimit-Remaining":{"description":"Whole requests left in that bucket. Pace against this rather than against a 429.","schema":{"type":"string"}},"RateLimit-Reset":{"description":"Seconds until the bucket is full again.","schema":{"type":"string"}},"x-key-expires-at":{"description":"Present only on keys issued with an expiry: when this key stops working, in ISO 8601.","schema":{"type":"string"}},"Sunset":{"description":"Present in the last seven days of an expiring key's life, per RFC 8594. Alert on it, because the alternative warning is a 401 that looks exactly like a typo.","schema":{"type":"string"}},"x-song-id":{"description":"Id of the song this request created. The same value as `song_id` in the body, in a header so a client that streams or discards the body can still redeem the work.","schema":{"type":"string"}},"x-seconds-claimed":{"description":"Seconds of allowance this request reserved up front, which for `takes` greater than one is the total across every take. A render that FAILS is refunded, so this is a claim rather than a settled charge; `GET /usage` is the settled figure.","schema":{"type":"string"}},"idempotent-replay":{"description":"`true` when this response is the stored result of an earlier request carrying the same `Idempotency-Key`. Nothing ran and nothing billed. Absent on a fresh request.","schema":{"type":"string"}}},"content":{"application/json":{"schema":{"type":"object","properties":{"kind":{"type":"string"},"client_brief_id":{"type":"string"},"lyric_id":{"type":"string"},"missing_phrases":{"type":"array","items":{}},"songs":{"type":"array","items":{"type":"object","properties":{"song_id":{"type":"string"},"duration_sec":{"type":"integer"},"status":{"type":"string"}}}},"seconds_claimed":{"type":"integer"},"poll_url":{"type":"string"}}},"example":{"kind":"client_jingle","client_brief_id":"cb8j2p6d0005s7f3rn3c7v4a","lyric_id":"ly9d4b6y0002s7f3ln5r7w1k","missing_phrases":[],"songs":[{"song_id":"sg7k2m4p0001s7f3zq9r1d6t","duration_sec":30,"status":"queued"}],"seconds_claimed":30,"poll_url":"https://jinglebuddy.example/api/v1/songs?kind=client_jingle"}}}},"401":{"description":"**missing_api_key**: No bearer token and no `x-api-key` header on the request.\n\n**invalid_api_key**: The key is unknown, revoked or past its expiry. All three answer identically so the endpoint cannot be used to enumerate keys.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"402":{"description":"**insufficient_credit**: The workspace has spent its plan allowance for the period and has no account credit left to cover the rest.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"404":{"description":"**not_found**: No such song, lyric, persona or playlist, or one that exists but is not visible to this workspace.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"409":{"description":"**idempotency_conflict**: An `Idempotency-Key` was reused. Either the first request carrying it is still running, or the record it produced has since been deleted and there is nothing left to replay.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"413":{"description":"**payload_too_large**: The request body is over this endpoint's ceiling.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"422":{"description":"**invalid_request**: The request parsed but a field is missing, out of range or the wrong type.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"429":{"description":"**rate_limited**: Too many requests from this key. `Retry-After` says how long to wait.\n\n**quota_exceeded**: The workspace has spent its allowance of rendered SECONDS for the billing period. Seconds, not songs: a :15 station ID and a three minute track cost the GPU wildly different amounts.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"500":{"description":"**internal_error**: Something broke on our side. The response never carries a stack trace.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"502":{"description":"**engine_unavailable**: The music engine refused or could not be reached.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"503":{"description":"**provider_unavailable**: The endpoint is implemented but this deployment has not satisfied the gate it depends on. `error.message` is the gate's own sentence and usually names the environment variables that would satisfy it.\n\nTwo gates are worth knowing about. Composing needs `MUSIC_ENGINE_URL` and `MUSIC_ENGINE_TOKEN`. Writing lyrics needs a language model (`LLM_PROVIDER` plus `LLM_API_KEY`), which is why `POST /lyrics` can be unavailable on a deployment where composing works perfectly.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"504":{"description":"**engine_timeout**: The engine did not answer in time. Usually a compose that landed on a GPU instance still loading the model.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}}}},"/jingles/radio":{"post":{"operationId":"createRadioJingle","tags":["Jingles"],"summary":"Radio imaging","description":"Station IDs, sweepers and beds, sung to the station's own facts.\n\nStation imaging: the sung station name, dial position and positioning slogan that sit between records.\n\nWHY A PACKAGE RATHER THAN A TRACK. A station's imaging is twenty pieces that all have to sing the same call letters, the same dial position and the same slogan, and re-typing those per render is exactly how one of them ends up wrong on air. `package` renders a coherent set in one call from one profile.\n\nThe five pieces, and what each is for:\n\n- `id` the legal station identification, sung, typically :05 to :10.\n- `sweeper` the short sung transition between songs.\n- `promo_bed` an instrumental bed a voice promo is read over. Instrumental by definition.\n- `news_theme` an instrumental sting and bed for the bulletin.\n- `traffic_theme` the same for traffic, deliberately busier.\n\nA station profile is saved, so tomorrow's sweepers are `station_profile_id` and a new slogan.\n\nBILLED AS THE SUM of every piece at every length, and `seconds_claimed` says so before you commit.\n\nThis answers **202 Accepted** with a song id and a poll URL, not with audio. Composing runs on a GPU we do not hold open, so the honest answer to \"is it done\" is a second request.\n\nPoll `GET /songs/{song_id}` until `status` leaves `queued`/`rendering`. **Poll every 5 seconds, not faster.** The engine runs scale-to-zero by an explicit owner decision, so the first render after roughly fifteen idle minutes pays a 30 to 90 second cold start BEFORE the render begins: a one-second loop is a hundred pointless requests, and the rate limiter will start refusing them before the answer is even possible.\n\n`queued` and `rendering` are separate states for that reason. `queued` means nothing has touched it yet, which during a cold start is the normal and expected condition, not a stall.\n\nA webhook is the alternative to polling entirely: see the events on this endpoint.\n\nThis call spends the workspace's allowance, so it is worth retrying safely. Send an `Idempotency-Key` header (any value you choose, a UUID per logical request being the usual one) and a retry carrying the same key and the same payload answers with the FIRST call's result instead of composing and billing again. The replay is marked with `idempotent-replay: true`.\n\nA duplicate that arrives while the first is still running answers `409 idempotency_conflict` with `Retry-After`: wait and send the identical request again. Keys are forgotten after 24 hours.","x-required-scope":"jingles","x-webhook-events":["jingle.ready","song.ready","song.failed"],"parameters":[{"name":"Idempotency-Key","in":"header","required":false,"description":"A value you choose that makes this request safe to retry. The first call with a given key does the work; a retry carrying the same key AND the same payload answers with that first result instead of composing and billing again, and carries `idempotent-replay: true`.\n\nWhat the key is scoped to, exactly: the method, the path (so the same key against a different song or a different endpoint is a different request), the API key that sent it (so two services sharing a workspace cannot collide on each other's job ids), and a fingerprint of the payload. **Changing the payload under the same key does not replay**: it is a different request, so it renders and it bills. You are never handed the answer to a question you did not ask, but the key is not a way to avoid paying for a second, different one.\n\n`format` is deliberately outside the fingerprint, so a retry that only changes the container replays the stored track rather than paying for a re-render.\n\nThis matters more here than it does for a speech API. A compose is seconds against a warm GPU and a minute and a half against a cold one, which is comfortably past the point where clients time out, proxies retry and people press the button again.\n\nRetained for 24 hours; after that the key is forgotten and an identical request renders and bills as new. A key whose request FAILED is released at once, because a failed render costs nothing and has to stay retryable.\n\n1 to 255 printable ASCII characters; a UUID per logical request is the usual choice. Omit the header entirely and nothing changes.","schema":{"type":"string"},"example":"6f9619ff-8b86-d011-b42d-00c04fc964ff"}],"requestBody":{"required":false,"content":{"application/json":{"schema":{"type":"object","properties":{"station_profile_id":{"type":"string","description":"Reuse a saved station. Any field you also send overrides it for this render only. Absent from the sample below for the reason `brief_id` is: a realistic-looking id in a request example is one everybody copies and nobody owns."},"station_name":{"type":"string","minLength":1,"maxLength":120,"description":"The call letters or the on-air name, spelled the way it should be SUNG: `K H I T` sings as four letters, `KHIT` may not."},"dial_position":{"type":"string","maxLength":40,"description":"`101.5 FM`, `AM 1010`. A string rather than a number because the sung form is what matters and half of these are not decimals."},"station_format":{"type":"string","maxLength":60,"description":"`country`, `classic hits`, `news talk`. Named `station_format` and not `format` because `format` on this API is the audio container everywhere else, and one word meaning two things across one surface is worse than a longer name."},"positioner":{"type":"string","maxLength":160,"description":"The positioning slogan: `the valley's best country`."},"city":{"type":"string","maxLength":80,"description":"The market, when the imaging should name it."},"imaging_notes":{"type":"string","maxLength":1000,"description":"How it should sound and be sung: voice gender, energy, whether the slogan lands before or after the call letters."},"package":{"oneOf":[{"type":"array","items":{"type":"string","maxLength":20},"maxItems":5},{"type":"string","description":"The same list, comma separated."}],"description":"Which pieces to produce: `id`, `sweeper`, `promo_bed`, `news_theme`, `traffic_theme`. `full` is shorthand for all five.\n\n`promo_bed`, `news_theme` and `traffic_theme` are instrumental whatever else you send, because a bed with a vocal on it is not a bed.","default":["id","sweeper"]},"lengths":{"oneOf":[{"type":"array","items":{"type":"integer","minimum":10,"maximum":600},"maxItems":4},{"type":"string","description":"The same list, comma separated."}],"description":"Lengths to produce each piece at, in seconds. Sensible defaults per piece when omitted (an ID is short, a bed is long). Each entry must sit inside the engine's 10 to 600 second envelope, which is why a genuinely :05 station ID comes back as :10."},"style_tags":{"oneOf":[{"type":"array","items":{"type":"string","maxLength":60},"maxItems":12},{"type":"string","description":"The same list, comma separated."}],"description":"Style words the caption is built from: `uptempo pop`, `80s synthwave`, `gospel choir`, `male vocal`. A JSON array or a comma separated string.\n\nORDER IS MEANING. The tags are folded into the caption in the order given and the first ones carry the most weight, which is also why reordering them changes the `Idempotency-Key` fingerprint: it is a different request, not a formatting difference.\n\nLeave these off and the station's format decides them, which is usually the right answer for imaging."},"takes":{"type":"integer","minimum":1,"maximum":4,"description":"How many alternates to render. Each take is a separate seed and each one is billed, so `takes: 4` at `duration_sec: 60` claims 240 seconds against the allowance up front.\n\nTakes are versions of ONE song rather than four songs: they share a title, a brief and a place in a playlist, and `GET /songs/{song_id}` lists them under `versions`. That is deliberate, because a library that lists all four as peers buries everything else the workspace made.","default":1},"save_profile":{"type":"boolean","description":"Keep the station profile for next time.","default":true}}}}}},"responses":{"202":{"description":"202 with one entry per piece per length. `piece` says which is which, because a package of eight songs is otherwise indistinguishable from eight ids.","headers":{"request-id":{"description":"Identifies this call in your workspace's request log. Quote it in a support conversation and we can find the exact request.","schema":{"type":"string"}},"x-request-latency-ms":{"description":"How long we took, end to end.","schema":{"type":"string"}},"RateLimit-Limit":{"description":"Burst capacity of the token bucket this request was measured against.","schema":{"type":"string"}},"RateLimit-Remaining":{"description":"Whole requests left in that bucket. Pace against this rather than against a 429.","schema":{"type":"string"}},"RateLimit-Reset":{"description":"Seconds until the bucket is full again.","schema":{"type":"string"}},"x-key-expires-at":{"description":"Present only on keys issued with an expiry: when this key stops working, in ISO 8601.","schema":{"type":"string"}},"Sunset":{"description":"Present in the last seven days of an expiring key's life, per RFC 8594. Alert on it, because the alternative warning is a 401 that looks exactly like a typo.","schema":{"type":"string"}},"x-song-id":{"description":"Id of the song this request created. The same value as `song_id` in the body, in a header so a client that streams or discards the body can still redeem the work.","schema":{"type":"string"}},"x-seconds-claimed":{"description":"Seconds of allowance this request reserved up front, which for `takes` greater than one is the total across every take. A render that FAILS is refunded, so this is a claim rather than a settled charge; `GET /usage` is the settled figure.","schema":{"type":"string"}},"idempotent-replay":{"description":"`true` when this response is the stored result of an earlier request carrying the same `Idempotency-Key`. Nothing ran and nothing billed. Absent on a fresh request.","schema":{"type":"string"}}},"content":{"application/json":{"schema":{"type":"object","properties":{"kind":{"type":"string"},"station_profile_id":{"type":"string"},"songs":{"type":"array","items":{"type":"object","properties":{"song_id":{"type":"string"},"piece":{"type":"string"},"duration_sec":{"type":"integer"},"status":{"type":"string"}}}},"seconds_claimed":{"type":"integer"},"poll_url":{"type":"string"}}},"example":{"kind":"radio_jingle","station_profile_id":"st4v9m1q0007s7f3gt6c2y8x","songs":[{"song_id":"sg7k2m4p0001s7f3zq9r1d6t","piece":"id","duration_sec":10,"status":"queued"}],"seconds_claimed":40,"poll_url":"https://jinglebuddy.example/api/v1/songs?kind=radio_jingle"}}}},"401":{"description":"**missing_api_key**: No bearer token and no `x-api-key` header on the request.\n\n**invalid_api_key**: The key is unknown, revoked or past its expiry. All three answer identically so the endpoint cannot be used to enumerate keys.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"402":{"description":"**insufficient_credit**: The workspace has spent its plan allowance for the period and has no account credit left to cover the rest.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"404":{"description":"**not_found**: No such song, lyric, persona or playlist, or one that exists but is not visible to this workspace.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"409":{"description":"**idempotency_conflict**: An `Idempotency-Key` was reused. Either the first request carrying it is still running, or the record it produced has since been deleted and there is nothing left to replay.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"413":{"description":"**payload_too_large**: The request body is over this endpoint's ceiling.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"422":{"description":"**invalid_request**: The request parsed but a field is missing, out of range or the wrong type.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"429":{"description":"**rate_limited**: Too many requests from this key. `Retry-After` says how long to wait.\n\n**quota_exceeded**: The workspace has spent its allowance of rendered SECONDS for the billing period. Seconds, not songs: a :15 station ID and a three minute track cost the GPU wildly different amounts.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"500":{"description":"**internal_error**: Something broke on our side. The response never carries a stack trace.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"502":{"description":"**engine_unavailable**: The music engine refused or could not be reached.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"503":{"description":"**provider_unavailable**: The endpoint is implemented but this deployment has not satisfied the gate it depends on. `error.message` is the gate's own sentence and usually names the environment variables that would satisfy it.\n\nTwo gates are worth knowing about. Composing needs `MUSIC_ENGINE_URL` and `MUSIC_ENGINE_TOKEN`. Writing lyrics needs a language model (`LLM_PROVIDER` plus `LLM_API_KEY`), which is why `POST /lyrics` can be unavailable on a deployment where composing works perfectly.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"504":{"description":"**engine_timeout**: The engine did not answer in time. Usually a compose that landed on a GPU instance still loading the model.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}}}},"/personas":{"get":{"operationId":"listPersonas","tags":["Library"],"summary":"List personas","description":"Saved style identities.\n\nA persona is a STYLE RECIPE plus an optional reference track. It is not a cloned voice: this engine has no speaker embedding for singing, and the name is deliberate because nothing here promises a particular person's voice.\n\nWhat it buys is consistency. A reference track carries character across a cover render, which is as close to a recurring artist as this engine gets, and a station that images with one persona sounds like one station.","x-required-scope":null,"parameters":[{"name":"page_size","in":"query","required":false,"description":"How many records to return.","schema":{"type":"integer","minimum":1,"maximum":100,"description":"How many records to return.","default":25}}],"responses":{"200":{"description":"The workspace's own personas, newest first.","headers":{"request-id":{"description":"Identifies this call in your workspace's request log. Quote it in a support conversation and we can find the exact request.","schema":{"type":"string"}},"x-request-latency-ms":{"description":"How long we took, end to end.","schema":{"type":"string"}},"RateLimit-Limit":{"description":"Burst capacity of the token bucket this request was measured against.","schema":{"type":"string"}},"RateLimit-Remaining":{"description":"Whole requests left in that bucket. Pace against this rather than against a 429.","schema":{"type":"string"}},"RateLimit-Reset":{"description":"Seconds until the bucket is full again.","schema":{"type":"string"}},"x-key-expires-at":{"description":"Present only on keys issued with an expiry: when this key stops working, in ISO 8601.","schema":{"type":"string"}},"Sunset":{"description":"Present in the last seven days of an expiring key's life, per RFC 8594. Alert on it, because the alternative warning is a 401 that looks exactly like a typo.","schema":{"type":"string"}}},"content":{"application/json":{"schema":{"type":"object","properties":{"personas":{"type":"array","items":{"type":"object","properties":{"persona_id":{"type":"string"},"name":{"type":"string"},"description":{"type":"string"},"style_tags":{"type":"array","items":{"type":"string"}},"reference_song_id":{"type":"string"},"is_public":{"type":"boolean"},"created_at_unix":{"type":"integer"}}}},"has_more":{"type":"boolean"}}},"example":{"personas":[{"persona_id":"pe2h6j4x0003s7f3kd9c8e5u","name":"Station voice, female","description":"Bright pop-country delivery for imaging","style_tags":["pop country","female vocal","bright"],"reference_song_id":"sg7k2m4p0001s7f3zq9r1d6t","is_public":false,"created_at_unix":1786310400}],"has_more":false}}}},"401":{"description":"**missing_api_key**: No bearer token and no `x-api-key` header on the request.\n\n**invalid_api_key**: The key is unknown, revoked or past its expiry. All three answer identically so the endpoint cannot be used to enumerate keys.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"422":{"description":"**invalid_request**: The request parsed but a field is missing, out of range or the wrong type.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"429":{"description":"**rate_limited**: Too many requests from this key. `Retry-After` says how long to wait.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"500":{"description":"**internal_error**: Something broke on our side. The response never carries a stack trace.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}}},"post":{"operationId":"createPersona","tags":["Library"],"summary":"Create a persona","description":"Save a style identity for reuse.\n\nSaves style tags, and optionally a track to carry character from, under a name.\n\nSpends nothing: no GPU runs and no vendor is called. It is bounded by the plan's persona limit, which answers `422 invalid_request` naming the ceiling rather than silently keeping the oldest.","x-required-scope":"library","requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","properties":{"name":{"type":"string","minLength":1,"maxLength":80,"description":"What to call it."},"description":{"type":"string","maxLength":400,"description":"What it is for, in your own words."},"style_tags":{"oneOf":[{"type":"array","items":{"type":"string","maxLength":60},"maxItems":12},{"type":"string","description":"The same list, comma separated."}],"description":"Style words the caption is built from: `uptempo pop`, `80s synthwave`, `gospel choir`, `male vocal`. A JSON array or a comma separated string.\n\nORDER IS MEANING. The tags are folded into the caption in the order given and the first ones carry the most weight, which is also why reordering them changes the `Idempotency-Key` fingerprint: it is a different request, not a formatting difference."},"reference_song_id":{"type":"string","description":"A finished track of your own to carry character from. Deleting that track later leaves the persona standing with a null reference, which still works as a style recipe."},"is_public":{"type":"boolean","description":"Publish to the explore feed.","default":false}},"required":["name","style_tags"]}}}},"responses":{"201":{"description":"201 with the saved persona.","headers":{"request-id":{"description":"Identifies this call in your workspace's request log. Quote it in a support conversation and we can find the exact request.","schema":{"type":"string"}},"x-request-latency-ms":{"description":"How long we took, end to end.","schema":{"type":"string"}},"RateLimit-Limit":{"description":"Burst capacity of the token bucket this request was measured against.","schema":{"type":"string"}},"RateLimit-Remaining":{"description":"Whole requests left in that bucket. Pace against this rather than against a 429.","schema":{"type":"string"}},"RateLimit-Reset":{"description":"Seconds until the bucket is full again.","schema":{"type":"string"}},"x-key-expires-at":{"description":"Present only on keys issued with an expiry: when this key stops working, in ISO 8601.","schema":{"type":"string"}},"Sunset":{"description":"Present in the last seven days of an expiring key's life, per RFC 8594. Alert on it, because the alternative warning is a 401 that looks exactly like a typo.","schema":{"type":"string"}}},"content":{"application/json":{"schema":{"type":"object","properties":{"persona_id":{"type":"string"},"name":{"type":"string"},"description":{"type":"string"},"style_tags":{"type":"array","items":{"type":"string"}},"reference_song_id":{"type":"string"},"is_public":{"type":"boolean"},"created_at_unix":{"type":"integer"}}},"example":{"persona_id":"pe2h6j4x0003s7f3kd9c8e5u","name":"Station voice, female","description":"Bright pop-country delivery for imaging","style_tags":["pop country","female vocal","bright"],"reference_song_id":"sg7k2m4p0001s7f3zq9r1d6t","is_public":false,"created_at_unix":1786310400}}}},"401":{"description":"**missing_api_key**: No bearer token and no `x-api-key` header on the request.\n\n**invalid_api_key**: The key is unknown, revoked or past its expiry. All three answer identically so the endpoint cannot be used to enumerate keys.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"404":{"description":"**not_found**: No such song, lyric, persona or playlist, or one that exists but is not visible to this workspace.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"422":{"description":"**invalid_request**: The request parsed but a field is missing, out of range or the wrong type.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"429":{"description":"**rate_limited**: Too many requests from this key. `Retry-After` says how long to wait.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"500":{"description":"**internal_error**: Something broke on our side. The response never carries a stack trace.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}}}},"/playlists":{"get":{"operationId":"listPlaylists","tags":["Library"],"summary":"List playlists","description":"Saved collections of songs.\n\nCollections the workspace has made. A playlist is how a client's whole flight, or a station's whole imaging package, is handed over as one thing.","x-required-scope":null,"parameters":[{"name":"page_size","in":"query","required":false,"description":"How many records to return.","schema":{"type":"integer","minimum":1,"maximum":100,"description":"How many records to return.","default":25}}],"responses":{"200":{"description":"Newest first, with a song count rather than the songs themselves.","headers":{"request-id":{"description":"Identifies this call in your workspace's request log. Quote it in a support conversation and we can find the exact request.","schema":{"type":"string"}},"x-request-latency-ms":{"description":"How long we took, end to end.","schema":{"type":"string"}},"RateLimit-Limit":{"description":"Burst capacity of the token bucket this request was measured against.","schema":{"type":"string"}},"RateLimit-Remaining":{"description":"Whole requests left in that bucket. Pace against this rather than against a 429.","schema":{"type":"string"}},"RateLimit-Reset":{"description":"Seconds until the bucket is full again.","schema":{"type":"string"}},"x-key-expires-at":{"description":"Present only on keys issued with an expiry: when this key stops working, in ISO 8601.","schema":{"type":"string"}},"Sunset":{"description":"Present in the last seven days of an expiring key's life, per RFC 8594. Alert on it, because the alternative warning is a 401 that looks exactly like a typo.","schema":{"type":"string"}}},"content":{"application/json":{"schema":{"type":"object","properties":{"playlists":{"type":"array","items":{"type":"object","properties":{"playlist_id":{"type":"string"},"name":{"type":"string"},"description":{"type":"string"},"is_public":{"type":"boolean"},"song_count":{"type":"integer"},"created_at_unix":{"type":"integer"}}}},"has_more":{"type":"boolean"}}},"example":{"playlists":[{"playlist_id":"pl5t1v8z0006s7f3xc4m2b9g","name":"Marco's Pizza, August","description":"Every cut of the August flight","is_public":false,"song_count":3,"created_at_unix":1786310400}],"has_more":false}}}},"401":{"description":"**missing_api_key**: No bearer token and no `x-api-key` header on the request.\n\n**invalid_api_key**: The key is unknown, revoked or past its expiry. All three answer identically so the endpoint cannot be used to enumerate keys.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"422":{"description":"**invalid_request**: The request parsed but a field is missing, out of range or the wrong type.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"429":{"description":"**rate_limited**: Too many requests from this key. `Retry-After` says how long to wait.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"500":{"description":"**internal_error**: Something broke on our side. The response never carries a stack trace.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}}},"post":{"operationId":"createPlaylist","tags":["Library"],"summary":"Create a playlist","description":"Collect songs under one name.\n\nCreates a collection, optionally with songs in it.\n\nSong ids that this workspace does not own answer 404 rather than being skipped: a playlist that silently came back two tracks short is worse than a refusal, because the caller ships it.\n\nA song appears in a playlist once, so sending the same id twice is not an error and does not duplicate it. That is also what makes \"add to playlist\" safe against a double click.","x-required-scope":"library","requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","properties":{"name":{"type":"string","minLength":1,"maxLength":120,"description":"What to call it."},"description":{"type":"string","maxLength":400,"description":"What is in it."},"song_ids":{"oneOf":[{"type":"array","items":{"type":"string","maxLength":40},"maxItems":100},{"type":"string","description":"The same list, comma separated."}],"description":"Songs to put in it, in order. Every one must belong to this workspace."},"is_public":{"type":"boolean","description":"Publish to the explore feed.","default":false}},"required":["name"]}}}},"responses":{"201":{"description":"201 with the playlist and how many songs went in.","headers":{"request-id":{"description":"Identifies this call in your workspace's request log. Quote it in a support conversation and we can find the exact request.","schema":{"type":"string"}},"x-request-latency-ms":{"description":"How long we took, end to end.","schema":{"type":"string"}},"RateLimit-Limit":{"description":"Burst capacity of the token bucket this request was measured against.","schema":{"type":"string"}},"RateLimit-Remaining":{"description":"Whole requests left in that bucket. Pace against this rather than against a 429.","schema":{"type":"string"}},"RateLimit-Reset":{"description":"Seconds until the bucket is full again.","schema":{"type":"string"}},"x-key-expires-at":{"description":"Present only on keys issued with an expiry: when this key stops working, in ISO 8601.","schema":{"type":"string"}},"Sunset":{"description":"Present in the last seven days of an expiring key's life, per RFC 8594. Alert on it, because the alternative warning is a 401 that looks exactly like a typo.","schema":{"type":"string"}}},"content":{"application/json":{"schema":{"type":"object","properties":{"playlist_id":{"type":"string"},"name":{"type":"string"},"description":{"type":"string"},"is_public":{"type":"boolean"},"song_count":{"type":"integer"},"created_at_unix":{"type":"integer"}}},"example":{"playlist_id":"pl5t1v8z0006s7f3xc4m2b9g","name":"Marco's Pizza, August","description":"Every cut of the August flight","is_public":false,"song_count":3,"created_at_unix":1786310400}}}},"401":{"description":"**missing_api_key**: No bearer token and no `x-api-key` header on the request.\n\n**invalid_api_key**: The key is unknown, revoked or past its expiry. All three answer identically so the endpoint cannot be used to enumerate keys.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"404":{"description":"**not_found**: No such song, lyric, persona or playlist, or one that exists but is not visible to this workspace.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"422":{"description":"**invalid_request**: The request parsed but a field is missing, out of range or the wrong type.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"429":{"description":"**rate_limited**: Too many requests from this key. `Retry-After` says how long to wait.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"500":{"description":"**internal_error**: Something broke on our side. The response never carries a stack trace.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}}}},"/usage":{"get":{"operationId":"getUsage","tags":["Account"],"summary":"Usage and allowance","description":"Seconds spent, seconds left, and when the period rolls.\n\nWhat the workspace has spent this period and what is left.\n\nREAD THIS BEFORE A BIG CALL rather than discovering the ceiling through a 429. A jingle package at three lengths and four takes is 420 seconds, and this endpoint is how a client prices that in advance.\n\nSECONDS, NOT SONGS, is the unit throughout. A :15 station ID and a three minute track cost the GPU wildly different amounts, so a per-song allowance would either overcharge the imaging customer or give the album away.\n\nUnscoped: reading your own meter is not something a key should have to be granted.","x-required-scope":null,"parameters":[{"name":"breakdown","in":"query","required":false,"description":"Include per-kind totals for the period: compose, extend, cover, stems, lyrics. Costs a second aggregate query, which is why it is opt-in.","schema":{"type":"boolean","description":"Include per-kind totals for the period: compose, extend, cover, stems, lyrics. Costs a second aggregate query, which is why it is opt-in.","default":false}}],"responses":{"200":{"description":"`seconds_remaining` can be zero while `credit_cents` is not: credit covers the invoice, not the allowance, and the two refuse differently (`insufficient_credit` is 402 and fixable now, `quota_exceeded` is 429 and waits for the boundary).","headers":{"request-id":{"description":"Identifies this call in your workspace's request log. Quote it in a support conversation and we can find the exact request.","schema":{"type":"string"}},"x-request-latency-ms":{"description":"How long we took, end to end.","schema":{"type":"string"}},"RateLimit-Limit":{"description":"Burst capacity of the token bucket this request was measured against.","schema":{"type":"string"}},"RateLimit-Remaining":{"description":"Whole requests left in that bucket. Pace against this rather than against a 429.","schema":{"type":"string"}},"RateLimit-Reset":{"description":"Seconds until the bucket is full again.","schema":{"type":"string"}},"x-key-expires-at":{"description":"Present only on keys issued with an expiry: when this key stops working, in ISO 8601.","schema":{"type":"string"}},"Sunset":{"description":"Present in the last seven days of an expiring key's life, per RFC 8594. Alert on it, because the alternative warning is a 401 that looks exactly like a typo.","schema":{"type":"string"}}},"content":{"application/json":{"schema":{"type":"object","properties":{"plan":{"type":"string"},"seconds_used":{"type":"number"},"seconds_limit":{"type":"integer"},"seconds_remaining":{"type":"number"},"period_start_unix":{"type":"integer"},"period_end_unix":{"type":"integer"},"credit_cents":{"type":"integer"},"concurrency":{"type":"integer"},"breakdown":{"type":"object","properties":{"compose":{"type":"number"},"extend":{"type":"integer"},"cover":{"type":"integer"},"stems":{"type":"integer"},"lyrics":{"type":"integer"}}}}},"example":{"plan":"creator","seconds_used":4820.5,"seconds_limit":18000,"seconds_remaining":13179.5,"period_start_unix":1785110400,"period_end_unix":1787788800,"credit_cents":2500,"concurrency":2,"breakdown":{"compose":3900.5,"extend":420,"cover":360,"stems":140,"lyrics":0}}}}},"401":{"description":"**missing_api_key**: No bearer token and no `x-api-key` header on the request.\n\n**invalid_api_key**: The key is unknown, revoked or past its expiry. All three answer identically so the endpoint cannot be used to enumerate keys.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"429":{"description":"**rate_limited**: Too many requests from this key. `Retry-After` says how long to wait.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"500":{"description":"**internal_error**: Something broke on our side. The response never carries a stack trace.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}}}}},"x-webhooks":{"song.ready":{"post":{"summary":"song.ready","description":"A compose, extend or cover finished and the master is stored. Fired once per SONG, so a request that asked for four takes fires once, with every take listed.\n\nEvery delivery carries `x-jinglebuddy-event`, `x-jinglebuddy-delivery` (a unique id, so a redelivery is recognisable), `x-jinglebuddy-timestamp` (unix seconds) and `x-jinglebuddy-signature`.\n\nThe signature is `sha256=` followed by the hex HMAC-SHA256 of `<timestamp>.<raw body>` under your webhook's secret. Verify against the RAW bytes, never against a re-serialised object: any JSON library that reorders keys or changes number formatting produces a different digest for the same delivery.\n\nReject a delivery whose timestamp is more than five minutes old, and compare digests in constant time.\n\nDeliveries are retried on any non-2xx answer, so your handler must be idempotent on `x-jinglebuddy-delivery`.","requestBody":{"content":{"application/json":{"schema":{"type":"object","properties":{"song_id":{"type":"string"},"kind":{"type":"string"},"status":{"type":"string"},"duration_ms":{"type":"integer"},"render_ms":{"type":"integer"},"seconds_billed":{"type":"number"},"audio_url":{"type":"string"}}},"example":{"song_id":"sg7k2m4p0001s7f3zq9r1d6t","kind":"client_jingle","status":"ready","duration_ms":30120,"render_ms":41200,"seconds_billed":30.12,"audio_url":"https://jinglebuddy.example/api/v1/songs/sg7k2m4p0001s7f3zq9r1d6t/audio"}}}},"responses":{"200":{"description":"Acknowledged. Anything else is retried."}}}},"song.failed":{"post":{"summary":"song.failed","description":"The render failed and the allowance was refunded. `error` is the same sentence `GET /songs/{song_id}` carries.\n\nEvery delivery carries `x-jinglebuddy-event`, `x-jinglebuddy-delivery` (a unique id, so a redelivery is recognisable), `x-jinglebuddy-timestamp` (unix seconds) and `x-jinglebuddy-signature`.\n\nThe signature is `sha256=` followed by the hex HMAC-SHA256 of `<timestamp>.<raw body>` under your webhook's secret. Verify against the RAW bytes, never against a re-serialised object: any JSON library that reorders keys or changes number formatting produces a different digest for the same delivery.\n\nReject a delivery whose timestamp is more than five minutes old, and compare digests in constant time.\n\nDeliveries are retried on any non-2xx answer, so your handler must be idempotent on `x-jinglebuddy-delivery`.","requestBody":{"content":{"application/json":{"schema":{"type":"object","properties":{"song_id":{"type":"string"},"status":{"type":"string"},"error":{"type":"string"}}},"example":{"song_id":"sg7k2m4p0001s7f3zq9r1d6t","status":"failed","error":"The music engine did not respond in time."}}}},"responses":{"200":{"description":"Acknowledged. Anything else is retried."}}}},"stems.ready":{"post":{"summary":"stems.ready","description":"A stem export finished. Every stem the separation produced is listed.\n\nEvery delivery carries `x-jinglebuddy-event`, `x-jinglebuddy-delivery` (a unique id, so a redelivery is recognisable), `x-jinglebuddy-timestamp` (unix seconds) and `x-jinglebuddy-signature`.\n\nThe signature is `sha256=` followed by the hex HMAC-SHA256 of `<timestamp>.<raw body>` under your webhook's secret. Verify against the RAW bytes, never against a re-serialised object: any JSON library that reorders keys or changes number formatting produces a different digest for the same delivery.\n\nReject a delivery whose timestamp is more than five minutes old, and compare digests in constant time.\n\nDeliveries are retried on any non-2xx answer, so your handler must be idempotent on `x-jinglebuddy-delivery`.","requestBody":{"content":{"application/json":{"schema":{"type":"object","properties":{"song_id":{"type":"string"},"stems":{"type":"array","items":{"type":"object","properties":{"kind":{"type":"string"},"size_bytes":{"type":"integer"}}}}}},"example":{"song_id":"sg7k2m4p0001s7f3zq9r1d6t","stems":[{"kind":"vocals","size_bytes":5241880}]}}}},"responses":{"200":{"description":"Acknowledged. Anything else is retried."}}}},"jingle.ready":{"post":{"summary":"jingle.ready","description":"Every song in a jingle package has landed. Fired ONCE for the package rather than per track, because a package is the deliverable: an imaging bundle is not useful three sweepers in.\n\nEvery delivery carries `x-jinglebuddy-event`, `x-jinglebuddy-delivery` (a unique id, so a redelivery is recognisable), `x-jinglebuddy-timestamp` (unix seconds) and `x-jinglebuddy-signature`.\n\nThe signature is `sha256=` followed by the hex HMAC-SHA256 of `<timestamp>.<raw body>` under your webhook's secret. Verify against the RAW bytes, never against a re-serialised object: any JSON library that reorders keys or changes number formatting produces a different digest for the same delivery.\n\nReject a delivery whose timestamp is more than five minutes old, and compare digests in constant time.\n\nDeliveries are retried on any non-2xx answer, so your handler must be idempotent on `x-jinglebuddy-delivery`.","requestBody":{"content":{"application/json":{"schema":{"type":"object","properties":{"kind":{"type":"string"},"station_profile_id":{"type":"string"},"song_ids":{"type":"array","items":{"type":"string"}},"status":{"type":"string"}}},"example":{"kind":"radio_jingle","station_profile_id":"st4v9m1q0007s7f3gt6c2y8x","song_ids":["sg7k2m4p0001s7f3zq9r1d6t"],"status":"ready"}}}},"responses":{"200":{"description":"Acknowledged. Anything else is retried."}}}}},"components":{"securitySchemes":{"bearer":{"type":"http","scheme":"bearer","description":"A workspace key issued under API Keys. Server-side only."},"apiKey":{"type":"apiKey","in":"header","name":"x-api-key","description":"The same key, for clients that cannot set an Authorization header."}},"schemas":{"Error":{"type":"object","required":["error"],"properties":{"error":{"type":"object","required":["code","message"],"properties":{"code":{"type":"string","enum":["missing_api_key","invalid_api_key","insufficient_scope","not_found","insufficient_credit","idempotency_conflict","resource_not_ready","payload_too_large","invalid_request","quota_exceeded","rate_limited","provider_unavailable","engine_unavailable","engine_timeout","internal_error"],"description":"Stable machine-readable slug. Branch on this, not on the message and not on the status: two pairs of codes share a status and mean opposite things about whether to retry."},"message":{"type":"string","description":"Written to be shown to a human."}}}}}}}}