The API

Post a prompt or a brief, poll a song id.

Everything below is generated from the same catalogue the route handlers validate against, so this page cannot describe a call we do not serve. The contract is at /api/v1/openapi.json, keyless, and keys are minted in the console on Station and Group.
Authentication

One header, minted in the console.

Keys are issued per workspace and spend that workspace's allowance. The secret is shown once at creation and we store a hash of it, so nobody here can read it back to you.
Headers
Authorization: Bearer jb_live_...
# or, for clients that cannot set Authorization
x-api-key: jb_live_...

Two things worth knowing before you build

A key is a bearer credential for a whole workspace and every render it makes is billed to that workspace. It belongs on a server, never in a browser, a mobile app or a playout box a contractor can read.

Scopes are fixed when a key is issued and are never edited afterwards. Widening one means minting a new key, which is deliberate: the jingle endpoints carry a scope of their own because one call to them can spend twenty times what one compose does.

The surface

Every endpoint, generated from the contract.

This table and the OpenAPI document are built from the same catalogue the route handlers import, so it cannot list a path we do not serve or omit one we do.
Songs
MethodPathWhat it does
POST/api/v1/songs
music
Compose a song
Text to music, with sung vocals or instrumental.
GET/api/v1/songs
List songs
The workspace's catalogue, newest first, cursor-paginated.
GET/api/v1/songs/{song_id}
Get a song
One song in full, with its takes and stems.
DELETE/api/v1/songs/{song_id}
library
Delete a song
Forget a song, its takes, its stems and its audio.
GET/api/v1/songs/{song_id}/audio
Download the audio
The rendered master, as bytes.
GET/api/v1/songs/{song_id}/stems
List stems
What has been separated out of this track so far.
POST/api/v1/songs/{song_id}/stems
stems
Export stems
Separate a finished track into its parts.
POST/api/v1/songs/{song_id}/extend
music
Extend a song
Continue a finished track past its ending.
POST/api/v1/songs/{song_id}/cover
music
Cover a song
Re-render an existing track in another style.
Lyrics
MethodPathWhat it does
POST/api/v1/lyrics
lyrics
Write lyrics
Words to sing, written to a brief and a length.
Jingles
MethodPathWhat it does
POST/api/v1/jingles/client
jingles
Client jingle
An advertiser's jingle, from the brief, cut to broadcast lengths.
POST/api/v1/jingles/radio
jingles
Radio imaging
Station IDs, sweepers and beds, sung to the station's own facts.
Library
MethodPathWhat it does
GET/api/v1/personas
List personas
Saved style identities.
POST/api/v1/personas
library
Create a persona
Save a style identity for reuse.
GET/api/v1/playlists
List playlists
Saved collections of songs.
POST/api/v1/playlists
library
Create a playlist
Collect songs under one name.
Account
MethodPathWhat it does
GET/api/v1/usage
Usage and allowance
Seconds spent, seconds left, and when the period rolls.
A real request

3 calls, one for each kind of thing you can ask for.

The first composes from a description, from style tags or from words you supply, and it is the one to reach for when what you want is a track. The other two take a brief's facts as fields rather than as hopeful sentences in a prompt, which is the part a free text request keeps getting wrong when the phone number or the dial position has to come back. Every sample below is generated from the same parameter definitions the route validates against.
POST/api/v1/songsCompose a song

Text to music, with sung vocals or instrumental.

Request
curl -X POST 'https://jinglebuddy.radioworkflow.com/api/v1/songs' \
  -H "Authorization: Bearer $JINGLEBUDDY_API_KEY" \
  -H 'Content-Type: application/json' \
  -H 'Idempotency-Key: 6f9619ff-8b86-d011-b42d-00c04fc964ff' \
  -d '{
  "prompt": "Warm family pizzeria jingle, singable hook, upbeat and friendly",
  "kind": "song",
  "style_tags": [
    "uptempo pop",
    "bright horns",
    "female vocal"
  ],
  "instrumental": false,
  "duration_sec": 30,
  "takes": 1,
  "is_public": false
}' \
  --max-time 900
Response 202
{
  "song_id": "sg7k2m4p0001s7f3zq9r1d6t",
  "status": "queued",
  "job_id": "jb6c2n8t0001s7f3wd5y4q3l",
  "poll_url": "https://jinglebuddy.example/api/v1/songs/sg7k2m4p0001s7f3zq9r1d6t",
  "seconds_claimed": 30,
  "estimated_wait_sec": 90
}
Can also answer422 invalid_request404 not_found429 quota_exceeded402 insufficient_credit409 idempotency_conflict413 payload_too_large503 provider_unavailable502 engine_unavailable504 engine_timeout
POST/api/v1/jingles/clientClient jingle

