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.
| Endpoint | Mode | Typical duration |
|---|---|---|
POST /v1/separate (2-stem / 4-stem) | async | 15–90s |
POST /v1/transcribe (audio-to-MIDI) | async | 20–120s |
POST /v1/master (auto-mastering) | async | 10–40s |
POST /v1/analyze/upload | async | 5–30s |
| All other endpoints | sync | <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.
{
"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.
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)
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.
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);
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:
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.
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
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:
| Attempt | Wait before retry |
|---|---|
| 1 | immediate |
| 2 | 1 minute |
| 3 | 5 minutes |
| 4 | 15 minutes |
| 5 | 1 hour |
| 6 | 4 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.
# 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.
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.
| Event | When | Payload 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 |
{
"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"
}
}
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
- Errors — error envelope, retry strategy, trace IDs.
- Job polling endpoint reference — full response schema.
- Webhook dashboard — configure URL, rotate secret, view delivery log.