limboBeat Docs

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:

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"
}

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:

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:

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.


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#

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#

  1. 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.
  2. Constant-time compare. Use your language's constant-time HMAC comparison primitive. A plain == leaks the secret one byte at a time via timing.
  3. Check the timestamp. Reject requests where X-QA-Timestamp is older than ~5 minutes — this prevents replay attacks where an attacker who once observed a valid request keeps sending it forever.
  4. TLS only. Your callback_url must 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

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>": [] }
  }
}

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 }

Contact the Limbo team to raise your 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 ERROR vs FAIL. - ERROR means we could not finish the analysis — retry the submission. - FAIL means we analyzed the release and it has problems you must fix — do not ship.

Treating them the same will either block valid releases (treating ERROR as FAIL) or ship broken ones (treating FAIL as ERROR). Always branch on the exact value.

Related fields:


6. Limits#

Item Value
Webhook retry policy none currently
Lifetime quota per account, see your dashboard

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: