API reference

The BlueMetal Music API

One JSON endpoint turns a prompt into a finished song. Async by default, OpenAI-shaped errors, presigned audio, and a price of $0.0095 per song.

3-minute quickstart#

Base URL https://api.bluemetal.ai. JSON in, JSON out, UTF-8. Every response carries an x-request-id. Prefer to click before you type? The playground runs exactly these calls.

1

Get a key

Sign in on the dashboard and create an API key. It is shown exactly once — we store only its SHA-256 hash — so copy it straight into your secret store.

export BLUEMETAL_API_KEY="bm_live_…"
2

Ask for a song

Send one prompt. Musical direction and lyrics are prepared automatically. You get a generation id back immediately, along with a queue position.

curl https://api.bluemetal.ai/v1/music/generations \
  -H "Authorization: Bearer $BLUEMETAL_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
        "prompt": "Lo-fi jazz hip-hop about a rainy afternoon, with mellow Rhodes piano and soft female vocal"
      }'
3

Poll until it is done

Read the generation until status is succeeded. audio.url is a presigned R2 link valid for 24 hours; re-reading the generation mints a fresh one. Objects are kept for 30 days.

curl https://api.bluemetal.ai/v1/music/generations/gen_01JABCDEF \
  -H "Authorization: Bearer $BLUEMETAL_API_KEY"

Authentication#

Server-to-server calls use a bearer key: Authorization: Bearer bm_live_…. Keys are created on the dashboard and shown exactly once — we store only sha256(key), so a lost key is replaced, never recovered. Never put one in client-side code.

The dashboard and playground use a session cookie instead. There are no passwords: sign in with GitHub, or ask for an email magic link that is valid for 15 minutes and can be used once.

Apps that act for other people, such as AI assistants, connect with OAuth 2.1 instead of asking for a key: authorization code with required S256 PKCE, scope music, one-hour access tokens and rotating refresh tokens. Register a client at POST /oauth/register; every endpoint is listed at https://api.bluemetal.ai/.well-known/oauth-authorization-server. An access token works wherever a key does, and people can disconnect an app from their dashboard at any time. An agent can add credit with POST /v1/credits/purchase: it answers 402 with a Machine Payments Protocol challenge, the person approves the amount in their Stripe Link wallet, and the agent retries with the token in Payment-Authorization. Keys and tokens never touch a saved card.

A key belongs to one account and carries its credit. Revoking a key on the dashboard takes effect immediately — anything still using it starts getting 401.

Generation lifecycle#

A generation moves queued → running → succeeded, failed or canceled. Songs end naturally. Generation time varies with the song and current demand.

StatusWhat it means
queuednon-terminalAccepted and waiting to start. queue_position is a best-effort hint. Cancellable.
runningnon-terminalGenerating. No longer cancellable; a cancel returns 409.
succeededterminalaudio and usage are populated. audio.url is a presigned R2 GET valid for 24 hours; re-read the generation to mint a fresh one. Objects are deleted after 30 days.
failedterminalerror explains why. Retryable worker failures are already retried once on a different machine before you see this.
canceledterminalYou cancelled it while it was still queued. Nothing was billed.

Poll with backoff — start at a second and grow to about five, well inside the 600 reads/minute limit. If you would rather not poll at all, pass a webhook_url and we will call you once. And if the song is short and you can hold a connection, pass mode:"sync": it returns the finished object inside 90 seconds, or a 202 with status:"running" if it needs longer, so your client must handle both.

Send an Idempotency-Key on every create. A replay within 24 hours returns the original generation instead of paying for a second one — which matters most on the retry after a timeout, when you cannot tell whether the first request landed.

POST/v1/music/generations

Create a generation#

Turn a prompt into a full song. Returns 202 with a queued generation in async mode, or 200 with a finished one in sync mode if it lands inside 90 seconds.

Headers
HeaderDescription
AuthorizationrequiredBearer bm_live_…
Idempotency-KeyoptionalA UUID. Replays within 24 hours return the original generation instead of creating a second one.
AcceptoptionalWith mode:"sync" and audio/mpeg (or audio/wav, audio/flac), a finished generation returns raw audio bytes rather than JSON, plus x-bluemetal-generation-id and x-bluemetal-usage-usd headers.
Parameters
FieldTypeDescription
promptstringrequiredWhat the song should sound like — genre, tempo, instrumentation, vocal. Max 2,000 characters.
seedintegeroptionalControls music sampling. Prompt preparation can vary between new requests; use Idempotency-Key for exact replay.
formatstringdefault mp3mp3 (192 kbps), wav or flac.
modestringdefault asyncasync returns immediately. sync holds the connection for up to 90 s, then falls back to a 202 with status running.
webhook_urlstringoptionalPOSTed once on a terminal state, HMAC-signed. See Webhooks.
Request
curl https://api.bluemetal.ai/v1/music/generations \
  -H "Authorization: Bearer $BLUEMETAL_API_KEY" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: $(uuidgen)" \
  -d '{
        "prompt": "Upbeat indie pop, 120 BPM, female vocal, bright guitars",
        "format": "mp3"
      }'
