One JSON endpoint turns a description and a set of lyrics into a finished song. Async by default, OpenAI-shaped errors, presigned audio, and a price of $0.000115 per second of audio.
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.
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_…"One POST. You get a generation id back immediately, along with a queue position and an estimated wait.
curl https://api.bluemetal.ai/v1/music/generations \
-H "Authorization: Bearer $BLUEMETAL_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"model": "minimax-music-3",
"prompt": "Lo-fi jazz hip-hop, 85 BPM, mellow Rhodes piano, soft female vocal",
"lyrics": "[Verse]\nRain on the window, coffee cold\n[Chorus]\nStay a while, stay a while",
"duration": 90
}'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"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.
401.A generation moves queued → running → succeeded, failed or canceled. Rendering costs roughly two-fifths of the song's own length on one RTX 5090 — a 60-second track lands in about 22 seconds, a three-minute track in about 70.
| Status | What it means | |
|---|---|---|
| queued | non-terminal | Accepted and waiting for a worker. queue_position and estimated_wait_s are best-effort hints. Cancellable. |
| running | non-terminal | On a GPU. No longer cancellable — a cancel returns 409. |
| succeeded | terminal | audio 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. |
| failed | terminal | error explains why. Retryable worker failures are already retried once on a different machine before you see this. |
| canceled | terminal | You 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.
Turn a description and optional lyrics 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.
| Header | Description | |
|---|---|---|
| Authorization | required | Bearer bm_live_… |
| Idempotency-Key | optional | A UUID. Replays within 24 hours return the original generation instead of creating a second one. |
| Accept | optional | With 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. |
| Field | Type | Description | |
|---|---|---|---|
| model | string | required | Model id. Today the only value is minimax-music-3; see GET /v1/models. |
| prompt | string | required | What the song should sound like — genre, tempo, instrumentation, vocal. Max 2,000 characters. |
| lyrics | string | optional | Lyrics, max 6,000 characters. Section tags such as [Verse], [Chorus] and [Bridge] are honoured and shape the arrangement. |
| duration | integer | default 60 | Seconds of audio, 10–300. Best effort: the model may end a song early, and you are billed for delivered audio. |
| seed | integer | optional | The same seed with the same inputs gives the same song. |
| format | string | default mp3 | mp3 (192 kbps), wav or flac. |
| mode | string | default async | async returns immediately. sync holds the connection for up to 90 s, then falls back to a 202 with status running. |
| webhook_url | string | optional | POSTed once on a terminal state, HMAC-signed. See Webhooks. |
curl https://api.bluemetal.ai/v1/music/generations \
-H "Authorization: Bearer $BLUEMETAL_API_KEY" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: $(uuidgen)" \
-d '{
"model": "minimax-music-3",
"prompt": "Upbeat indie pop, 120 BPM, female vocal, bright guitars",
"lyrics": "[Verse]\nCity lights are humming low\n[Chorus]\nHold on, hold on, the night is ours",
"duration": 120,
"format": "mp3"
}'{
"id": "gen_01JABCDEF",
"object": "music.generation",
"model": "minimax-music-3",
"status": "queued",
"created_at": 1756500000,
"started_at": null,
"completed_at": null,
"audio": null,
"usage": null,
"error": null,
"queue_position": 2,
"estimated_wait_s": 25
}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.
| Field | Type | Description | |
|---|---|---|---|
| id | string | path | The generation id returned by the create call. |
curl https://api.bluemetal.ai/v1/music/generations/gen_01JABCDEF \
-H "Authorization: Bearer $BLUEMETAL_API_KEY"{
"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": 32000,
"expires_at": 1756586443
},
"usage": { "audio_seconds": 178.4, "amount_usd": 0.0107 },
"error": null
}Your generations, newest first, cursor-paginated.
| Field | Type | Description | |
|---|---|---|---|
| limit | integer | default 20 | How many to return. |
| after | string | optional | A generation id to page after. |
| status | string | optional | Filter to queued, running, succeeded, failed or canceled. |
curl "https://api.bluemetal.ai/v1/music/generations?limit=20&status=succeeded" \
-H "Authorization: Bearer $BLUEMETAL_API_KEY"{ "object": "list", "data": [ { … } ], "has_more": false }Cancels a generation that is still queued. 409 once it has started — a running job is already burning GPU.
| Field | Type | Description | |
|---|---|---|---|
| id | string | path | The generation to cancel. |
curl -X POST https://api.bluemetal.ai/v1/music/generations/gen_01JABCDEF/cancel \
-H "Authorization: Bearer $BLUEMETAL_API_KEY"{ "id": "gen_01JABCDEF", "status": "canceled", … }An OpenAI-shaped model list, extended with the fields a provider listing wants: modalities, quantization, max duration, per-second pricing and supported parameters. GET /v1/models/{id} returns one.
curl https://api.bluemetal.ai/v1/models -H "Authorization: Bearer $BLUEMETAL_API_KEY"{
"object": "list",
"data": [{
"id": "minimax-music-3",
"object": "model",
"owned_by": "minimax",
"name": "MiniMax Music 3",
"description": "Text+lyrics → full song, up to 5 minutes, 32 kHz stereo",
"input_modalities": ["text"],
"output_modalities": ["audio"],
"quantization": "int4-mixed",
"max_duration_seconds": 300,
"pricing": { "audio_second": "0.000115", "unit": "USD" },
"supported_parameters": ["prompt", "lyrics", "duration", "seed", "format"],
"endpoints": ["/v1/music/generations", "/v1/audio/speech"]
}]
}Exists so an OpenAI SDK works with only a base-URL swap. input maps to lyrics and instructions to prompt. Always synchronous, and it returns raw audio bytes rather than JSON.
| Field | Type | Description | |
|---|---|---|---|
| model | string | required | minimax-music-3. |
| input | string | required | Maps to lyrics. |
| instructions | string | optional | Maps to prompt — the style description. |
| response_format | string | optional | mp3, wav or flac. |
| seed | integer | optional | Same meaning as on the native endpoint. |
curl https://api.bluemetal.ai/v1/audio/speech \
-H "Authorization: Bearer $BLUEMETAL_API_KEY" \
-H "Content-Type: application/json" \
-o song.mp3 \
-d '{
"model": "minimax-music-3",
"instructions": "Synthwave, 110 BPM, retro 80s synths",
"input": "[Verse]\nNeon rivers, chrome and glass",
"response_format": "mp3"
}'Raw audio bytes with Content-Type: audio/mpeg | audio/wav | audio/flac.
Your account, including the remaining prepaid credit. Session cookie or an API key.
curl https://api.bluemetal.ai/v1/account -H "Authorization: Bearer $BLUEMETAL_API_KEY"{ "id": "acct_…", "email": "[email protected]", "credits_usd": 0.4731, "created_at": 1756400000, "plan": "free" }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.
| Field | Type | Description | |
|---|---|---|---|
| name | string | required | A label you will recognise later, e.g. "prod". |
curl https://api.bluemetal.ai/v1/keys \
-H "Authorization: Bearer $BLUEMETAL_API_KEY" \
-H "Content-Type: application/json" \
-d '{"name":"prod"}'{ "id": "key_…", "name": "prod", "key": "bm_live_…", "created_at": 1756400000, "last_used_at": null }Generations, audio seconds and spend, bucketed by day.
| Field | Type | Description | |
|---|---|---|---|
| from | integer | optional | Unix timestamp, inclusive. |
| to | integer | optional | Unix timestamp, exclusive. |
| bucket | string | default day | Bucket size. |
curl "https://api.bluemetal.ai/v1/usage?from=1756425600&bucket=day" \
-H "Authorization: Bearer $BLUEMETAL_API_KEY"{
"data": [
{ "ts": 1756425600, "generations": 14, "audio_seconds": 1382.6, "amount_usd": 0.0829 }
],
"total": { "generations": 143, "audio_seconds": 14204.1, "amount_usd": 0.8522 }
}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 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": "duration must be between 10 and 300",
"type": "invalid_request_error",
"code": "invalid_request",
"param": "duration"
}
}| Status | Code | When |
|---|---|---|
| 400 | invalid_request | A field is missing or out of range — a prompt over 2,000 characters, a duration outside 10–300. |
| 401 | invalid_api_key | The key is missing, malformed, revoked or not yours. Keys start bm_live_. |
| 402 | insufficient_credits | The 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. |
| 404 | not_found | No such generation or key — or it belongs to another account. We do not distinguish the two. |
| 409 | conflict | You tried to cancel a generation that had already started. |
| 413 | too_large | The body exceeded the size limit. |
| 429 | rate_limit_exceeded | Over the concurrency or request-rate limit. Read retry-after and x-ratelimit-reset and back off. |
| 500 | server_error | Our fault. Every 4xx and 5xx is logged with the x-request-id from the response — quote it and we can find the exact request. |
| 503 | no_capacity | Every GPU is busy and the queue is full. Retry with backoff; nothing was billed. |
| Limit | Value | Notes |
|---|---|---|
| Concurrent generations | 10 | Per account, counted across queued and running jobs. |
| Write requests | 60 / min | Creates and cancels. |
| Read requests | 600 / min | Polling, 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.
$0.000115 per second of delivered audio, with a 15-second minimum per request. You are billed for what the model actually produced, never for idle GPU time, and credit is debited only when a generation succeeds.
| Track | Price | |
|---|---|---|
| 60 s track | $0.0069 | |
| 180 s track | $0.0207 | |
| 300 s track | $0.0345 |
New accounts get $0.50 of credit — about 24 three-minute tracks — with no card. When the balance hits zero, creates return 402: there is no overage and no invoice in v1.
minimax-music-3 — MiniMax Music 3, text and lyrics to a full song of up to five minutes, 32 kHz stereo. It is the only model behind this API today; the request shape is the one every model we add will use.