Async Jobs & Webhooks

Heavy operations like stem separation, mastering, and audio-to-MIDI don’t fit inside a single request. They return 202 Accepted immediately with a job_id, and you get the final result either by polling or by receiving a webhook.

Which endpoints are async

Most of the API is synchronous — metadata lookups, BPM/key analysis, and feature extraction return the result in the same response. Only GPU-bound operations run as jobs.

EndpointModeTypical duration
POST /v1/separate (2-stem / 4-stem)async15–90s
POST /v1/transcribe (audio-to-MIDI)async20–120s
POST /v1/master (auto-mastering)async10–40s
POST /v1/analyze/uploadasync5–30s
All other endpointssync<500 ms

The 202 flow

When you POST to an async endpoint, the API accepts the upload, enqueues the job, and returns immediately with a job_id and a poll_url. The actual result arrives later — either when you poll that URL, or when TuneLab POSTs it to your configured webhook.

202 Accepted
{
  "job_id": "job_01HYZ3K8A2F4PQRS7T9V1XW",
  "status": "queued",
  "poll_url": "https://api.tunelab.dev/v1/jobs/job_01HYZ3K8A2F4PQRS7T9V1XW",
  "estimated_completion": "2026-04-09T14:32:18Z",
  "_meta": {
    "credits_reserved": 10,
    "credits_remaining": 890,
    "trace_id": "afa-9e2b3f8a"
  }
}

Credits are reserved at submission and charged on success. If the job fails for a reason on our side (internal_error, service_unavailable), the reservation is refunded automatically.

Polling pattern

Good for short jobs, one-off scripts, and interactive UIs where you want a progress spinner. Poll GET /v1/jobs/{job_id} until status is completed or failed.

python polling
import time, requests

API = "https://api.tunelab.dev"
HEADERS = {"Authorization": "Bearer tl_live_YOUR_KEY"}

# 1. Submit the job
with open("track.wav", "rb") as f:
    r = requests.post(
        f"{API}/v1/separate",
        headers=HEADERS,
        files={"audio": f},
        data={"stems": "4"},
    )
r.raise_for_status()
job = r.json()
job_id = job["job_id"]
print(f"queued: {job_id}")

# 2. Poll with exponential backoff (2s, 3s, 4s ... max 15s)
delay = 2
while True:
    r = requests.get(f"{API}/v1/jobs/{job_id}", headers=HEADERS)
    r.raise_for_status()
    status = r.json()
    if status["status"] == "completed":
        for stem, url in status["result"]["stems"].items():
            print(f"{stem}: {url}")
        break
    if status["status"] == "failed":
        raise RuntimeError(status["error"])
    time.sleep(delay)
    delay = min(delay + 1, 15)
Back off, don't hammer.
Start at 2s and grow to 15s. Polling faster than once per second on the same job returns 429 rate_limit_exceeded — the job state doesn't change that fast anyway.

Webhook pattern (preferred)

Configure a webhook URL once in the platform dashboard. Every async job completion POSTs a JSON body to your URL — no polling loop, no idle compute, no race conditions.

express receiver
import express from "express";
import crypto from "crypto";

const app = express();
const SECRET = process.env.TUNELAB_WEBHOOK_SECRET;

// IMPORTANT: capture the raw body for signature verification
app.post(
  "/webhooks/tunelab",
  express.raw({ type: "application/json" }),
  (req, res) => {
    const sig = req.header("X-TuneLab-Signature");
    if (!verify(sig, req.body, SECRET)) {
      return res.status(401).send("bad signature");
    }

    const event = JSON.parse(req.body.toString("utf8"));
    switch (event.type) {
      case "job.completed":
        console.log(`job ${event.data.job_id} ok`, event.data.result);
        // enqueue your own work — download, store, notify user, etc.
        break;
      case "job.failed":
        console.warn(`job ${event.data.job_id} failed`, event.data.error);
        break;
    }
    // Ack fast. Do heavy work async — we time out at 10s.
    res.status(200).send("ok");
  }
);

function verify(header, body, secret) {
  if (!header) return false;
  const parts = Object.fromEntries(
    header.split(",").map((p) => p.split("="))
  );
  const expected = crypto
    .createHmac("sha256", secret)
    .update(`${parts.t}.${body.toString("utf8")}`)
    .digest("hex");
  return crypto.timingSafeEqual(
    Buffer.from(expected),
    Buffer.from(parts.v1)
  );
}

app.listen(3000);
!
Respond within 10 seconds.
Your endpoint must return a 2xx within 10s or we treat the delivery as failed and retry. Do any heavy work (downloads, DB writes, user notifications) on your own queue after you've already ack'd.