Response
{
  "id": "gen_01JABCDEF",
  "object": "music.generation",
  "status": "queued",
  "created_at": 1756500000,
  "started_at": null,
  "completed_at": null,
  "audio": null,
  "usage": null,
  "error": null,
  "queue_position": 2
}
GET/v1/music/generations/{id}

Retrieve a generation#

The same object, with audio and usage filled in once it succeeds. 404 if the generation is not yours. Poll this with backoff; every read mints a fresh 24-hour audio URL.

Parameters
FieldTypeDescription
idstringpathThe generation id returned by the create call.
Request
curl https://api.bluemetal.ai/v1/music/generations/gen_01JABCDEF \
  -H "Authorization: Bearer $BLUEMETAL_API_KEY"
Response
{
  "id": "gen_01JABCDEF",
  "object": "music.generation",
  "status": "succeeded",
  "created_at": 1756500000,
  "started_at": 1756500003,
  "completed_at": 1756500043,
  "audio": {
    "url": "https://…r2 presigned GET…",
    "format": "mp3",
    "duration": 178.4,
    "bytes": 2914560,
    "sample_rate": 48000,
    "expires_at": 1756586443
  },
  "usage": { "audio_seconds": 178.4, "amount_usd": 0.0095 },
  "error": null
}
GET/v1/music/generations

List generations#

Your generations, newest first, cursor-paginated.

Parameters
FieldTypeDescription
limitintegerdefault 20How many to return.
afterstringoptionalA generation id to page after.
statusstringoptionalFilter to queued, running, succeeded, failed or canceled.
Request
curl "https://api.bluemetal.ai/v1/music/generations?limit=20&status=succeeded" \
  -H "Authorization: Bearer $BLUEMETAL_API_KEY"
Response
{ "object": "list", "data": [ { … } ], "has_more": false }
POST/v1/music/generations/{id}/cancel

Cancel a generation#

Cancels a generation that is still queued. 409 once it has started — generation has already started.

Parameters
FieldTypeDescription
idstringpathThe generation to cancel.
Request
curl -X POST https://api.bluemetal.ai/v1/music/generations/gen_01JABCDEF/cancel \
  -H "Authorization: Bearer $BLUEMETAL_API_KEY"
Response
{ "id": "gen_01JABCDEF", "status": "canceled", … }
POST/v1/audio/speech

OpenAI-compatible alias#

Use input as the song prompt. Musical direction and lyrics are prepared automatically. Always synchronous, returning raw audio bytes rather than JSON.

Parameters
FieldTypeDescription
modelstringignoredOpenAI SDKs require the field; any value is accepted and ignored.
inputstringrequiredDescribe the song, including any exact lyrics. Maximum 2,000 characters.
response_formatstringoptionalmp3, wav or flac.
seedintegeroptionalSame meaning as on the native endpoint.
Request
curl https://api.bluemetal.ai/v1/audio/speech \
  -H "Authorization: Bearer $BLUEMETAL_API_KEY" \
  -H "Content-Type: application/json" \
  -o song.mp3 \
  -d '{
        "model": "bluemetal",
        "input": "Synthwave about a midnight drive, 110 BPM, retro 80s synths",
        "response_format": "mp3"
      }'
Response

Raw audio bytes with Content-Type: audio/mpeg | audio/wav | audio/flac.

GET/v1/account

Account#

Your account, including the remaining prepaid credit. Session cookie or an API key.

Request
curl https://api.bluemetal.ai/v1/account -H "Authorization: Bearer $BLUEMETAL_API_KEY"
Response
{ "id": "acct_…", "email": "[email protected]", "credits_usd": 0.4731, "created_at": 1756400000, "plan": "free" }
POST/v1/keys

API keys#

POST /v1/keys creates a key and returns the secret exactly once — only sha256(key) is stored. GET /v1/keys lists keys without their secrets, showing a prefix. DELETE /v1/keys/{id} revokes one and returns 204.