An advertiser's jingle, from the brief, cut to broadcast lengths.

Request
curl -X POST 'https://jinglebuddy.radioworkflow.com/api/v1/jingles/client' \
  -H "Authorization: Bearer $JINGLEBUDDY_API_KEY" \
  -H 'Content-Type: application/json' \
  -H 'Idempotency-Key: 6f9619ff-8b86-d011-b42d-00c04fc964ff' \
  -d '{
  "business_name": "Marco'\''s Pizza",
  "lengths": [
    15,
    30,
    60
  ],
  "website": "marcospizza.com",
  "style_tags": [
    "uptempo pop",
    "bright horns",
    "female vocal"
  ],
  "takes": 1,
  "save_brief": true
}' \
  --max-time 900
Response 202
{
  "kind": "client_jingle",
  "client_brief_id": "cb8j2p6d0005s7f3rn3c7v4a",
  "lyric_id": "ly9d4b6y0002s7f3ln5r7w1k",
  "missing_phrases": [],
  "songs": [
    { "song_id": "sg7k2m4p0001s7f3zq9r1d6t", "duration_sec": 30, "status": "queued" }
  ],
  "seconds_claimed": 30,
  "poll_url": "https://jinglebuddy.example/api/v1/songs?kind=client_jingle"
}
Can also answer422 invalid_request404 not_found413 payload_too_large429 quota_exceeded402 insufficient_credit409 idempotency_conflict503 provider_unavailable502 engine_unavailable504 engine_timeout
POST/api/v1/jingles/radioRadio imaging

Station IDs, sweepers and beds, sung to the station's own facts.

Request
curl -X POST 'https://jinglebuddy.radioworkflow.com/api/v1/jingles/radio' \
  -H "Authorization: Bearer $JINGLEBUDDY_API_KEY" \
  -H 'Content-Type: application/json' \
  -H 'Idempotency-Key: 6f9619ff-8b86-d011-b42d-00c04fc964ff' \
  -d '{
  "station_name": "K H I T",
  "dial_position": "101.5",
  "station_format": "country",
  "package": [
    "id",
    "sweeper",
    "promo_bed"
  ],
  "lengths": [
    10,
    30
  ],
  "style_tags": [
    "uptempo pop",
    "bright horns",
    "female vocal"
  ],
  "takes": 1,
  "save_profile": true
}' \
  --max-time 900
Response 202
{
  "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"
}
Can also answer422 invalid_request404 not_found413 payload_too_large429 quota_exceeded402 insufficient_credit409 idempotency_conflict503 provider_unavailable502 engine_unavailable504 engine_timeout
Asynchronous by design

A compose answers with an id, not with audio.

Music is not text to speech. A render runs on a GPU we do not hold open, so the honest answer to whether it is done is a second request.

Queued can mean waking

The engine sleeps when nobody is using it. The first render after roughly fifteen idle minutes spends 30 to 90 seconds waking up before the render starts, and it sits in queued for all of it. That is why queued and rendering are separate states: collapsing them would make every cold start read as a hang.

Poll every five seconds

Read the song id back until its status leaves queued or rendering. A one second loop is a hundred pointless requests before the answer is even possible, and the rate limiter will start refusing them first. A compose is allowed to run for minutes on our side, so set a client timeout that can wait for it.

Or do not poll

Webhooks fire when a render lands and when a whole package does. For a twenty piece imaging package that is the difference between one subscription and four hundred polls. A compose endpoint cannot fire its own completion event, because the interesting moment happens after the response.

Retry safety

Everything that spends takes an idempotency key.

A render can legitimately outlast a client's timeout, which is comfortably past the point where proxies retry and people press the button again. A retry must not bill twice.

Idempotency-Key

For example: 6f9619ff-8b86-d011-b42d-00c04fc964ff

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.

What 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.

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.

This 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.

Retained 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.

1 to 255 printable ASCII characters; a UUID per logical request is the usual choice. Omit the header entirely and nothing changes.

Errors

One envelope, and a code you can branch on.

Every failure answers with the same envelope. The HTTP status carries the category and error.code is the stable slug you branch on, which matters because 3 of these statuses carry more than one code (401, 409 and 429). A client that branches on the status alone will eventually turn a hard stop into an infinite retry loop.
StatusCodeWhat happenedWhat to do
401missing_api_key
Any endpoint

No bearer token and no x-api-key header on the request.

