{"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":"Review"},{"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.\n\nIT SURVIVES THE ANCHOR LEAVING THE FILTER, which is the ordinary case and not an edge one: page `?status=queued`, and the song you anchored on has very likely finished rendering by the time you ask for the next page. Paging resumes from exactly where that song sat, handing you the next row and not the one after it, so a sweep is not restarted every time the set it is walking changes underneath it. The same holds for a `?shared=live` link that lapses or is revoked mid-run.\n\nA 422 NOW MEANS THE CURSOR CANNOT BE USED, and there are three ways to get one. Two are yours: an id naming no song in THIS workspace, and an id naming one of a different `kind` from the page you asked for, which is two sweeps getting mixed up. The third is not, and is worth planning for: the song you anchored on was DELETED while you were paging. Nothing can resume from a row that no longer has a position, so that one means restart the sweep. All three have to be an error rather than an empty page, because an empty page is indistinguishable from the end of the list.\n\nTHE `kind` GUARD ONLY WORKS IN ONE DIRECTION, because it can only test what you send. Sending a `?kind=bed` cursor to a `?kind=song` page is caught; sending it to a page with NO `kind` is not, and answers with the catalogue older than that bed rather than an error. Send the same filters you paged with.\n\nWHAT IT CANNOT DO is deliver a song that moves INTO the filtered set ABOVE your cursor while you are paging. Nothing keyed on a position can, so a run answers for the set as it stood when the run reached each row. Run it again for whatever crossed the line in between.","schema":{"type":"string","description":"The cursor: `last_song_id` from the previous page.\n\nIT SURVIVES THE ANCHOR LEAVING THE FILTER, which is the ordinary case and not an edge one: page `?status=queued`, and the song you anchored on has very likely finished rendering by the time you ask for the next page. Paging resumes from exactly where that song sat, handing you the next row and not the one after it, so a sweep is not restarted every time the set it is walking changes underneath it. The same holds for a `?shared=live` link that lapses or is revoked mid-run.\n\nA 422 NOW MEANS THE CURSOR CANNOT BE USED, and there are three ways to get one. Two are yours: an id naming no song in THIS workspace, and an id naming one of a different `kind` from the page you asked for, which is two sweeps getting mixed up. The third is not, and is worth planning for: the song you anchored on was DELETED while you were paging. Nothing can resume from a row that no longer has a position, so that one means restart the sweep. All three have to be an error rather than an empty page, because an empty page is indistinguishable from the end of the list.\n\nTHE `kind` GUARD ONLY WORKS IN ONE DIRECTION, because it can only test what you send. Sending a `?kind=bed` cursor to a `?kind=song` page is caught; sending it to a page with NO `kind` is not, and answers with the catalogue older than that bed rather than an error. Send the same filters you paged with.\n\nWHAT IT CANNOT DO is deliver a song that moves INTO the filtered set ABOVE your cursor while you are paging. Nothing keyed on a position can, so a run answers for the set as it stood when the run reached each row. Run it again for whatever crossed the line in between."}},{"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."}},{"name":"shared","in":"query","required":false,"description":"Only songs carrying a share link, narrowed to whether that link has expired. `shared=live` is the inventory: every track in this workspace whose share link has not run out, which is the set of URLs still out in the world. `shared=expired` is the ones whose window has closed, and it is a different list from `GET /songs/{song_id}/review`'s `needs_renew`: this one does not ask what the client said, so it includes the approved spots whose link simply lapsed on schedule.\n\nIT ASKS ABOUT THE LINK AND NOT ABOUT THE TRACK, which is the limit to read before trusting it. It tests the token and the expiry date, exactly as the console's own Share panel does, and does NOT re-check that the render finished and left bytes behind. No path rolls a ready song back today, so the two coincide; the split is the guard for one that might, and a track shared while ready and later rolled back would still be listed here on purpose, because it is still a URL somebody was sent.\n\nIT IS THE INVENTORY OF WHAT IS STILL REACHABLE, NOT AN AUDIT OF WHAT WAS MINTED, and the difference matters if you are chasing a key. Revoking a link nulls the token, no token is ever written to the audit log, and nothing records who minted one, so a revoked link appears under neither value and an empty answer means \"nothing is reachable now\" rather than \"nothing was ever handed out\".\n\nIT NEEDS NO SCOPE because it publishes nothing new. `GET /songs/{song_id}/review` is unscoped and already answers `share.state` for one song, and `GET /songs` already lists every song id, so a key with no grants at all can assemble this same answer one call at a time. What this changes is the COST and not the answer: one call instead of one per song, against a per-key rate limit those calls would otherwise spend. A throttle is not an authorization boundary, which is why it does not turn into a reason for a scope. It still publishes no addresses: a track's own token is returned only by the three methods on `/songs/{song_id}/share`, which do carry the `share` scope.\n\nIT MOVES UNDER A CURSOR, which `kind` does not and `status` already does. A song leaves `shared=live` when its link lapses or an operator revokes it, and the revoke is the likely one because it takes a person rather than a clock. Leaving the set does NOT break your cursor: `start_after_song_id` still resumes from exactly where that song sat, and a 422 means you sent a cursor from somewhere else rather than that your sweep is over. The half no cursor can cover is the other direction, and `shared=expired` is where you meet it, because that set only ever grows: a link lapsing ABOVE your cursor mid-run is never delivered to that run. So page it in one sitting and run it again tomorrow for whatever crossed the line today.","schema":{"type":"string","enum":["live","expired"],"description":"Only songs carrying a share link, narrowed to whether that link has expired. `shared=live` is the inventory: every track in this workspace whose share link has not run out, which is the set of URLs still out in the world. `shared=expired` is the ones whose window has closed, and it is a different list from `GET /songs/{song_id}/review`'s `needs_renew`: this one does not ask what the client said, so it includes the approved spots whose link simply lapsed on schedule.\n\nIT ASKS ABOUT THE LINK AND NOT ABOUT THE TRACK, which is the limit to read before trusting it. It tests the token and the expiry date, exactly as the console's own Share panel does, and does NOT re-check that the render finished and left bytes behind. No path rolls a ready song back today, so the two coincide; the split is the guard for one that might, and a track shared while ready and later rolled back would still be listed here on purpose, because it is still a URL somebody was sent.\n\nIT IS THE INVENTORY OF WHAT IS STILL REACHABLE, NOT AN AUDIT OF WHAT WAS MINTED, and the difference matters if you are chasing a key. Revoking a link nulls the token, no token is ever written to the audit log, and nothing records who minted one, so a revoked link appears under neither value and an empty answer means \"nothing is reachable now\" rather than \"nothing was ever handed out\".\n\nIT NEEDS NO SCOPE because it publishes nothing new. `GET /songs/{song_id}/review` is unscoped and already answers `share.state` for one song, and `GET /songs` already lists every song id, so a key with no grants at all can assemble this same answer one call at a time. What this changes is the COST and not the answer: one call instead of one per song, against a per-key rate limit those calls would otherwise spend. A throttle is not an authorization boundary, which is why it does not turn into a reason for a scope. It still publishes no addresses: a track's own token is returned only by the three methods on `/songs/{song_id}/share`, which do carry the `share` scope.\n\nIT MOVES UNDER A CURSOR, which `kind` does not and `status` already does. A song leaves `shared=live` when its link lapses or an operator revokes it, and the revoke is the likely one because it takes a person rather than a clock. Leaving the set does NOT break your cursor: `start_after_song_id` still resumes from exactly where that song sat, and a 422 means you sent a cursor from somewhere else rather than that your sweep is over. The half no cursor can cover is the other direction, and `shared=expired` is where you meet it, because that set only ever grows: a link lapsing ABOVE your cursor mid-run is never delivered to that run. So page it in one sitting and run it again tomorrow for whatever crossed the line today."}}],"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"}}}}}}},"/songs/{song_id}/share":{"post":{"operationId":"createShareLink","tags":["Review"],"summary":"Share a track for approval","description":"A link the client opens with no account, and answers on.\n\nHands back a URL that plays this track for anybody holding it: no account, no sign in, and a form to approve it or ask for changes. That answer comes back on `GET /songs/{song_id}/review` and on the `song.reviewed` webhook.\n\nTHE PAGE SHOWS THE TRACK AND NOTHING ELSE. The title, the player, the style tags and the length. No lyrics, no prompt, no caption, no seed, no model, no takes, no stems, no author and not the name of your workspace. A link handed to one person tends to reach several, and everything on that list is yours rather than theirs.\n\nIDEMPOTENT, AND `created` IS WHAT TELLS YOU. Calling this twice does not mint a second link and does not invalidate the first: the URL that went out in your email is the URL that comes back, and `expires_in_days` IS IGNORED on that path, because quietly moving the death date of a link somebody is already holding is not something a call saying \"share\" should do. `created: false` with an `expires_at_unix` you did not ask for is that case.\n\nSO READ `state` BEFORE YOU MAIL THE URL. The link handed back may have ALREADY EXPIRED: a track shared forty days ago and never revoked answers 200 with `created: false`, `state: \"expired\"` and the same address, because that address is still the one in the client's mailbox and renewing is what puts it back to work. Mailing it without looking sends an advertiser a 404. `PATCH` this path first, and the URL does not change.\n\nSCOPED ON ITS OWN. A share token is the only capability in this product with nothing behind it but the URL, so it is a grant a key gets deliberately rather than one that rides along with rendering.\n\nSHARING AGAIN RETIRES THE PREVIOUS ANSWER. Revoke, then share, mints a NEW link, and any verdict on file was given on the old one: it is kept (the note is what take two gets cut from) and marked, which is what `is_current` reports on the review read.","x-required-scope":"share","x-webhook-events":["song.reviewed"],"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`."}}],"requestBody":{"required":false,"content":{"application/json":{"schema":{"type":"object","properties":{"expires_in_days":{"type":["integer","null"],"enum":[7,30,90,null],"description":"How long the link stays live, counted FROM NOW rather than from any date already on it. `null` means it never expires, which is a deliberate choice rather than the default: an imaging package a programme director refers back to all year is the case it exists for. Omitting the field asks for 30 days.","default":30}}}}}},"responses":{"200":{"description":"`state` is `live`, `expired` or `none`. `expires_at_unix` is null when the link never expires AND when there is no link, so read `state` rather than inferring from it. `created` is false when an existing link was handed back 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"}}},"content":{"application/json":{"schema":{"type":"object","properties":{"song_id":{"type":"string"},"created":{"type":"boolean"},"state":{"type":"string"},"expires_at_unix":{"type":"integer"},"share_url":{"type":"string"}}},"example":{"song_id":"sg7k2m4p0001s7f3zq9r1d6t","created":true,"state":"live","expires_at_unix":1788902400,"share_url":"https://jinglebuddy.example/s/k3Qk8xV2p1sT7bN0wR4mJyLh6cD9zF2a"}}}},"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"}}}},"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"}}}}}},"patch":{"operationId":"renewShareLink","tags":["Review"],"summary":"Renew a share link","description":"Push the death date out without changing the address.\n\nMoves when the link stops working, keeping the URL exactly as it is.\n\nTHIS IS WHAT AN EXPIRED LINK NEEDS. The token is still on the track and still in the client's mailbox, so pushing the date out makes the address they already have work again. Re-issuing would mint nothing (`POST` is idempotent) and rotating means revoking, which kills the URL somebody else is holding.\n\n`token` IS A CONCURRENCY GUARD, NOT A CREDENTIAL. Your key and its scope are the authorization; this says WHICH link you meant. Without it a renew sent after somebody in the workspace revoked and issued again would land on the new client's link, answer 200, and hand you a fresh date beside the stale address you were about to mail. It is the last path segment of the `share_url` a `POST` gave you.\n\nTHE LIFETIME IS COUNTED FROM NOW. `expires_in_days: 30` on a link with three days left adds twenty seven, not thirty.","x-required-scope":"share","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`."}}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","properties":{"token":{"type":"string","maxLength":200,"description":"The link you are renewing: the last path segment of `share_url`. A token that is no longer the live one answers 404 rather than silently renewing whatever is."},"expires_in_days":{"type":["integer","null"],"enum":[7,30,90,null],"description":"How long the link stays live, counted FROM NOW rather than from any date already on it. `null` means it never expires, which is a deliberate choice rather than the default: an imaging package a programme director refers back to all year is the case it exists for. Required here: this method exists to set the date, so there is nothing sensible to assume."}},"required":["token","expires_in_days"]}}}},"responses":{"200":{"description":"The same body the share endpoint answers with, and `created` is always false: this method never mints. A 404 means the token you named is not the live link on that song.","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"},"created":{"type":"boolean"},"state":{"type":"string"},"expires_at_unix":{"type":"integer"},"share_url":{"type":"string"}}},"example":{"song_id":"sg7k2m4p0001s7f3zq9r1d6t","created":false,"state":"live","expires_at_unix":1791494400,"share_url":"https://jinglebuddy.example/s/k3Qk8xV2p1sT7bN0wR4mJyLh6cD9zF2a"}}}},"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"}}}}}},"delete":{"operationId":"revokeShareLink","tags":["Review"],"summary":"Revoke a share link","description":"Kill the link. The page stops resolving immediately.\n\nTurns the link off. The next request to it answers 404, with nothing cached in between and nothing said about why: a page that admitted the link was once real would tell whoever holds the URL that they had the right one.\n\nONE GRACE PERIOD, and it is worth knowing rather than discovering. The audio itself is served through a signed URL that lives an hour, so somebody with the page already open can finish the track. What stops is opening the page and posting a verdict.\n\nA NO-OP ON A TRACK WITH NO LINK, answering 200 rather than 404: you asked for a state and that state is what you get. 404 here means a song id this workspace does not own.\n\nIT DOES NOT DELETE THE CLIENT'S ANSWER. A verdict is yours to keep, and the note is what the next take gets cut from. If that answer had been marked as history by a re-share, revoking promotes it back to current, on the reasoning that killing a link without issuing another leaves the standing answer standing: nobody has been asked a second question.","x-required-scope":"share","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 state this call left behind, which is always `none`. Revoking twice answers the same thing. It does not re-read the row afterwards, so it says nothing about a link somebody else minted in the meantime.","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"},"state":{"type":"string"},"expires_at_unix":{},"share_url":{}}},"example":{"status":"ok","song_id":"sg7k2m4p0001s7f3zq9r1d6t","state":"none","expires_at_unix":null,"share_url":null}}}},"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}/review":{"get":{"operationId":"getSongReview","tags":["Review"],"summary":"Read the client's answer","description":"Approved, changes requested, or nobody has replied yet.\n\nWhat the person holding the share link said about this track, and where the link itself stands.\n\nTHIS IS NOT `status`. `GET /songs/{song_id}` reports whether the GPU finished; this reports whether the advertiser is happy, and no combination of the status values can ask that.\n\n`review` IS NULL WHEN NOBODY HAS ANSWERED. There is no third verdict on the wire, because \"awaiting\" is the ABSENCE of an answer, which is a state nothing can contradict.\n\nREAD `is_current`, NOT JUST `verdict`. An answer survives a re-share: if the workspace cut another take and sent a new link, the verdict on file was given on the old one and `is_current` is false, which means somebody is being asked again and has not replied. A dashboard that paints \"approved\" off `verdict` alone will keep saying so while a brand new client sits on a fresh link.\n\nAND READ `on_current_take` BESIDE IT, because a verdict is about AUDIO as well as about a link and the two go stale separately. Promoting a different take makes it the master under a share link that is ALREADY OUT, on the same address, with nothing sent and nothing to renew, so `is_current` stays true and the answer on file is about a mix the client has never heard. False means exactly that. `null` means the question could not be asked (the take was not recorded, which is every verdict older than this field, or the track has no master), and it is NOT a \"no\": anything gating an on-air handoff should require `true` rather than \"not false\". IT IS A PAGE RENDER, NOT A LISTEN, the same limit `share.opened` carries below: the answer is tied to the master the review page SERVED, and a reviewer who reloads after a change and approves without pressing play sets `true` over audio they have not heard. It rules out the stale answer, not the inattentive one. The two fields are independent, so `is_current: false` beside `on_current_take: true` is a real combination and means a new client is sitting on a new link that plays the same audio the last one answered about.\n\n`needs_renew` IS THE ONE COMBINATION WORTH NAMING: waiting on a client who can no longer answer, because the link ran out. A live link takes a nudge; this takes a `PATCH` first, and nudging somebody toward a URL that 404s wastes both ends of the call.\n\n`share.opened` IS THE THIRD THING TO TRIAGE ON, and it is the one that changes WHO you chase rather than when. It is per track, like the rest of this endpoint, so it is a field to read once you have a song id and not a way to find one; the limit below applies to it in full, and there is no webhook for a link being opened. It is `opened`, `unopened`, `unrecorded` or `none`. A track waiting on somebody who has never once opened the link is usually not a slow client: it is a mistyped address, a spam filter, or a mail that was drafted and never sent, and ringing the client fixes none of those.\n\nBRANCH ON `opened`, NEVER ON `opened_at_unix` ALONE. That timestamp is null for two different reasons: nobody has opened the link, and the link predates this field so nothing was recorded. `unrecorded` is what separates them, and a caller reading the timestamp by itself will report a whole back catalogue as undelivered on the day this ships.\n\nIT IS A PAGE FETCH, NOT A LISTEN, AND IT IS NOISY IN BOTH DIRECTIONS. A mail scanner that follows links, or a chat client unfurling the URL, sets it just as a client does, so `opened` means \"something fetched this\" rather than \"somebody heard the spot\". In the other direction, opens by YOUR OWN PEOPLE ARE NOT RECORDED: a visit from a user with a seat in the workspace of MEMBER or above, or from platform staff, is deliberately skipped, because otherwise the operator who checks a link before mailing it would permanently mark every link as reached. So a track can read `unopened` after a colleague has opened it. Both limits are small and neither is zero; treat this as evidence about DELIVERY rather than as an audit of who has seen the track.\n\nNO WAITING PERIOD IS APPLIED HERE. The console waits three days before captioning a track, because a flag lit for every link in its first hour is one nobody reads. This is the raw fact, so your own queue picks its own patience.\n\nUNSCOPED, unlike the three methods on `/share`, because this is your own catalogue and carries no token. The address is the capability and it is published only where the `share` grant applies.\n\nONE TRACK AT A TIME, and that is a real limit rather than an oversight: there is no review filter on `GET /songs`, so \"which of my two hundred are still awaiting a reply\" is two hundred calls. Take the `song.reviewed` webhook instead and the answer comes to you as it lands.","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":"`review` is null until somebody answers. `share.state` is `live`, `expired` or `none`, and a track that was never shared has no answer to wait for. `share.opened` is `opened`, `unopened`, `unrecorded` or `none`; branch on that rather than on `opened_at_unix`, which is null in two of those four cases.","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"},"share":{"type":"object","properties":{"state":{"type":"string"},"expires_at_unix":{"type":"integer"},"opened":{"type":"string"},"opened_at_unix":{"type":"integer"}}},"review":{"type":"object","properties":{"verdict":{"type":"string"},"is_current":{"type":"boolean"},"reviewer_name":{"type":"string"},"note":{"type":"string"},"answered_at_unix":{"type":"integer"},"superseded_at_unix":{},"on_current_take":{"type":"boolean"}}},"needs_renew":{"type":"boolean"}}},"example":{"song_id":"sg7k2m4p0001s7f3zq9r1d6t","share":{"state":"live","expires_at_unix":1788902400,"opened":"opened","opened_at_unix":1786310400},"review":{"verdict":"changes_requested","is_current":true,"reviewer_name":"Marco","note":"Love it. Can we hear the phone number twice?","answered_at_unix":1786396800,"superseded_at_unix":null,"on_current_take":true},"needs_renew":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"}}}},"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"}}}}}}},"/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. `invented_facts` lists numbers the writer made up, which have ALREADY been removed from `body` before it was saved: a jingle may only sing a number you supplied, so a non-empty list means a line was cut and the draft is worth reading before it is rendered. Songs are exempt, since a number in a song is writing rather than a claim about a business.","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":{}},"invented_facts":{"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":[],"invented_facts":[]}}}},"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.\n\nTwo audit fields, both checked against the written words rather than reported by the writer. `missing_phrases` is a `must_say` entry that did not land. `invented_facts` is the opposite failure and the more dangerous one: a number in the lyric that no field of this brief supplied, already removed from the words before they were rendered. Treat either as a package to read before it airs.","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":{}},"invented_facts":{"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":[],"invented_facts":[],"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 on-air name, spelled the way it should be SUNG. Required in every sung piece, and singing the `call_letters` satisfies it, so a :10 with room for the calls and the frequency and nothing else is still a correct ID."},"call_letters":{"type":"string","maxLength":40,"description":"The legal identification, spelled the way it should be SUNG: `K H I T` sings as four letters, `KHIT` may not. Both spellings are accepted in the finished lyric whichever way you send it. Wanted rather than required, because brand plus spelled calls plus a frequency is more than a :10 can sing."},"dial_positions":{"oneOf":[{"type":"array","items":{"type":"string","maxLength":60},"maxItems":6},{"type":"string","description":"The same list, comma separated."}],"description":"Every frequency this signal is on, best first. Strings rather than numbers because the sung form is what matters and half of these are not decimals. ONLY THE FIRST IS CHECKED in the finished lyric; the rest are sung where the length allows, so listing four frequencies does not make your :10 shotgun ID impossible."},"dial_position":{"type":"string","maxLength":60,"description":"The singular form, still accepted, equivalent to sending `dial_positions` with one entry. Ignored when `dial_positions` is present."},"networks":{"oneOf":[{"type":"array","items":{"type":"string","maxLength":120},"maxItems":6},{"type":"string","description":"The same list, comma separated."}],"description":"What identifies a signal carried on a network rather than a frequency of its own, or as well as one. Checked in the finished lyric the same way a frequency is, so imaging for a syndicated feed identifies itself without claiming a dial position it does not have. When there is no `dial_positions` entry, the first network here is the carrier every piece must sing."},"network":{"type":"string","maxLength":120,"description":"The singular form, still accepted, equivalent to sending `networks` with one entry. Ignored when `networks` is present."},"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."}}}},"song.reviewed":{"post":{"summary":"song.reviewed","description":"Somebody holding a share link answered on the track: approved, or asked for changes. The only event in this list a customer's own pipeline did not cause, and the only one whose subject is an opinion rather than a render, so it carries no audio facts. `reviewer_name` is whatever the reviewer typed on a page that verifies nothing about who they are: render it as a caption, never treat it as an identity. A reviewer who changes their mind fires this again with the new verdict.\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"},"verdict":{"type":"string"},"reviewer_name":{"type":"string"},"note":{"type":"string"},"song_url":{"type":"string"}}},"example":{"song_id":"sg7k2m4p0001s7f3zq9r1d6t","verdict":"changes_requested","reviewer_name":"Dana at Rivera Motors","note":"Love it. Can we hear the phone number twice?","song_url":"https://jinglebuddy.example/api/v1/songs/sg7k2m4p0001s7f3zq9r1d6t"}}}},"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."}}}}}}}}