limbo/ Docs
Sandbox

Limbo QA — API Integration Guide#

This guide is for developers integrating with the Limbo QA 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 QA 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
API base https://api.sandbox.qaagent.limbobeat.com/v1
Dashboard https://sandbox.qaagent.limbobeat.com

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.sandbox.qaagent.limbobeat.com

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 spell-check and OCR.
production_line string no Copyright/production line (e.g. 2026 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 text in OCR and lyrics is treated as expected.
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, spell-check + offensive-text run on 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-2026-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": "2026-06-15",
    "upc": "1234567890128",
    "language": "en",
    "production_line": "2026 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-2026-00042-01",
      "title": "Sample Album Title",
      "main_artist": ["Example Artist"],
      "isrc": "USXXX2600001",
      "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": "2026 My Label Records",
      "composer": ["Example Artist"],
      "lyricist": ["Example Artist"],
      "producer": ["John Doe"]
    }
  ]
}

Response — 202 Accepted#

{
  "album_id": "MY-LABEL-2026-00042",
  "qa_run_id": "QA-2026-05-22-cde10f64",
  "status": "QUEUED",
  "status_url": "/qa/album/MY-LABEL-2026-00042/status?qa_run_id=QA-2026-05-22-cde10f64",
  "report_url": "/qa/album/MY-LABEL-2026-00042/report?qa_run_id=QA-2026-05-22-cde10f64"
}

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 — 500 Internal Server Error#

Transient failure. Quota is rolled back; 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.sandbox.qaagent.limbobeat.com

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-2026-00042",
  "qa_run_id": "QA-2026-05-22-cde10f64",
  "status": "COMPLETED",
  "overall_status": "WARNING",
  "started_at": "2026-05-22T01:14:32.821707+00:00",
  "completed_at": "2026-05-22T01:14:47.241891+00:00",
  "fingerprint_completed_at": "2026-05-22T01:16:02.114553+00:00",
  "track_count": "1",
  "album_title": "Sample Album Title",
  "main_artist": "Example Artist",
  "label": "My Label Records",
  "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-2026-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.sandbox.qaagent.limbobeat.com

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#

Truncated for readability. See §5.5 for how to interpret overall_status, blocking_issues, and warnings.

{
  "album_id": "MY-LABEL-2026-00042",
  "qa_run_id": "QA-2026-05-22-cde10f64",
  "upc": "1234567890128",
  "timestamp": "2026-05-22T01:16:02.114553+00:00",
  "overall_status": "WARNING",
  "fingerprint_pending": false,
  "summary": {
    "total_checks": 9,
    "passed": 7,
    "warnings": 2,
    "failed": 0,
    "errors": 0
  },
  "blocking_issues": [],
  "warnings": [
    {
      "check": "image_moderation",
      "target": "https://my-cdn.example.com/covers/00042.jpg",
      "category": "web_full_match",
      "confidence": 1.0,
      "severity": "WARNING",
      "detail": "5 exact image match(es) found on web",
      "urls": ["https://www.youtube.com/watch?v=...", "..."]
    }
  ],
  "album_checks": {
    "image_moderation": { "check": "image_moderation", "status": "PASS", "images": [...] },
    "image_ocr":        { "check": "image_ocr",        "status": "PASS", "images": [...] },
    "spell_check":      { "check": "spell_check",      "status": "PASS", "issues": [], "fields_checked": {...} }
  },
  "track_checks": {
    "MY-LABEL-2026-00042-01": {
      "metadata_validation":    { "check": "metadata_validation", "status": "PASS", "audio_specs": {...} },
      "audio_silence":          { "check": "audio_silence",       "status": "PASS", "silences": [] },
      "audio_spectrogram":      { "check": "audio_spectrogram",   "status": "PASS", "spectrogram_url": "..." },
      "audio_speech_detection": { "check": "audio_speech_detection","status": "PASS", "classification": "MUSIC" },
      "audio_fingerprint": {
        "track_id": "MY-LABEL-2026-00042-01",
        "status": "PASS",
        "matches_found": 1,
        "matches": [{
          "title": "Sample Album Title",
          "score": "100",
          "artists": ["Example Artist"],
          "release_date": "2025-06-06"
        }],
        "ai_detection": [{ "prediction": "human", "ai_probability": "9.0" }]
      }
    }
  },
  "track_info":     { "MY-LABEL-2026-00042-01": { "title": "...", "track_number": 1, "isrc": "..." } },
  "album_metadata": { "title": "...", "main_artist": ["..."], "label": "..." },
  "ai_review": {
    "executive_summary": "The release is consistent with the declared metadata...",
    "issue_reviews": [
      { "check": "image_moderation", "verdict": "agree", "reasoning": "..." }
    ],
    "missed_issues": []
  },
  "qa_config": { "checks_enabled": {...}, "thresholds": {...}, "sub_checks": {...} }
}

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 comparison primitive (e.g. hmac.compare_digest in Python, crypto.timingSafeEqual in Node). 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 at 10 seconds between tests and 20 tests per day.

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 — the data is persisted on our side for the run's retention period.


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.sandbox.qaagent.limbobeat.com
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.sandbox.qaagent.limbobeat.com
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.sandbox.qaagent.limbobeat.com
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 deduped lists of findings live in blocking_issues[] (FAIL-severity) and warnings[] (WARNING-severity).