Webhook signing

Every webhook carries an X-TuneLab-Signature header so you can prove it came from us and hasn’t been tampered with. The format is:

header
X-TuneLab-Signature: t=1712678400,v1=5257a869e7ecebeda32affa62cdca3fa7e4f7b7d1e5b7e8c9a0b1c2d3e4f5a6b

t is the Unix timestamp when the signature was generated. v1 is the hex-encoded HMAC-SHA256 of <timestamp> + "." + <raw_body> using your webhook secret.

python verification
import hmac, hashlib, time
from flask import Flask, request, abort

app = Flask(__name__)
SECRET = b"whsec_YOUR_WEBHOOK_SECRET"
TOLERANCE = 300  # 5 minutes

@app.post("/webhooks/tunelab")
def receive():
    header = request.headers.get("X-TuneLab-Signature", "")
    parts = dict(p.split("=", 1) for p in header.split(","))
    ts, sig = parts.get("t"), parts.get("v1")
    if not ts or not sig:
        abort(401)

    # 1. Replay protection — reject anything older than 5 minutes
    if abs(time.time() - int(ts)) > TOLERANCE:
        abort(401, "stale signature")

    # 2. Constant-time comparison of HMAC
    payload = f"{ts}.{request.get_data(as_text=True)}"
    expected = hmac.new(SECRET, payload.encode(), hashlib.sha256).hexdigest()
    if not hmac.compare_digest(expected, sig):
        abort(401, "bad signature")

    event = request.get_json()
    handle(event)
    return "", 200
×
Always use constant-time comparison.
Never compare signatures with == — that leaks timing information and is exploitable. Use hmac.compare_digest in Python, crypto.timingSafeEqual in Node, or the equivalent in your language.

Retry behavior

If your endpoint returns a non-2xx status or times out, TuneLab retries up to 5 times with exponential backoff:

AttemptWait before retry
1immediate
21 minute
35 minutes
415 minutes
51 hour
64 hours

After 5 consecutive failures the webhook endpoint is disabled automatically and you’ll see an alert in the dashboard. Re-enable it once your receiver is fixed. The 20 most recent delivery attempts — successful and failed — are visible in the platform dashboard’s delivery log with full request/response bodies for debugging.

Idempotency

TuneLab guarantees at-least-once delivery, not exactly-once. Your receiver may see the same event more than once in rare cases (e.g. a network blip between our delivery and your 200 response). Deduplicate by job_id — it’s unique per job and stable across retries.

dedupe
# Simple in-memory dedupe; use Redis / DB in production
seen = set()

def handle(event):
    job_id = event["data"]["job_id"]
    if job_id in seen:
        return  # already processed, ack and move on
    seen.add(job_id)
    # ... do the work

You can also make the submission side idempotent. Pass an Idempotency-Key header when submitting a job — if you retry the same request (same key, within 24 hours), you get back the same job_id instead of a duplicate job. Useful when your client crashes mid-upload and you don’t know if the request went through.

idempotent submit
curl https://api.tunelab.dev/v1/separate \
  -H "Authorization: Bearer tl_live_YOUR_KEY" \
  -H "Idempotency-Key: order-4829-separate-v1" \
  -F "audio=@track.wav" \
  -F "stems=4"

Webhook event types

Two events fire today. More will be added as the platform grows.

EventWhenPayload highlights
job.completed Job finished successfully data.job_id, data.result (URLs + metadata), data.credits_used
job.failed Job failed permanently data.job_id, data.error.code, data.error.message, data.trace_id
job.completed payload
{
  "type": "job.completed",
  "created": 1712678400,
  "data": {
    "job_id": "job_01HYZ3K8A2F4PQRS7T9V1XW",
    "endpoint": "/v1/separate",
    "status": "completed",
    "result": {
      "stems": {
        "vocals":  "https://cdn.tunelab.dev/r/abc/vocals.wav",
        "drums":   "https://cdn.tunelab.dev/r/abc/drums.wav",
        "bass":    "https://cdn.tunelab.dev/r/abc/bass.wav",
        "other":   "https://cdn.tunelab.dev/r/abc/other.wav"
      },
      "expires_at": "2026-04-16T14:32:18Z"
    },
    "credits_used": 10,
    "trace_id": "afa-9e2b3f8a"
  }
}
i
Polling vs webhooks — which to use?
Polling: short jobs under 30 seconds, interactive UIs with a progress spinner, one-off scripts, local dev. Webhooks: batch processing, background pipelines, production systems where idle polling wastes compute, anything longer than a minute.

Next steps