API reference
Base URL https://api.stemx.app. All requests use HTTPS. Uploads are multipart/form-data; responses are JSON. New here? Start with the quickstart.
Authentication
Create a key on the keys page and send it on every request. The key is shown once; store it securely.
Authorization: Bearer sk_live_...A missing or unknown key returns 401 with {"error": "invalid_key"}.
Billing and quota
You are billed per second of input audio, from the same balance as the web app: your plan's monthly minutes first, then any pack minutes. Jobs that are queued or running count against the balance until they finish; failed and cancelled jobs are not billed. Plans and prices are on the pricing page.
POST /v1/jobs
Queue a separation. Returns 202 at once with a job ID. Fields: audio (the file; required, except for pitch and vocal_split jobs); stems, both (vocals and instrumental, the default) or six (vocals, drums, bass, guitar, piano, other); midi, true to also get a MIDI file per stem; format, mp3 (default), wav or flac, where WAV and FLAC need a paid plan or a minutes pack (otherwise 402 plan_required); and optionally webhook_url (see Webhooks). Audio up to 10 minutes and 100 MB.
curl -X POST https://api.stemx.app/v1/jobs \
-H "Authorization: Bearer sk_live_..." \
-F "audio=@song.mp3" \
-F "stems=six" \
-F "midi=true"{
"job_id": "3n8fK2pQ7xW1mZ4vTbYh9Q",
"status": "queued",
"queue_position": 1,
"created_at": "2026-10-02T04:12:00+00:00"
}queue_position is 1-based and only present while the job is queued.
Analysis only
To get just the key and BPM, send -F "kind=analysis" with the audio: the job returns key and BPM without separating anything. It is not billed in minutes. It is capped at 100 a day per account, shared with the web key and BPM finder; over the cap you get 429 analysis_limit. Analysis jobs cannot take a webhook_url: they finish in seconds, so poll the job.
Pitch and lead/backing vocals
Two more kinds work on a finished full separation instead of new audio. Send kind=pitch with source_job_id and semitones (non-zero, within ±12) to transpose its stems, or kind=vocal_split with source_job_id to split its vocals into lead and backing. Both are billed in minutes like a separation.
Webhooks
Set webhook_url on POST /v1/jobs and we send a signed POST when the job finishes, succeeded or failed, instead of you polling. Delivery is best effort and never changes the job's own status. A cancelled job sends nothing. The body is {job_id, status, result_uris, error_code}, plus midi_uris when the job asked for MIDI, with result_uris null on a failure and error_code null on success.
The X-Stem-Signature header is the hex HMAC-SHA256 of the raw request body, keyed with your webhook secret from the keys page, where you can also rotate it. Verify against the raw bytes, not re-serialised JSON, and compare in constant time. If the account behind your key has no secret, setting webhook_url is refused with 422 webhook_secret_missing; a webhook is never sent unsigned.
Each delivery also carries X-Stem-Timestamp and X-Stem-Signature-V1, the hex HMAC-SHA256 of the timestamp, a dot and the raw body, so you can reject replays; X-Stem-Delivery identifies the delivery.
import hashlib
import hmac
def verify(body: bytes, signature_header: str, secret: str) -> bool:
expected = hmac.new(secret.encode(), body, hashlib.sha256).hexdigest()
return hmac.compare_digest(expected, signature_header)GET /v1/jobs/{job_id}
Read a job. status is one of queued, running, succeeded, failed, cancelled. When it has succeeded the response carries signed download links, valid for one hour from the moment you ask; ask again for fresh links while the files are kept (3 to 30 days depending on plan). A job that does not exist or is not yours returns 404 not_found. With midi=true, a succeeded job also has midi_uris: one signed .mid link per stem, keyed by stem name (all six, even with stems=both), plus combined, one file with every stem's notes. A stem with no notes has no link.
curl https://api.stemx.app/v1/jobs/3n8fK2pQ7xW1mZ4vTbYh9Q \
-H "Authorization: Bearer sk_live_..."{
"job_id": "3n8fK2pQ7xW1mZ4vTbYh9Q",
"status": "succeeded",
"created_at": "2026-10-02T04:12:00+00:00",
"result_uris": {
"vocals": "https://storage.googleapis.com/...",
"drums": "https://storage.googleapis.com/...",
"bass": "https://storage.googleapis.com/...",
"guitar": "https://storage.googleapis.com/...",
"piano": "https://storage.googleapis.com/...",
"other": "https://storage.googleapis.com/..."
},
"analysis": {
"key": "G major",
"key_confidence": 0.82,
"bpm": 128,
"bpm_confidence": 0.94
},
"midi_uris": {
"vocals": "https://storage.googleapis.com/...",
"drums": "https://storage.googleapis.com/...",
"bass": "https://storage.googleapis.com/...",
"guitar": "https://storage.googleapis.com/...",
"piano": "https://storage.googleapis.com/...",
"other": "https://storage.googleapis.com/...",
"combined": "https://storage.googleapis.com/..."
}
}A failed job has status: "failed" and an error_code: input_unreadable, separation_failed, encode_failed, storage_failed, stuck, pitch_failed, lyrics_failed, source_expired, or source_has_no_vocals.
Key and BPM
analysis gives the track's key and tempo with a confidence from 0 to 1 for each. Job responses also carry beats and downbeats (in seconds) and meter when they are detected. It is best effort: on job responses a field that was not detected is omitted, and analysis may be absent altogether, so check for presence rather than null. On /v1/separate the same fields are written out as null. A key is only given when the model is confident: When it answers, it's right about 80% of the time. It answers on about 44% of songs.
GET /v1/jobs
List your jobs, newest first, as {"jobs": [...]}. Query parameters: limit (1 to 100, default 100) and status (one of the five statuses; anything else is 422 bad_status).
DELETE /v1/jobs/{job_id}
Cancel a job that is still queued. Returns the job with status: "cancelled"; it is not billed. A running job cannot be cancelled (409 already_running), nor can a finished one (409 already_terminal).
POST /v1/separate
Synchronous separation for short clips (up to 90 seconds): the response arrives when the stems are ready. Fields: audio, stems (vocals, instrumental, both or six, default both), format (mp3, flac or wav, default mp3). Use POST /v1/jobs for anything longer. MIDI is not available here.
curl -X POST https://api.stemx.app/v1/separate \
-H "Authorization: Bearer sk_live_..." \
-F "audio=@clip.wav" -F "stems=both" -F "format=mp3"The 200 response is {stems, duration_s, processing_ms, model, output_offset_ms, expires_at, analysis}: stems maps each stem name to a signed link, valid for one hour (until expires_at); duration_s is the length of the audio in seconds; model gives the separation model's name and attribution; analysis is described under Key and BPM above.
Errors
Job routes return {"error_code": ..., "detail": ...}; /v1/separate returns {"error": ..., "detail": ...}.
401 invalid_key: missing or unknown key.402 plan_required:formatiswavorflacwithout a paid plan or a minutes pack.403 account_disabled: the account behind the key is disabled.422 bad_params:stemsorformatis not one of the documented values.422 missing_audio: a separation sent withoutaudio.422 invalid_pitch_request,422 invalid_semitones,422 invalid_vocal_split_request: apitchorvocal_splitjob without itssource_job_id(andsemitones), or withsemitonesoutside ±12.404 source_not_found,400 source_not_ready,400 source_expired,400 source_has_no_vocals:source_job_idis not a finished full separation of yours whose files and vocals are still available.422 bad_webhook_url:webhook_urlis not an allowed destination.503 feature_disabled: that kind of job is switched off right now.429 quota_exceeded: your balance is used up. The error carriesupgrade_urlandremaining_s(seconds of allowance left). Buy a pack or upgrade on the pricing page.429 rate_limited: more than 120 requests a minute on one key; wait theRetry-Afterseconds.429 analysis_limit: the 100 analyses a day for the account are used.429 queue_full: on/v1/separateonly, the service is busy; retry afterretry_after_sseconds.422 unsupported_kind:kindis not a kind this API accepts.422 webhook_secret_missing: you setwebhook_urlbut the account behind your key has no webhook secret; create one on the keys page.422 webhook_not_supported: you setwebhook_urlon akind=analysisjob; analysis jobs cannot take a webhook, so poll the job.413 too_large: file over 100 MB.413 too_long: audio over 10 minutes.413 audio_too_long_for_sync: over 90 seconds on/v1/separate; use/v1/jobs.503 webhook_check_unavailable:webhook_urlcould not be checked right now; retry shortly.503 storage_failed: download links could not be signed (GET /v1/jobs/{job_id}and/v1/separate); retry shortly.503 queue_unavailableand503 separation_timeout: on/v1/separateonly; retry shortly, or submit the track to/v1/jobs.415 unsupported_media: the file could not be decoded as audio.
Limits
- Files up to 100 MB and 10 minutes (90 seconds on
/v1/separate). - 120 requests a minute per key.
GETrequests count toward it, so poll a job no more than about once every 2 to 5 seconds, or use a webhook instead. - Jobs at once: Free 1, Starter 1, Pro 2, Scale 4.
Models
Separation: BS-RoFormer-SW (bs-roformer-infer, MIT; weights enerjazzer/BS-ROFO-SW-Fixed); HT-Demucs 6s (Meta AI Research, demucs MIT). MIDI: YourMT3+ (mimbres/YourMT3) for pitched stems and ADTOF (CC BY-NC-SA) for drums. Lead and backing vocal split: models by UVR (Anjok07 & aufr33), used under their MIT licence. Tempo and key: Beat This! (Foscarin, Schlüter & Widmer, CPJKU); S-KEY (Kong et al., Deezer).