Send the key as Authorization: Bearer jb_live_..., or as x-api-key. Nothing about the request changes on a retry.

401invalid_api_key
Any endpoint

The key is unknown, revoked or past its expiry. All three answer identically so the endpoint cannot be used to enumerate keys.

Check the secret was copied whole, then check the key is still live under API Keys. If it expired, issue a new one: x-key-expires-at and Sunset warn before this happens.

403insufficient_scope

The key is valid but was not granted the scope this endpoint needs.

Scopes are fixed when a key is issued. Create a new key carrying the scope named in the message; there is nothing to change on the request.

404not_found

No such song, lyric, persona or playlist, or one that exists but is not visible to this workspace.

Re-read the id from the endpoint that produced it. A record belonging to another workspace answers this rather than 403, so the two are indistinguishable from outside.

402insufficient_credit

The workspace has spent its plan allowance for the period and has no account credit left to cover the rest.

Unlike quota_exceeded this one CAN be fixed without waiting for the period boundary: add credit or move to a larger plan, then retry the identical request. GET /usage publishes both numbers so a caller can price a render before spending it.

409idempotency_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.

If Retry-After is present the twin is still in flight: wait that long and send the identical request again, which answers with the first one's result. Without it the first result is gone for good, and only a NEW key will render again.

409resource_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.

Poll GET /songs/{song_id} until status is ready, then ask again. Deliberately not a 404: the id is valid, so a client that treats this as gone stops polling something that is about to succeed. Composing is 30 to 90 seconds against a cold engine, so poll every 5 seconds, not every 500ms.

413payload_too_large

The request body is over this endpoint's ceiling.

Shorten the lyric or the prompt. The bounds are published on every field, so the same bytes will never be accepted.

422invalid_request

The request parsed but a field is missing, out of range or the wrong type.

Read error.message: it names the field and the bound it broke. Fix the payload, because a retry sends the same body.

429quota_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.

Terminal until the period rolls, and Retry-After says how long that is. GET /usage carries period_end_unix and the seconds left. NO UPGRADE SHORTENS THE WAIT: a bigger plan raises the ceiling next period, it does not refill this one. Do not retry, and do not treat this 429 like the other one: tell them apart with error.code.

429rate_limited
Any endpoint

Too many requests from this key. Retry-After says how long to wait.

Sleep for Retry-After seconds and retry: this one is safe to retry. Pace against RateLimit-Remaining to stay out of it.

503provider_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.

Two 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.

Nothing about the request can fix it, so retrying is pointless until an operator acts. Render error.message rather than re-typing a variable list.

502engine_unavailable

The music engine refused or could not be reached.

Retry once after a short pause. If it persists the engine itself is down and no change to the payload will help.

504engine_timeout

The engine did not answer in time. Usually a compose that landed on a GPU instance still loading the model.

Retry once, with a client timeout of 900 seconds so a legitimate 30 to 90 second cold start has room to land. Never retry in a loop. The engine runs scale-to-zero by an explicit owner decision, so the FIRST render after fifteen idle minutes is the slow one and the retry usually lands on a warm instance.

500internal_error
Any endpoint

Something broke on our side. The response never carries a stack trace.

Retry once. If it persists, quote the request-id header: the detail is in our log and deliberately not in your response.

Limits

The numbers your client has to know about.

Shortest render
10s

The engine's floor. A request under it is refused rather than silently clamped, because a caller who asked for five seconds and received ten has no field telling them why.

Longest render
600s

The engine's ceiling. Your plan's single track limit may be lower.

Billed unit
1 second

Of rendered audio, not one song. A jingle package at three lengths and four takes is priced on the response before you commit.

Concurrent renders, per plan

PlanAt onceAPI
Free1Not on this plan
Creator2Not on this plan
Station4Included
Group8Included

Concurrency is a card count

Generation holds a GPU for the length of the render, so the numbers beside each plan are a real resource rather than a throttle we invented. A request over the ceiling waits rather than failing, which is worth knowing before you fan out a package from a cron job.

What happens when the allowance runs out

Renders stop and the call answers with a code you can branch on, which is why the error table above is worth reading before you write the retry. The refusal happens before the engine is called, so nothing is stored and nothing is billed. The billing side of it, including the refund rule for a render that failed on our side, is on the pricing page.

Read it first

The whole contract, before you sign up for anything.

The OpenAPI document is generated from the same catalogue this page reads, so it cannot describe an endpoint differently from the table above. Point a generator, a REST client or a coding agent at https://jinglebuddy.radioworkflow.com/api/v1/openapi.json. No key required, and no account.