limbo/Beat — API Integration Guide#
This guide is for developers integrating with the limbo/Beat API. It assumes you have already received your access credentials (API key, dashboard user/password, webhook signing secret) from the Limbo team. If you have not, contact us before continuing.
Do not share your API key, do not commit it to source control.
1. Overview#
1.1 What this API does#
The limbo/Beat API performs automated quality-assurance analysis on a music release (album + tracks). You submit the album metadata and asset URLs; the API runs a series of checks (images, metadata, audio) and returns a structured report indicating whether the release is ready to ship, has warnings, or has blocking issues.
The pipeline is asynchronous. The submission endpoint returns immediately; the result is delivered either via webhook (recommended) or by polling.
1.2 Base URL#
| Resource | URL |
|---|---|
| Environment API Host | https://<api-host>/v1 |
| Environment Dashboard | https://<dashboard-host> |
Hostnames for your environment are provided at provisioning. The /v1 path prefix and HTTPS-only requirement are the same across all environments.
1.3 Authentication#
Every API request must include your API key in the x-api-key header:
x-api-key: <YOUR_API_KEY>
The API key is shown only once when your access is provisioned. Store it securely on your side.
There is no OAuth, no Bearer token, no rotating credential flow. The API key is the single secret used to authenticate.
The dashboard uses a separate username/password. API integrations only ever use
x-api-key.
1.4 The dashboard#
The dashboard is a web UI where your operator can:
- See the history of every QA run you have submitted
- Open the full report for any run
- Edit your
qa_configdefaults (which checks are enabled, severity, thresholds) - Send a test payload to your webhook endpoint without consuming quota
You log in with the username/password you received when your access was provisioned.
2. API Reference#
The API exposes three endpoints. All require the x-api-key header.
| Method | Path | Purpose |
|---|---|---|
POST |
/qa/album |
Submit an album for QA analysis |
GET |
/qa/album/{albumId}/status |
Get the pipeline state and summary of a previous submission |
GET |
/qa/album/{albumId}/report |
Get the full report once analysis has completed |
2.1 POST /qa/album#
Submit one album with its tracks for QA analysis. The response is 202 Accepted with a qa_run_id you can use to retrieve the result.
POST /qa/album
Host: <api-host>
Headers#
| Name | Required | Value | Description |
|---|---|---|---|
x-api-key |
yes | <YOUR_API_KEY> |
Authentication |
Content-Type |
yes | application/json |
|
Idempotency-Key |
yes | any unique string ≤ 256 chars (UUID v4 is the standard choice) | Your identifier for one logical submission. See §5.2. |
Top-level body parameters#
| Name | Type | Required | Description |
|---|---|---|---|
album_id |
string | yes | Your stable, unique identifier for this album. Used to look the run up later. Treat as opaque — we do not parse it. |
callback_url |
string | no | Public HTTPS URL we POST the final report to (§3). If omitted, you must poll. |
album |
object | yes | See album object below. |
tracks |
array | yes | One or more tracks. See track object below. |
qa_config |
object | no | Per-request override. Deep-merged onto your saved config. Omit if you want your default. See §5.3. |
album object parameters#
| Name | Type | Required | Description |
|---|---|---|---|
title |
string | yes | Album/release title. |
main_artist |
array of strings | yes | At least one artist name. Use exact display form. |
genre_1 |
string | yes | Primary genre label (free text — your taxonomy is fine). |
cover_image_url |
string (URL) | yes | Public HTTPS URL of the cover image. Must be reachable from the public internet. |
label |
string | no | Label name. |
genre_2 |
string | no | Secondary genre. |
release_date |
string (YYYY-MM-DD) | no | |
upc |
string | no | 12- or 13-digit UPC/EAN. Validated for format + check digit. |
language |
string (ISO 639-1) | no | Defaults to "en". Used to scope language-specific textual checks. |
production_line |
string | no | Copyright/production line (e.g. 2025 My Label Records). |
version |
string | null | no | Version tag (Remix, Live, etc). |
contributor, producer, featuring, remixer, composer, author, lyricist, performer, arranger, director |
array of strings | null | no | Per-role credit lists, all optional. |
tracks[] object parameters#
Required:
| Name | Type | Description |
|---|---|---|
track_id |
string | Your stable identifier for the track. |
title |
string | |
main_artist |
array of strings | At least one. |
track_number |
integer | 1-based, position within the disc. |
audio_url |
string (URL) | Public HTTPS URL of the audio. Lossless (FLAC/WAV) is preferred and required for some sub-checks. |
genre_1 |
string |
Optional:
| Name | Type | Default | Description |
|---|---|---|---|
isrc |
string | null |
12-char ISRC. Format + duplicate-across-tracks checks apply. |
disc_number |
integer | 1 |
|
duration_seconds |
float | null |
Used by cross-checks; if omitted we read it from the audio file. |
version |
string | null |
Remix, Acoustic, Live, etc. |
language |
string | "en" |
|
explicit |
boolean | false |
If true, explicit-language findings are treated as expected and not flagged. |
is_cover |
boolean | false |
Marks the track as a known cover. |
is_remix |
boolean | false |
Marks the track as a known remix. |
lyrics |
string | null |
Plain text. If present, additional textual checks run against it. |
genre_2 |
string | null |
|
production_line |
string | null |
Per-track override. |
featuring, contributor, remixer, composer, author, lyricist, performer, producer, publisher, arranger, director |
array of strings | null |
Per-role credit lists. |
Example request body#
{
"album_id": "MY-LABEL-RELEASE-00042",
"callback_url": "https://my-app.example.com/webhooks/limbo-qa",
"album": {
"title": "Sample Album Title",
"main_artist": ["Example Artist"],
"label": "My Label Records",
"genre_1": "World Music",
"genre_2": "Folk",
"release_date": "2025-06-15",
"upc": "1234567890128",
"language": "en",
"production_line": "2025 My Label Records",
"version": null,
"cover_image_url": "https://my-cdn.example.com/covers/00042.jpg",
"producer": ["John Doe"],
"composer": ["Example Artist"],
"lyricist": ["Example Artist"]
},
"tracks": [
{
"track_id": "MY-LABEL-RELEASE-00042-01",
"title": "Sample Album Title",
"main_artist": ["Example Artist"],
"isrc": "USXXX2500001",
"track_number": 1,
"disc_number": 1,
"duration_seconds": 245.3,
"language": "en",
"explicit": false,
"is_cover": false,
"is_remix": false,
"audio_url": "https://my-cdn.example.com/audio/00042-01.flac",
"lyrics": "Sample lyrics line one\nSample lyrics line two...",
"genre_1": "World Music",
"genre_2": "Folk",
"production_line": "2025 My Label Records",
"composer": ["Example Artist"],
"lyricist": ["Example Artist"],
"producer": ["John Doe"]
}
]
}
Response — 202 Accepted#
{
"album_id": "MY-LABEL-RELEASE-00042",
"qa_run_id": "QA-7m3pXkQwF2A9",
"status": "QUEUED",
"status_url": "/qa/album/MY-LABEL-RELEASE-00042/status?qa_run_id=QA-7m3pXkQwF2A9",
"report_url": "/qa/album/MY-LABEL-RELEASE-00042/report?qa_run_id=QA-7m3pXkQwF2A9"
}
qa_run_id— unique handle for this submission. Store it.status— alwaysQUEUEDfor new submissions. See §5.1 for the full lifecycle.status_url/report_url— relative paths you can poll, both already qualified with the rightqa_run_id.
Response — 202 Accepted (idempotent replay)#
When you POST the same Idempotency-Key again and the previous request already finished, you receive the cached response with an extra flag:
{
"album_id": "...",
"qa_run_id": "...",
"status": "QUEUED",
"status_url": "...",
"report_url": "...",
"idempotent_replay": true
}
The submission is not processed again. Quota is not charged again.
Response — 400 Bad Request#
| Error code | When |
|---|---|
missing_idempotency_key |
The Idempotency-Key header is required and was not sent. |
generic { "error": "Invalid JSON body" } |
The body is not valid JSON. |
generic { "error": "Validation error: ..." } |
The body failed schema validation (missing required field, wrong type, etc). |
invalid_qa_config_override |
Your qa_config override merged into your saved config produces an invalid config. |
Response — 403 Forbidden#
| Error code | When |
|---|---|
tenant_unknown |
The API key is not recognized. Double-check the header. |
tenant_suspended |
Your access is not active. Contact the Limbo team. |
quota_exhausted |
See §5.4 for the shape and meaning. |
Response — 409 Conflict#
{ "error": "request_in_flight" }
Another POST with the same Idempotency-Key is still being processed. Poll the same key; do not retry until it returns.
Response — 422 Unprocessable Entity#
{
"error": "idempotency_key_reuse",
"message": "Idempotency-Key was already used with a different request body. Use a fresh Idempotency-Key for a new request, or resend the exact same body to replay."
}
You sent the same Idempotency-Key you used before, but the request body is not byte-identical to the original. We block this to prevent your client from accidentally receiving back the qa_run_id of a different submission. Either:
- Generate a fresh
Idempotency-Keyfor the new submission, OR - Resend the exact original body byte-for-byte to replay the cached response.
Quota is not charged on this response.
Response — 500 Internal Server Error#
Transient failure. Quota is not consumed on this failure; retry with the same Idempotency-Key is safe.
2.2 GET /qa/album/{albumId}/status#
Lightweight poll. Returns the pipeline state and a summary of check counts. Use this when you only need to know whether a run finished — not the full report.
GET /qa/album/{albumId}/status?qa_run_id={qa_run_id}
Host: <api-host>
Path parameters#
| Name | Type | Required | Description |
|---|---|---|---|
albumId |
string | yes | Your album_id from the submission. |
Query parameters#
| Name | Type | Required | Description |
|---|---|---|---|
qa_run_id |
string | no | Specific run to look up. If omitted, returns the latest run owned by your account for the given albumId. |
Headers#
| Name | Required | Value |
|---|---|---|
x-api-key |
yes | <YOUR_API_KEY> |
Response — 200 OK#
{
"album_id": "MY-LABEL-RELEASE-00042",
"qa_run_id": "QA-7m3pXkQwF2A9",
"status": "COMPLETED",
"overall_status": "WARNING",
/* additional pipeline timestamps and echoed input metadata */
"callback_url": "https://my-app.example.com/webhooks/limbo-qa",
"summary": {
"total_checks": "9",
"passed": "7",
"failed": "0",
"errors": "0",
"warnings": "2"
}
}
Field meanings:
status— pipeline state. See §5.1.overall_status— the verdict your business logic should branch on. See §5.5.summary.*— integer counts, returned as strings in this endpoint. Cast to int on your side.
Response — 404 Not Found#
{ "error": "Not Found", "message": "No QA run found for album_id=MY-LABEL-RELEASE-00042" }
A 404 is returned both when the album does not exist AND when it exists but belongs to a different account — by design, the API never leaks the existence of records that are not yours.
2.3 GET /qa/album/{albumId}/report#
Returns the complete JSON report with every check result, per-track findings, the AI executive review, and your effective qa_config. Use this when you did not set a callback_url and need to pull the result, or when you need to fetch the report again after the webhook.
GET /qa/album/{albumId}/report?qa_run_id={qa_run_id}
Host: <api-host>
Path parameters#
| Name | Type | Required | Description |
|---|---|---|---|
albumId |
string | yes | Your album_id from the submission. |
Query parameters#
| Name | Type | Required | Description |
|---|---|---|---|
qa_run_id |
string | no | Specific run to look up. If omitted, returns the latest run owned by your account for the given albumId. |
Headers#
| Name | Required | Value |
|---|---|---|
x-api-key |
yes | <YOUR_API_KEY> |
Response — 200 OK#
The response is a structured JSON report. The top-level shape is what your integration branches on:
{
"album_id": "MY-LABEL-RELEASE-00042",
"qa_run_id": "QA-7m3pXkQwF2A9",
"upc": "1234567890128",
"timestamp": "2025-05-22T01:16:02.114553+00:00",
"overall_status": "WARNING",
"summary": {
"total_checks": 9,
"passed": 7,
"warnings": 2,
"failed": 0,
"errors": 0
},
"blocking_issues": [ /* FAIL-severity findings */ ],
"warnings": [ /* WARNING-severity findings */ ],
"album_checks": { /* per-check structured results for album-level checks */ },
"track_checks": { /* keyed by track_id, per-check structured results per track */ },
"track_info": { /* keyed by track_id, the input track metadata echoed back */ },
"album_metadata": { /* the input album metadata echoed back */ },
"ai_review": { /* AI executive review across all findings */ },
"qa_config": { /* the effective config used for this run */ }
}
Integration branches on overall_status (see §5.5) and iterates blocking_issues[] / warnings[] to surface findings. Each finding entry carries at minimum check, severity, and detail, plus check-specific extra fields.
Response — 200 OK (still in progress)#
{ "status": "RUNNING", "message": "QA is still in progress" }
The run exists but the final report is not yet ready. Poll again shortly.
Response — 400 Bad Request#
{ "error": "Missing albumId path parameter" }
The URL was malformed.
Response — 403 Forbidden#
Same shapes as POST /qa/album (auth issues).
Response — 404 Not Found#
{ "error": "Not Found", "message": "QA run not found" }
No such run, or it belongs to a different account.
{ "error": "Not Found", "message": "Report not yet available" }
The run exists but the report has not been written yet and status is not RUNNING. Rare; retry once.
2.4 POST /qa/vocal-content#
Classifies a batch of individual tracks as carrying sung words or not. Unlike §2.1 this is not an album: the tracks are unrelated to each other, and nothing about a release is required or returned.
Analysis is asynchronous and takes minutes. This endpoint answers immediately with a job_id; poll §2.5 for the result.
POST /qa/vocal-content
Host: <api-host>
Content-Type: application/json
Headers#
| Name | Required | Value |
|---|---|---|
x-api-key |
yes | <YOUR_API_KEY> |
Idempotency-Key |
yes | A value you generate per request. See §5.2. |
Content-Type |
yes | application/json |
Request body#
| Field | Type | Required | Description |
|---|---|---|---|
tracks |
array | yes | Between 5 and 20 tracks. |
tracks[].track_id |
string | yes | Your own identifier. Echoed back untouched; never interpreted. Letters, digits and . _ : -, up to 128 characters. |
tracks[].audio_url |
string | yes | Publicly reachable http:// or https:// URL. Fetched once, on our side. |
{
"tracks": [
{ "track_id": "ISRC-AAAAA0000001", "audio_url": "https://cdn.example.com/01.wav" },
{ "track_id": "ISRC-AAAAA0000002", "audio_url": "https://cdn.example.com/02.wav" },
{ "track_id": "ISRC-AAAAA0000003", "audio_url": "https://cdn.example.com/03.wav" },
{ "track_id": "ISRC-AAAAA0000004", "audio_url": "https://cdn.example.com/04.wav" },
{ "track_id": "ISRC-AAAAA0000005", "audio_url": "https://cdn.example.com/05.wav" }
]
}
Response — 202 Accepted#
{
"job_id": "vc-4f2a91c8e5b74d03a6f1c2e8b9d47a05",
"status": "accepted",
"track_count": 5,
"poll_url": "/qa/vocal-content/vc-4f2a91c8e5b74d03a6f1c2e8b9d47a05"
}
job_id— generated on our side. Keep it: it is the only handle to this job.status— alwaysacceptedhere. It means the batch was queued, not that anything was analysed yet. The analysis statuses are in §2.5.track_count— how many tracks were accepted, and therefore how much of your allowance this job spends. See §5.4.poll_url— path to poll, relative to your API host. The result carries, per track, thevocal/instrumentalverdict, the vocal time ranges, and the detected words themselves (accepted_words). See §2.5.
A replay of the same Idempotency-Key with the same body returns the original job_id and adds "idempotent_replay": true. It does not start a second analysis and does not consume quota again.
Errors#
| Status | error |
Meaning |
|---|---|---|
| 400 | track_count |
Fewer than 5 or more than 20 tracks. |
| 400 | track_schema |
A track is missing track_id or audio_url. |
| 400 | track_id |
A track_id does not match the allowed pattern. |
| 400 | duplicate_track_id |
Two tracks share a track_id within the request. |
| 400 | audio_url_scheme |
An audio_url is not http:// or https://. |
| 400 | unsafe_url |
An audio_url was rejected by our fetch guards, or could not be resolved in time. |
| 400 | missing_idempotency_key |
The Idempotency-Key header is absent. |
| 403 | quota_exhausted |
The batch would exceed your vocal-content allowance. See §5.4. |
| 409 | request_in_flight |
Another request with this Idempotency-Key is still being processed. |
| 422 | idempotency_key_reuse |
This Idempotency-Key was already used with a different body. Use a fresh one. |
Nothing is consumed by a rejected request.
2.5 GET /qa/vocal-content/{jobId}#
Poll for the result of a job submitted through §2.4.
GET /qa/vocal-content/{jobId}
Host: <api-host>
Path parameters#
| Name | Type | Required | Description |
|---|---|---|---|
jobId |
string | yes | The job_id returned by §2.4. |
Headers#
| Name | Required | Value |
|---|---|---|
x-api-key |
yes | <YOUR_API_KEY> |
Response — 200 OK, still working#
{ "job_id": "vc-4f2a91…", "track_count": 5, "status": "pending" }
status is one of:
| Value | Meaning |
|---|---|
pending |
Accepted; the audio is being collected. |
running |
Under analysis. |
completed |
Every track was analysed. |
partial |
Some tracks were analysed; others failed. |
error |
The batch never started. error_code and error_message say why. |
Poll no more often than every 30 seconds. A batch typically takes several minutes.
Response — 200 OK, finished#
{
"job_id": "vc-4f2a91…",
"status": "completed",
"track_count": 5,
"completed_count": 5,
"tracks": [
{
"track_id": "ISRC-AAAAA0000001",
"status": "completed",
"classification": "vocal",
"audio_duration_seconds": 214.5,
"vocal_time_seconds": 98.2,
"vocal_time_ratio": 0.4578,
"accepted_word_count": 42,
"vocal_segments": [ { "start": 12.4, "end": 41.9 } ],
"accepted_words": [
{ "word": "tonight", "start": 13.1, "end": 13.6, "probability": 0.94 }
]
},
{
"track_id": "ISRC-AAAAA0000002",
"status": "completed",
"classification": "instrumental",
"audio_duration_seconds": 187.0,
"vocal_time_seconds": 0.0,
"vocal_time_ratio": 0.0,
"accepted_word_count": 0,
"vocal_segments": []
}
]
}
Field meanings:
classification— the answer.vocalmeans sung words were detected;instrumentalmeans they were not. Branch on this.vocal_time_seconds/vocal_time_ratio— how much of the track carries vocal content, absolute and as a fraction of duration.accepted_word_count— words detected inside vocal regions.vocal_segments— time ranges, in seconds, where vocal content was found.accepted_words— the words the verdict actually rests on, each with its position and the recogniser's confidence. One word is enough to make a trackvocal, so this is how you check whether that word is a lyric or a false positive on an instrument. Empty for an instrumental track, and occasionally absent — see below.- A track with
status: "error"carries noclassification. The rest of the batch is still returned.
accepted_wordsis omitted, rather than shown empty, when the classifier's calibration has changed since a job ran: the words that produced that verdict can no longer be identified with certainty.accepted_word_countis always present and always the count the job itself recorded.
instrumentalcovers both silence and wordless vocal sound (humming, ad-libs, vocal textures). The distinction this endpoint makes is sung words or no sung words, not "voice or no voice".
Response — 404 Not Found#
{ "error": "Not Found", "message": "No vocal-content job vc-4f2a91…" }
As in §2.2, a 404 is returned both when the job does not exist AND when it belongs to a different account.
3. Webhook Callbacks#
Polling works, but the recommended way to receive the result is to set callback_url in your submission. We will POST the final report to that URL.
3.1 How it works#
- The webhook fires once per run, after the full analysis finishes.
- It waits until all checks are complete — you receive one final webhook.
- The body is the same JSON as
GET /qa/album/{albumId}/report(§2.3), with one extra field only present on webhooks:original_request, an echo of the body you originally submitted.
Headers we send#
Content-Type: application/json
X-QA-Signature: <hex-hmac-sha256>
X-QA-Timestamp: <epoch-seconds-as-string>
Recognizing a test fire#
When you trigger a webhook from the dashboard's Test webhook button (§3.3), the payload includes an extra top-level marker:
{ "test": true, ... }
This field is never present on real production callbacks. Treat it as a hard signal in your receiver: when the top-level test field is true, acknowledge the request (return 2xx) and stop processing. Do not write to your QA database, do not alert your team, do not advance the album's release state. Everything else in the payload — headers, signature scheme, body shape, field names, nesting — is identical to a real callback, so the rest of your code path (signature validation, JSON parsing) still gets exercised end-to-end.
3.2 Signature verification#
We sign every webhook with HMAC-SHA256 using your signing secret (delivered when your access was provisioned). You MUST verify the signature on every received webhook. Unverified callers can spoof requests against your endpoint otherwise.
The signing recipe is:
signed_content = X-QA-Timestamp + "." + raw_request_body
signature = HMAC-SHA256(signing_secret, signed_content).hex()
Compare the result against the X-QA-Signature header using a constant-time comparison.
Critical pitfalls#
- Use the RAW request body bytes, not a re-stringified version of the parsed JSON. Parsing + re-stringifying re-orders keys and changes whitespace; the HMAC will not match. Read the bytes BEFORE parsing.
- Constant-time compare. Use your language's constant-time HMAC comparison primitive. A plain
==leaks the secret one byte at a time via timing. - Check the timestamp. Reject requests where
X-QA-Timestampis older than ~5 minutes — this prevents replay attacks where an attacker who once observed a valid request keeps sending it forever. - TLS only. Your
callback_urlmust be HTTPS. We will not connect to plain HTTP.
3.3 Testing your endpoint from the dashboard#
The dashboard has a Test webhook button that fires a synthetic payload with the same headers, signature scheme, and body shape as a real callback. Use it to validate your verification code before going live. The synthetic payload sets "test": true at the top level (see §3.1). Rate-limited; the current cooldown and daily cap are visible in your dashboard.
3.4 Webhook response expectations#
We expect any 2xx response from your endpoint. We do not currently retry failed deliveries. If your endpoint is down at the moment of delivery, you can still pull the report via GET /qa/album/{albumId}/report — results remain available via GET within the run's defined access window.
4. Tutorial — End-to-end workflow#
A complete integration is composed of a small handful of HTTP exchanges. This section walks through the typical flow.
4.1 Submit the album#
Generate one Idempotency-Key for this logical submission (a UUID v4 is the standard choice). Persist it on your side. If a retry fires, reuse the same key — never generate a new one for a retry (see §5.2).
POST /qa/album
Host: <api-host>
Content-Type: application/json
x-api-key: <YOUR_API_KEY>
Idempotency-Key: <UNIQUE_KEY_PER_SUBMISSION>
{ ...album payload as in §2.1... }
The response (§2.1) returns a qa_run_id. Store it — you will use it to retrieve the result.
If callback_url was included, you can stop here and wait for the webhook (§4.2 → option A). Otherwise you need to poll (§4.2 → option B).
4.2 Wait for the result#
Option A — Webhook (recommended). We will POST the report to your callback_url once the analysis is complete (§3). Make sure your endpoint verifies the HMAC signature (§3.2) before trusting the payload.
Option B — Polling. Call the status endpoint:
GET /qa/album/{albumId}/status?qa_run_id={qa_run_id}
Host: <api-host>
x-api-key: <YOUR_API_KEY>
Repeat until the response shows "status": "COMPLETED". See §5.1 for the full lifecycle of status.
4.3 Fetch the full report#
Once the run is complete, retrieve the report:
GET /qa/album/{albumId}/report?qa_run_id={qa_run_id}
Host: <api-host>
x-api-key: <YOUR_API_KEY>
The body is described in §2.3.
If you used a webhook, you already have the same body — this step is only needed if you missed the callback or want to fetch the report again.
4.4 Interpret the verdict#
Branch your business logic on overall_status. The four values map to four different actions; see §5.5 for the decision table.
The lists of findings live in blocking_issues[] (FAIL-severity) and warnings[] (WARNING-severity).
5. Concepts#
5.1 Status lifecycle#
QUEUED ──► RUNNING ──► COMPLETED
- QUEUED — submission accepted, awaiting analysis.
- RUNNING — analysis is in progress.
- COMPLETED — all checks done. The webhook fires now (if
callback_urlis set).
When polling, treat any value that is not COMPLETED as "still running" — wait and poll again. GET /status is the cheap call; GET /report is the expensive one. Use status to know when the report is ready, then fetch the report once.
5.2 Idempotency#
POST /qa/album requires an Idempotency-Key header. We use it to deduplicate retries:
| Scenario | Behavior |
|---|---|
| Same key, same body, completed prior call | Cached response returned (idempotent_replay: true). No rerun. No extra quota charged. |
| Same key, same body, prior call still running | 409 request_in_flight. Wait, then poll the cached response. |
| Same key, different body | 422 idempotency_key_reuse. We refuse to replay because you'd get the wrong qa_run_id back. Use a fresh key or resend the exact same body. No quota charged. |
| Same key, prior call failed mid-flight | The previous submission did not complete; safe to retry with the same key. |
| New key | Fresh submission. Quota charged. |
Each key is bound to the body it was first used with. Same key + byte-identical body = replay. Same key + any body change = 422. This protects you from cross-submission data leaks caused by accidental key reuse on your side.
Recommended practice: generate one UUID v4 per logical submission attempt on your side. Persist it next to the album record. If your retry logic fires, send the same UUID with the same body, never a new key for the same body and never the same key for a different body.
If you omit the header you get a 400 missing_idempotency_key.
5.3 qa_config#
Your account has a saved qa_config (set via the dashboard or by your operator). Any submission can override individual fields by sending a partial qa_config in the body. The merge is deep — what you do not send is inherited from your saved default.
Top-level shape:
{
"qa_config": {
"checks_enabled": { "<check_name>": true },
"thresholds": { "<threshold_key>": 0 },
"sub_checks": {
"<check_name>": {
"<sub_finding>": { "enabled": true, "severity": "FAIL" }
}
},
"exceptions": { "<check_name>": [] }
}
}
checks_enabled— boolean toggle per QA check. Whole checks can be turned off for an account or for a single submission.thresholds— numeric or string knobs that tune individual checks (minimum scores, minimum dimensions, duration floors, sensitivity levels, etc.). Each threshold key is a stable identifier; the catalog is visible in your dashboard.sub_checks— per-check tree of{enabled, severity}toggles for individual sub-findings.severityisWARNINGorFAIL.WARNINGnever blocks;FAILmarks the run as overallFAIL.exceptions— per-check allowlists (for example, words a textual check should ignore for your catalog).
Per-request override example#
The values you send are deep-merged on top of your saved config. Use it to tweak a single threshold or disable a single sub-check for one submission without changing your default:
{
"qa_config": {
"thresholds": { "<threshold_key>": <value> },
"checks_enabled": { "<check_name>": false }
}
}
The merged config is validated. A malformed override returns 400 invalid_qa_config_override.
5.4 Quota#
Your account has a lifetime cap configured at provisioning and visible in the dashboard. Each POST /qa/album consumes a number of units determined by your plan. If a submission would exceed the cap, it is rejected and nothing is consumed.
The dashboard shows used / limit live. When you are out:
{ "error": "quota_exhausted", "used": 200, "limit": 200, "unit": "<plan-specific>", "requested": 14 }
unit— string identifying the counter your plan uses. Treat as opaque (informational only — do not hardcode values).requested— the amount this submission would have consumed. Lets you tell0 leftapart from a partial shortfall (e.g.you only had 13 left and asked for 14).
The vocal-content endpoint (§2.4) has a separate allowance, counted in tracks. It is not shared with the album counter above: spending one does not affect the other. A batch either fits in the remaining allowance in full or is rejected whole.
Contact the Limbo team to raise either cap.
5.5 Status semantics — overall_status#
overall_status is the verdict your business logic should branch on. The four values map to four different actions — they are NOT interchangeable:
| Value | Meaning | What to do |
|---|---|---|
PASS |
No issues found. | Ship the release. |
WARNING |
Non-blocking findings (soft validation, style, etc.). | A human should review, but you can still ship. |
ERROR |
One or more checks could not be executed (timeout, transient failure, or malformed input). The QA did NOT reach a verdict on the release. | Retry the submission. Do NOT treat this as a verdict — the release has not been evaluated. |
FAIL |
The QA reached a verdict and found one or more blocking issues (e.g. catalog conflicts, duplicate identifiers, missing required metadata). | Do not ship until the issues in blocking_issues[] are fixed. |
The key distinction is
ERRORvsFAIL. -ERRORmeans we could not finish the analysis — retry the submission. -FAILmeans we analyzed the release and it has problems you must fix — do not ship.Treating them the same will either block valid releases (treating
ERRORasFAIL) or ship broken ones (treatingFAILasERROR). Always branch on the exact value.
Related fields:
summary.{passed,warnings,failed,errors}— check-level rollups (how many individual checks landed in each bucket).blocking_issues[]— list of FAIL-severity findings. Surface these whenoverall_status == "FAIL".warnings[]— list of WARNING-severity findings.
5.6 Status semantics — individual checks#
Every entry inside album_checks and track_checks carries its own status. It is not the same field as overall_status and it takes one extra value:
| Value | Meaning |
|---|---|
PASS |
The check ran and found nothing. |
WARNING |
The check ran and found something non-blocking. |
FAIL |
The check ran and found a blocking issue. |
ERROR |
The check could not complete. Nothing was concluded. |
SKIP |
The check did not apply, or the input it needs was not present. |
SKIPis not aPASS. APASSmeans we looked and found nothing. ASKIPmeans we did not look — because the check is turned off for your account, or because the submission did not carry the data that check needs. Reading it as a pass turns "not verified" into "verified clean", which is the one mistake this field exists to prevent.
When a check is skipped for lack of input, the entry carries a skip_reason string. Treat it as opaque (informational only — do not hardcode values). If a check matters to you and you see it skipped, the fix is usually to include the missing field in your submission.
Two consequences worth knowing:
- Skipped and incomplete checks are counted in
summary.total_checksbut land in no bucket, sopassed + warnings + failed + errorscan be less thantotal_checks. Do not derive one from the other. - A skipped check never changes
overall_status. An album whose every check was skipped still reportsPASSat the top level — the individual entries are the only place that distinction is visible.
6. Limits#
| Item | Value |
|---|---|
| Webhook retry policy | none currently |
| Lifetime quota | per account, see your dashboard |
| Tracks per vocal-content request | 5 minimum, 20 maximum |
| Vocal-content allowance | per account, counted in tracks, separate from the album quota |
7. Troubleshooting#
| Symptom | Likely cause |
|---|---|
403 tenant_unknown |
Wrong API key, or header not sent. Confirm x-api-key exactly. |
403 tenant_suspended |
Your access is not active. Contact Limbo. |
400 missing_idempotency_key |
You forgot the header. Required on every POST. |
400 Validation error: ... |
Body does not match the schema. The error message points to the field. |
409 request_in_flight |
A concurrent POST with the same key is still running. Wait and poll. |
422 idempotency_key_reuse |
You reused an Idempotency-Key with a body that differs from the original byte-for-byte. Use a fresh key for the new submission, or resend the exact original body to replay. |
| HMAC verification fails | You parsed and re-stringified the body. Use the raw bytes (§3.2). |
| HMAC verification fails (raw bytes used) | Wrong signing secret, or the leading <timestamp>. was forgotten. |
| Webhook never fires | callback_url was not HTTPS, or your endpoint returned non-2xx. Pull GET /report to confirm the run finished. |
| Cover image / audio URL not accessible | URLs must be reachable from the public internet. Pre-signed URLs are fine if they have not expired. |
404 Not Found even though I just submitted |
The pipeline is still in RUNNING — poll GET /status first, or wait for the webhook. |
8. Support#
Talk to engineering at info@limbomusic.com for any of the following:
- Integration & onboarding — full response specification, sample payloads, the complete check / threshold / sub-finding catalog, signing-secret and API-key provisioning.
- Account operations — lost credentials, quota bumps, account suspension or reactivation.
- API issues & bug reports — please include
qa_run_id, the requestIdempotency-Key, and the timestamp range of the incident. With those three values we can pin down the run in seconds.