Parameters
FieldTypeDescription
namestringrequiredA label you will recognise later, e.g. "prod".
Request
curl https://api.bluemetal.ai/v1/keys \
  -H "Authorization: Bearer $BLUEMETAL_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"name":"prod"}'
Response
{ "id": "key_…", "name": "prod", "key": "bm_live_…", "created_at": 1756400000, "last_used_at": null }
GET/v1/usage

Usage#

Generations, audio seconds and spend, bucketed by day.

Parameters
FieldTypeDescription
fromintegeroptionalUnix timestamp, inclusive.
tointegeroptionalUnix timestamp, exclusive.
bucketstringdefault dayBucket size.
Request
curl "https://api.bluemetal.ai/v1/usage?from=1756425600&bucket=day" \
  -H "Authorization: Bearer $BLUEMETAL_API_KEY"
Response
{
  "data": [
    { "ts": 1756425600, "generations": 14, "audio_seconds": 1382.6, "amount_usd": 0.133 }
  ],
  "total": { "generations": 143, "audio_seconds": 14204.1, "amount_usd": 1.3585 }
}

Webhooks#

Pass webhook_url on a create and we POST the whole generation object once it reaches a terminal state, as {"type":"music.generation.succeeded","data":{…}}. Non-2xx responses are retried three times, after 10 s, 60 s and 300 s.

POST https://your-app.example.com/hooks/bluemetal
bluemetal-signature: t=1756500043,v1=9f86d081884c7d659a2feaa0c55ad015…
bluemetal-delivery: 7c9e6679-7425-40de-944b-e07fc1f90ae7

{
  "type": "music.generation.succeeded",
  "data": { "id": "gen_01JABCDEF", "status": "succeeded", "audio": { … }, "usage": { … } }
}

Verify the signature before you trust the body. It is an HMAC-SHA256 of t + "." + body using your account's signing secret, which lives on the dashboard. Compare in constant time and reject timestamps more than a few minutes old.

import crypto from "node:crypto"

export function verify(rawBody, header, secret) {
  const parts = Object.fromEntries(header.split(",").map((p) => p.split("=")))
  const age = Math.abs(Date.now() / 1000 - Number(parts.t))
  if (age > 300) return false                        // reject replays
  const expected = crypto
    .createHmac("sha256", secret)
    .update(`${parts.t}.${rawBody}`)
    .digest("hex")
  return crypto.timingSafeEqual(Buffer.from(expected), Buffer.from(parts.v1))
}

Errors#

Errors are OpenAI-shaped, with the matching HTTP status. Everything 4xx and 5xx is logged against the x-request-id on the response — quote it and we can find the exact request.

{
  "error": {
    "message": "prompt must be at most 2000 characters",
    "type": "invalid_request_error",
    "code": "invalid_request",
    "param": "prompt"
  }
}
StatusCodeWhen
400invalid_requestA required prompt is missing, a prompt exceeds 2,000 characters, or a retired lyrics/duration field was supplied.
401invalid_api_keyThe key is missing, malformed, revoked or not yours. Keys start bm_live_.
402insufficient_creditsThe request would cost more than your remaining balance. Credit is checked at submission and debited at completion, so a job is never half-billed. There is no overage in v1.
404not_foundNo such generation or key — or it belongs to another account. We do not distinguish the two.
409conflictYou tried to cancel a generation that had already started.
413too_largeThe body exceeded the size limit.
429rate_limit_exceededOver the concurrency or request-rate limit. Read retry-after and x-ratelimit-reset and back off.
500server_errorOur fault. Every 4xx and 5xx is logged with the x-request-id from the response — quote it and we can find the exact request.
503no_capacityGeneration capacity is busy and the queue is full. Retry with backoff; nothing was billed.

Rate limits#

LimitValueNotes
Concurrent generations10Per account, counted across queued and running jobs.
Write requests60 / minCreates and cancels.
Read requests600 / minPolling, listing, models, usage.

Every response carries x-ratelimit-limit, x-ratelimit-remaining, x-ratelimit-reset, retry-after (on 429 only). On a 429, wait for retry-after rather than retrying immediately — a tight retry loop is the fastest way to stay rate limited.

Pricing#

$0.0095 per song, regardless of length. Songs typically last 3 to 5 minutes. Generation time varies with the song and queue. There is no duration parameter. Credit is debited only when a generation succeeds.

TrackPrice
1 song$0.0095
10 songs$0.085
100 songs$0.85

New accounts get $0.10 of credit — about 10 songs — with no card. When the balance hits zero, creates return 402: there is no overage and no invoice in v1.

Available now

Music generation turns a prompt into music at 48 kHz stereo. Songs end naturally; no duration parameter is needed.