5. Concepts#

5.1 Status lifecycle#

QUEUED  ──►  RUNNING  ──►  PENDING_FINGERPRINT  ──►  COMPLETED

If you poll, 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, completed prior call Cached response returned. No rerun. No extra quota charged.
Same key, prior call still running 409 request_in_flight. Wait, then poll the cached response.
Same key, prior call failed mid-flight The slot is released; safe to retry with the same key.
New key Fresh submission. Quota charged.

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, never a new one.

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": { ... },
    "thresholds":     { ... },
    "sub_checks":     { ... },
    "exceptions":     { ... }
  }
}

checks_enabled#

Toggles whole checks on/off. Keys (boolean):

thresholds#

Common knobs (full list visible in the dashboard):

Key Purpose
silence_db_threshold Audio below this level (dBFS) counts as silence. More negative = stricter.
track_min_duration_seconds Minimum track length. Shorter tracks fail.
fingerprint_min_score Minimum fingerprint match score to flag a hit.
cover_min_width Minimum cover width in pixels.
cover_max_file_size_mb Maximum cover file size in MB.
ai_detection_min_probability Threshold above which AI-generation detection flags a track.

sub_checks#

Each check exposes a tree of {enabled, severity} toggles for individual sub-findings:

"sub_checks": {
  "metadata_validation": {
    "isrc_format":   { "enabled": true,  "severity": "FAIL" },
    "duplicate_isrc":{ "enabled": true,  "severity": "FAIL" },
    "track_numbering":{"enabled": true, "severity": "FAIL" }
  },
  "audio_fingerprint": {
    "copyright_match":{ "enabled": true, "severity": "FAIL" },
    "ai_generated":   { "enabled": true, "severity": "FAIL" },
    "cover_version":  { "enabled": true, "severity": "WARNING" }
  }
}

severity is WARNING or FAIL. WARNING never blocks; FAIL marks the run as overall FAIL.

exceptions#

Per-check allowlists. Current key: spell_check. Words in this list are not flagged.

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": { "fingerprint_min_score": 85 },
    "checks_enabled": { "audio_silence_detection": 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. The check is atomic: 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 of an external dependency, 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. copyright match, duplicate ISRC, 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
API throttle (per account) 10 req/s sustained, 20 burst
Webhook retry policy none currently
Synthetic webhook test 10 s cooldown, 20/day
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.
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#

When reporting a bug, please include: qa_run_id, request Idempotency-Key, and the timestamp range of the incident. With those values we can pin down the run in seconds.