Upload a recording.
Get one track per speaker.
DUETA has one endpoint that does work. Post a two-person recording to /v1/jobs/separation, choose which parts of the pipeline should run, and poll the job until two clean speaker tracks come back.
Overview
A separation job takes one audio file and returns two mono WAV tracks, one per speaker. You choose which phases run when you submit, and the job runs them in the order below.
- 01vocal_separationoptional
- 02separationalways runs
- 03enhancementoptional
- 04super_resolutionoptional
- 05si_sdroptional
Speaker separation is the job itself and always runs. The other four are arguments on the request, and turning one off removes it from the stage sequence you poll and from the time the job takes, but not from the price.
Quickstart
Start to finish: create an upload, send the bytes, make a job from its id, follow it, and download the tracks. Everything below works from a terminal with no browser step, and signing up grants $20 of credit to try it with.
https://drugs-introduction-exchanges-likely.trycloudflare.com, is a temporary address while the permanent domain is set up. It works today, and the samples will be reissued against the final host when there is one.# 1. Declare the recording. Nothing is charged; an upload is a file we hold.
UPLOAD=$(curl -sS -X POST https://drugs-introduction-exchanges-likely.trycloudflare.com/v1/uploads \
-H "Authorization: Bearer $DUETA_API_KEY" \
-H "Content-Type: application/json" \
-d "{\"filename\": \"conversation.wav\", \"size_bytes\": $(wc -c < conversation.wav)}" \
| jq -r .id)
# 2. Send the bytes. Raw body, not multipart. When the last one lands the file
# is decoded here and the upload turns "ready" with its real duration.
curl -sS -X PUT https://drugs-introduction-exchanges-likely.trycloudflare.com/v1/uploads/$UPLOAD/content \
-H "Authorization: Bearer $DUETA_API_KEY" \
-H "Content-Type: application/octet-stream" \
--data-binary @conversation.wav | jq '{state, duration_seconds, sample_rate}'
# 3. Make the job from the id. Defaults run every phase.
JOB=$(curl -sS -X POST https://drugs-introduction-exchanges-likely.trycloudflare.com/v1/jobs/separation \
-H "Authorization: Bearer $DUETA_API_KEY" \
-H "Content-Type: application/json" \
-d "{\"inputs\": [\"$UPLOAD\"]}" | jq -r .id)
# 4. Poll until the job leaves queued/running.
while :; do
JSON=$(curl -sS -H "Authorization: Bearer $DUETA_API_KEY" https://drugs-introduction-exchanges-likely.trycloudflare.com/v1/jobs/$JOB)
# steps[] is the API's own account of the pipeline: no inference needed.
echo "$JSON" | jq -r '[.steps[] | select(.state == "running")][0]
| "\(.name) \((.progress // 0) * 100 | floor)%"'
case $(echo "$JSON" | jq -r .status) in
succeeded) break ;;
failed|canceled) echo "$JSON" | jq -r .error; exit 1 ;;
esac
sleep 2
done
# 5. Download both tracks. The job carries names, not URLs.
echo "$JSON" | jq -r '.result.stems[].name' | while read -r NAME; do
curl -sS -H "Authorization: Bearer $DUETA_API_KEY" \
-o "$NAME.wav" "https://drugs-introduction-exchanges-likely.trycloudflare.com/v1/jobs/$JOB/stems/$NAME"
done- Upload.
POST /v1/uploadsdeclares the recording, thenPUT /v1/uploads/{id}/contentsends its raw bytes. When the last one lands the file is decoded here and the upload turnsready, carrying its real duration. Nothing is charged yet. - Submit.
POST /v1/jobs/separationwith{"inputs": ["upl_…"]}. No bytes in this request. The response is a job that has not started yet. - Follow it.
GET /v1/jobs/{id}untilstatusleavesqueued/running, or subscribe to/v1/jobs/{id}/events.steps,queue_position,progressandeta_secondssay where it is. - Download. Read the names from
result.stems[].nameand fetch each fromGET /v1/jobs/{id}/stems/{name}.
Authentication
Every request carries a bearer token. API keys are issued from POST /v1/keys, begin with mk_live_, and are shown in full only at creation, so store the secret then; afterwards only its prefix is ever returned.
Authorization: Bearer mk_live_<rest_of_key>The short-lived session token from /v1/auth/signup and /v1/auth/login is also accepted, which is what makes the bootstrap below possible from a bare terminal. Prefer a key for anything long-lived: sessions expire, and the key-management endpoints deliberately accept only a session token, so a leaked key cannot mint more keys.
# 1. Create an account. No browser needed; a starting credit grant comes with it.
curl -X POST https://drugs-introduction-exchanges-likely.trycloudflare.com/v1/auth/signup \
-H "Content-Type: application/json" \
-d '{"email": "you@example.com", "password": "a-strong-password"}'
# -> {"access_token": "eyJ...", "token_type": "bearer"}
# 2. Trade the session token for a long-lived API key.
curl -X POST https://drugs-introduction-exchanges-likely.trycloudflare.com/v1/keys \
-H "Authorization: Bearer eyJ..." \
-H "Content-Type: application/json" \
-d '{"name": "production-server"}'
# -> {"id": "...", "name": "production-server", "prefix": "mk_live_8fQ2",
# "key": "mk_live_8fQ2...", "created_at": "..."} <- full secret, shown once
# 3. Use that key for everything below.
export DUETA_API_KEY="mk_live_8fQ2..."POST /v1/keys/{id}/rotate returns a new secret and the old one stops working in the same instant, so update the consumers before you rotate, not after. A revoked or rotated-away key answers 401.Google sign-in is not enabled on this deployment; use email and password, or a key.
Uploading audio
WAV, MP3, FLAC, and AIFF are accepted. A separation job takes exactly 1 recording; dominant separation takes 2-10. Every limit below is checked by this service, on the file as we stored it, so a number you were told here is the number you are billed on.
| Requirement | Value | If it is not met |
|---|---|---|
| count | 1 audio file for separation | 400: "separation takes exactly 1 audio file (got 2)" |
| format | WAV, MP3, FLAC, AIFF | 400: the rejected type is named in detail |
| duration (per file) | At least 0.5 seconds | 400, at upload time: "tiny.wav is only 0.10s long. Audio must be at least 0.5s long." |
| duration (per job) | Up to 2 hours total — every input on the job added together, not any one file | 400, at job creation (POST /v1/jobs/quote reports the identical sentence as a reasons entry instead): "Total duration 8000s exceeds the cap of 7200s" |
| size | Up to 200 MB per file | 413, refused mid-stream rather than after the whole transfer |
| signal | Not digital silence | 400: a silent file can only produce a meaningless result |
Two ways in
Audio reaches a job by one of two routes, and they converge on the same implementation inside the API, so neither can drift into different rules from the other.
| Route | When |
|---|---|
| POST /v1/uploads → PUT …/content → POST /v1/jobs/… | Write new code against this one. The transfer is a resource while it is happening, so its progress survives a reload and any client holding the id can read it; an interrupted transfer resumes from the server's own byte count instead of starting again; the file is decoded and measured before you commit to a price; and the same recording can be run through several jobs without being sent twice. |
| POST /v1/jobs/… with multipart or url | Unchanged, supported, and the shortest thing to type. One request carries the files (or a url= we fetch server-side) and hands back a queued job. It reports nothing while the bytes are in flight, because the job does not exist until the whole body has been read. |
A multipart submission now creates uploads internally and then follows the identical path, so the job it answers with carries inputs[].upload_id like any other — re-usable ids for recordings you sent the old way. Nothing about an existing integration has to change, and nothing about the result differs; only how the bytes arrived.
Content-Range, quotes with POST /v1/jobs/quote, and subscribes to /events — every call on this page, with a session cookie in place of a bearer key. There is no private route behind it.Send the file as you recorded it
Do not downmix, resample, or normalise first. The opening phase uses the stereo image, so a file you have already collapsed to mono gives it less to work with.
Lossy input is fine: a 128 kbps MP3 of a real conversation separates better than a pristine recording of two people sharing one distant microphone. Every conversion the later phases need happens server-side, and every output track is mono whatever went in.
- Input sample rate does not matter. Anything from 8 kHz telephone audio upward is accepted and resampled internally. The output rate is decided by
super_resolution, not by the input. - Length is what you pay for. Trimming leading silence or a long musical intro before uploading is the one edit that reliably saves credit.
- Bad input fails fast. Duration and silence are checked when the upload is decoded — before any job exists and before any credit is held — so a file that cannot work is rejected in seconds rather than minutes later inside a model.
Uploads
A recording is a resource of its own before it is a job. You declare it, send its bytes, and then name its id when you create work from it — which is what lets the transfer be watched while it happens, resumed when it breaks, and re-used by as many jobs as you like without sending the file a second time.
/v1/uploads/v1/uploads/{id}/content/v1/uploads/{id}/v1/uploads/v1/uploads/{id}/events/v1/uploads/{id}Declare it
POST /v1/uploads takes JSON: {"filename", "size_bytes"?, "content_type"?, "callback_url"?}. It answers 201 with an upload in state pending, a content_url to send the bytes to, and an events_url to subscribe to.
size_bytesis optional and worth sending. It is what lets a client draw a determinate bar from 0%, it is checked against the 200 MB per-file cap before a byte moves, and a transfer whose length does not match it is refused rather than stored as a file that stops early.content_typeis recorded, not trusted. The file is identified by decoding it.callback_urlreturns acallback_secret— once, in this response only, and never echoed by a later read. See Callbacks & streaming.
// POST /v1/uploads -> 201
{
"id": "upl_9f2c7a41d0e8",
"filename": "alice.wav",
"content_type": null,
"size_bytes": 91240044,
"bytes_received": 0,
"state": "pending",
"duration_seconds": null,
"sample_rate": null,
"channels": null,
"checksum_sha256": null,
"error": null,
"created_at": "2026-08-28T09:21:44Z",
"expires_at": "2026-08-29T09:21:44Z",
"content_url": "/v1/uploads/upl_9f2c7a41d0e8/content",
"events_url": "/v1/uploads/upl_9f2c7a41d0e8/events",
"callback_secret": null
}
// GET /v1/uploads/upl_9f2c7a41d0e8 — the transfer is running.
// bytes_received is the server's own count: the number to resume from.
{ "state": "receiving", "bytes_received": 43122688, "size_bytes": 91240044,
"duration_seconds": null, "checksum_sha256": null, "error": null }
// The last byte landed and the file was decoded HERE, on our disk.
// Everything below is measured, not declared.
{ "state": "ready", "bytes_received": 91240044, "size_bytes": 91240044,
"duration_seconds": 612.4, "sample_rate": 48000, "channels": 2,
"checksum_sha256": "b1946ac92492d2347c6235b4d2611184...", "error": null }
// A refusal leaves the upload readable, with the reason a person can act on.
{ "state": "failed", "bytes_received": 0,
"error": "tiny.wav is only 0.10s long. Audio must be at least 0.5s long." }Send the bytes
PUT /v1/uploads/{id}/content carries the file itself as its body — Content-Type: application/octet-stream, not multipart. There is exactly one file and no fields, so every byte counted is a byte of audio, which is what makes bytes_received mean what it says.
Send it in one request, or in pieces with Content-Range: bytes {start}-{end}/{total}. Each piece must start exactly at the upload's own bytes_received, and that is how a transfer is resumed: read the upload, continue from the offset it reports. A piece that starts anywhere else is a 409 naming the offset it should have used. Only the complete range form is accepted — * for the total would leave the API unable to say when the file is finished.
bytes_received is the only number that knows how many. A refusal — too large, the wrong length, undecodable — is different: it discards what arrived and leaves the upload failed, so that file starts again at byte 0.When the last byte lands the file is decoded here, on our disk, and the upload moves to ready carrying duration_seconds, sample_rate, channels and checksum_sha256. All four are measured server-side. They are the numbers every later step uses: the price you are quoted, the length you are billed for, and what the model processes. A client may show its own estimate while a transfer is in flight; what it displays once these exist is these.
States
| State | Meaning |
|---|---|
| pending | Declared. No bytes yet. |
| receiving | Bytes are arriving. bytes_received climbs and only moves forward, so it can drive a bar without smoothing — and a second client, or the same one after a reload, reads the same number, because it is not stored in a browser. |
| ready | Every byte is in and the file decoded. The only state a job can be created from. |
| failed | Refused, with the reason in error in words a user can act on. Sending the file again from byte 0 reopens it. |
| expired | Swept, with its bytes. PUTing to it answers 410. |
How long an upload lives
- 24 hours if nothing uses it. An unused upload is swept away with its bytes.
- Creating a job pushes that out, so a recording you are working with does not expire underneath you.
- A job hardlinks the audio, so an upload expiring — or being deleted once nothing references it — can never take a finished job's input away.
DELETE /v1/uploads/{id}removes an upload and its bytes and answers204. It is refused with409while a job still references it: a job's record of what it ran on and what it charged for must not be able to dissolve. Those uploads go when their jobs do.
GET /v1/uploads lists yours, newest first, with limit (default 20, max 100), offset, and an optional state= filter. It is what a client comes back to after losing its own state: the recordings this account holds, and which of them are ready to make a job from.
Price the set before you commit
/v1/jobs/quote{"type", "inputs": [ids]} answers what a job over those uploads would cost, on the same server-measured durations the submission will use. It reserves nothing — no credit is held, no job is created, and the price is not guaranteed against a rate change between the two calls. The figure that is actually frozen is estimated_cost_usd on the job.
Every rejection the real submission applies comes back as a sentence in reasons[] rather than as an error, so a half-assembled set still gets a price and an explanation of what is wrong with it. can_submit is the single field that says whether POST /v1/jobs/{product} would accept the set as it stands. billable_seconds is the longest input, once; total_duration_seconds is every input added up. Two different numbers on purpose: the second is what the cap below is checked against.
// POST /v1/jobs/quote
// {"type": "dominant_separation", "inputs": ["upl_9f2c...", "upl_4a81..."]}
{
"type": "dominant_separation",
"inputs": ["upl_9f2c7a41d0e8", "upl_4a81be03c5f7"],
"billable_seconds": 612.4, // the LONGEST input, once, whatever N is
"total_duration_seconds": 1180.2, // every input added up — capped at 7200s (2h) per job
"estimated_cost_usd": 5.1,
"balance_usd": 20.0,
"sufficient_credit": true,
"can_submit": true,
"reasons": []
}
// A set that is not submittable yet. Every rejection the real submission
// applies comes back as a sentence rather than as an error, so a
// half-assembled set still gets a price and an explanation.
{
"billable_seconds": 612.4,
"estimated_cost_usd": 5.1,
"balance_usd": 1.2,
"sufficient_credit": false,
"can_submit": false,
"reasons": [
"These uploads have not finished: upl_4a81be03c5f7",
"This job costs more than your balance. Add credit in the console."
]
}The cap: 2 hours, per job, across every input added together — not per file, and not the same 0.5-second-minimum check that happens earlier at upload time. A set whose durations sum past it is rejected: as a sentence in quote's reasons[] (nothing is reserved either way), or as a 400 from the real submission.
// POST /v1/jobs/quote, over the cap — reported as a reason, nothing raised
{ "reasons": ["Total duration 8000s exceeds the cap of 7200s"], "can_submit": false, ... }
// POST /v1/jobs/{product}, over the cap -> 400. THE SAME SENTENCE, to the
// character: both paths read it from one formatter server-side, so a client can
// show the quote's reason as the refusal it is predicting.
{ "detail": "Total duration 8000s exceeds the cap of 7200s" }The whole sequence
Create the uploads, send their bytes (including the resumed variant), watch one land, price the pair, and create the job from the ids.
# 1. Declare each recording. Nothing is charged and nothing is queued.
# size_bytes is optional and worth sending: it draws a determinate bar,
# it refuses an oversized file before a byte moves, and a body whose
# length does not match it is refused rather than stored truncated.
A=$(curl -sS -X POST https://drugs-introduction-exchanges-likely.trycloudflare.com/v1/uploads \
-H "Authorization: Bearer $DUETA_API_KEY" \
-H "Content-Type: application/json" \
-d "{\"filename\": \"alice.wav\", \"size_bytes\": $(wc -c < alice.wav)}" | jq -r .id)
B=$(curl -sS -X POST https://drugs-introduction-exchanges-likely.trycloudflare.com/v1/uploads \
-H "Authorization: Bearer $DUETA_API_KEY" \
-H "Content-Type: application/json" \
-d "{\"filename\": \"bob.wav\", \"size_bytes\": $(wc -c < bob.wav)}" | jq -r .id)
# -> two uploads in state "pending", each with a content_url and an events_url.
# 2. Send the bytes. The body is the file itself — raw, not multipart.
curl -sS -X PUT https://drugs-introduction-exchanges-likely.trycloudflare.com/v1/uploads/$A/content \
-H "Authorization: Bearer $DUETA_API_KEY" \
-H "Content-Type: application/octet-stream" \
--data-binary @alice.wav
curl -sS -X PUT https://drugs-introduction-exchanges-likely.trycloudflare.com/v1/uploads/$B/content \
-H "Authorization: Bearer $DUETA_API_KEY" \
-H "Content-Type: application/octet-stream" \
--data-binary @bob.wav
# 2b. RESUMING after an interruption. Ask what we have, then send from
# exactly there. A piece that starts anywhere else is a 409 naming the
# offset it should have used.
HAVE=$(curl -sS -H "Authorization: Bearer $DUETA_API_KEY" \
https://drugs-introduction-exchanges-likely.trycloudflare.com/v1/uploads/$A | jq -r .bytes_received)
SIZE=$(wc -c < alice.wav)
tail -c +$((HAVE + 1)) alice.wav | curl -sS -X PUT https://drugs-introduction-exchanges-likely.trycloudflare.com/v1/uploads/$A/content \
-H "Authorization: Bearer $DUETA_API_KEY" \
-H "Content-Type: application/octet-stream" \
-H "Content-Range: bytes $HAVE-$((SIZE - 1))/$SIZE" \
--data-binary @-
# 3. Watch one land. Poll the upload...
curl -sS -H "Authorization: Bearer $DUETA_API_KEY" https://drugs-introduction-exchanges-likely.trycloudflare.com/v1/uploads/$A |
jq -r '"\(.state) \(.bytes_received)/\(.size_bytes)"'
# ...or subscribe, which is the same body pushed instead of asked for.
curl -sS -N -H "Authorization: Bearer $DUETA_API_KEY" https://drugs-introduction-exchanges-likely.trycloudflare.com/v1/uploads/$A/events
# 4. Price the pair BEFORE committing to a job. Reserves nothing.
curl -sS -X POST https://drugs-introduction-exchanges-likely.trycloudflare.com/v1/jobs/quote \
-H "Authorization: Bearer $DUETA_API_KEY" \
-H "Content-Type: application/json" \
-d "{\"type\": \"dominant_separation\", \"inputs\": [\"$A\", \"$B\"]}"
# -> {"billable_seconds": 612.4, "estimated_cost_usd": 5.10, "can_submit": true, ...}
# 5. Create the job from the ids. ORDER MATTERS on this product: the first
# recording is the timeline the others are aligned against.
JOB=$(curl -sS -X POST https://drugs-introduction-exchanges-likely.trycloudflare.com/v1/jobs/dominant-separation \
-H "Authorization: Bearer $DUETA_API_KEY" \
-H "Content-Type: application/json" \
-d "{\"inputs\": [\"$A\", \"$B\"]}" | jq -r .id)
# 6. Read the job. steps[] is the API's own account of what is running.
curl -sS -H "Authorization: Bearer $DUETA_API_KEY" https://drugs-introduction-exchanges-likely.trycloudflare.com/v1/jobs/$JOB |
jq '{status, queue_position, steps: [.steps[] | "\(.name) \(.state)"]}'POST /v1/jobs/separation and POST /v1/jobs/dominant-separation take {"inputs": ["upl_…"], "pipeline": {…}, "callback_url": "…"}. On dominant separation the order matters: the first recording is the timeline every other one is aligned against.Running a separation
/v1/jobs/separationThe body is JSON: {"inputs": ["upl_…"], "pipeline": {…}, "callback_url": "…"}. inputs names uploads you already hold — no bytes travel in this request — and pipeline is an object of the four boolean toggles below that decide which phases run. All four default to true, so a request carrying only inputs runs the whole chain.
The multipart/form-data body still works, unchanged: the file in files (or url= for a fetch we perform), and the same four toggles as plain form fields alongside it. It creates an upload internally, so the job it answers with names a re-usable upload_id either way. Use JSON in new code, for the reasons in Uploading audio.
vocal_separation=false when your recording has no music in it. That phase is a music vocal-separator, and on dry speech it can drop whole passages rather than clean them. Spoken-word material is the case to try it on.Arguments
| Field | Type | Default | What it does |
|---|---|---|---|
| inputs | string[] | required | The recording to separate, as exactly one upload id. In a multipart body this is the files part instead — one file, in an accepted format. |
| vocal_separation | boolean | true | Strips music, applause, and broadband background out of the mix before the speakers are told apart, so the separator only has to model voices. |
| enhancement | boolean | true | Denoises each separated speaker track: hiss, hum, room tone, and handling noise are attenuated while the voice is left in place. |
| super_resolution | boolean | true | Restores high-frequency bandwidth and writes the output tracks at 48 kHz instead of 16 kHz. |
| scoring | boolean | true | Estimates SI-SDR for the separated pair with a no-reference model and reports it on every track. |
separation field. Speaker separation is the job, not an option, and a request carrying that field is rejected with a 400 rather than silently ignored.In JSON the toggles are real booleans inside pipeline. In a multipart body they arrive as strings, because multipart has no other kind, and four spellings of each are accepted, in any case and with surrounding whitespace trimmed: true/false, 1/0, yes/no, and on/off.
Anything else, an empty value included, is a 400 naming the field: <field> must be true or false (got '…'). Accepted: true/false, 1/0, yes/no, on/off. Unknown strings are never coerced to false.
Choosing what to run
The defaults are tuned for the hardest common case: a recording you did not control. Changing one is worth it when a phase has nothing to do, or when it undoes work your downstream consumer wants intact.
| Field | When to change it |
|---|---|
| vocal_separation | Turn it off when the recording is already dry speech: a studio double-ender, a headset call, a meeting-room capture with nothing playing. |
| enhancement | Turn it off on already-clean studio material, where denoising can only risk softening a voice that had nothing wrong with it. |
| super_resolution | Turn it off when your pipeline consumes 16 kHz and would only resample the extra bandwidth away, as most ASR, telephony and VoIP paths do. |
| scoring | Turn it off when you never read the number, such as an automated pipeline that keeps whatever comes back. |
Request
A run configured for clean studio input headed into a 16 kHz consumer, with vocal separation and super-resolution off. Enhancement and scoring are left unset, so both run at their default of on.
# The shape to write new code against: name an upload you already hold.
curl -X POST https://drugs-introduction-exchanges-likely.trycloudflare.com/v1/jobs/separation \
-H "Authorization: Bearer $DUETA_API_KEY" \
-H "Content-Type: application/json" \
-d '{"inputs": ["upl_9f2c7a41d0e8"],
"pipeline": {"vocal_separation": false, "super_resolution": false}}'
# Still supported, unchanged, for every integration written before uploads
# existed. It creates an upload internally, so the job comes back naming a
# re-usable upload_id either way.
curl -X POST https://drugs-introduction-exchanges-likely.trycloudflare.com/v1/jobs/separation \
-H "Authorization: Bearer $DUETA_API_KEY" \
-F "files=@conversation.wav" \
-F "vocal_separation=false" \
-F "super_resolution=false"Response
The job comes back queued, before any work has started. The pipeline object echoes the configuration the server resolved, which is the value to log and assert against rather than the flags you meant to send.
{
"id": "0f9c1a7e-4b21-4d5a-9d0e-2c8b6a1f3e77",
"user_id": "3d1b5c90-7a44-4f2e-8b61-9e0c4d2a5f18",
"type": "separation",
"status": "queued",
"stage": "queued",
"progress": 0,
"eta_seconds": null,
"pipeline": {
"vocal_separation": false,
"enhancement": true,
"super_resolution": false,
"scoring": true
},
"input_metadata": {
"n_files": 1,
"duration_seconds": 412.5,
"estimated_cost_seconds": 412.5,
"estimated_cost_usd": 3.43,
"filenames": ["conversation.wav"],
"stems": [
{
"stem": "conversation-00",
"filename": "conversation.wav",
"disk_name": "conversation-00.wav",
"duration_seconds": 412.5,
"upload_id": "upl_9f2c7a41d0e8"
}
],
"pipeline": {
"vocal_separation": false,
"enhancement": true,
"super_resolution": false,
"scoring": true
}
},
// The ONLY place this appears. null unless the request carried a
// callback_url — the once-only secret described below, at job scope.
"callback_secret": null,
"queue_position": 2,
"inputs": [
{
"index": 0,
"upload_id": "upl_9f2c7a41d0e8",
"filename": "conversation.wav",
"duration_seconds": 412.5,
"stem": "conversation-00",
"size_bytes": 68112044,
"bytes_received": 68112044,
"state": "ready",
"sample_rate": 48000,
"channels": 2,
"checksum_sha256": "b1946ac92492d2347c6235b4d2611184..."
}
],
"steps": [
{ "name": "vocal_separation", "state": "skipped", "progress": 0 },
{ "name": "separation", "state": "pending", "progress": 0 },
{ "name": "enhancement", "state": "pending", "progress": 0 },
{ "name": "super_resolution", "state": "skipped", "progress": 0 },
{ "name": "si_sdr", "state": "pending", "progress": 0 }
],
"estimated_cost_seconds": 412.5,
"estimated_cost_usd": 3.43,
"billed_seconds": null,
"billed_usd": null,
"error": null,
"result": null,
"created_at": "2026-01-04T09:21:44Z",
"started_at": null,
"finished_at": null
}Running a dominant separation
/v1/jobs/dominant-separationThe body is JSON: {"inputs": ["upl_…", "upl_…"], "callback_url": "…"}. inputs names uploads you already hold — 2 to 10 of them, one per microphone. There is no pipeline object on this product: unlike separation, dominant separation is a single stage with nothing to switch on or off.
The multipart/form-data body works the same way, one files part per recording. It creates uploads internally, exactly as it does for Duplex, so the job it answers with names re-usable upload_ids either way.
inputs is a sequence, not a set: the first recording is the timeline every other one is aligned against. Naming the same two microphones in a different order does not change what a human hears, but it changes which file the model treats as the reference.Input rules
Every general requirement in Uploading audio still applies — format, per-file duration, size, silence. This product adds three rules of its own, all checked at job creation rather than after a GPU run:
| Rule | Value | If it is not met |
|---|---|---|
| count | 2-10 recordings, one per source in the room | 400: "dominant-separation takes 2-10 audio files (got 1)" |
| no duplicates | Every upload id named exactly once | 400: "The same upload is named more than once: …" |
| same take | The longest recording may be at most 2x the shortest | 400 naming both durations: a set this ragged is not the same event, and the model would align nonsense rather than fail visibly later |
Same room, same take — not sample-aligned. Dominant separation assumes every input is a different microphone pointed at one conversation, started and stopped by separate hands. Its first step is an envelope cross-correlation, so a few seconds of drift between recorders is normal and handled automatically; there is no equal_duration_tolerance check the way super-resolution has one. What it cannot make sense of is files that were never the same take, which is what the 2x ratio catches before it is paid for.
Request
Two microphones, both already uploaded, submitted in the order they should be aligned.
curl -X POST https://drugs-introduction-exchanges-likely.trycloudflare.com/v1/jobs/dominant-separation \
-H "Authorization: Bearer $DUETA_API_KEY" \
-H "Content-Type: application/json" \
-d '{"inputs": ["upl_9f2c7a41d0e8", "upl_4a81be03c5f7"]}'
# multipart still works, unchanged: one part per microphone, same order.
curl -X POST https://drugs-introduction-exchanges-likely.trycloudflare.com/v1/jobs/dominant-separation \
-H "Authorization: Bearer $DUETA_API_KEY" \
-F "files=@alice.wav" \
-F "files=@bob.wav"Response
The job comes back queued. steps holds one entry, run, because this product has no internal phases to report the way separation reports five — and pipeline is always {}.
{
"id": "f0014644-ae34-45e6-93ec-0f6ca74c14b9",
"user_id": "3d1b5c90-7a44-4f2e-8b61-9e0c4d2a5f18",
"type": "dominant_separation",
"status": "queued",
"stage": "queued",
"progress": 0.0,
"eta_seconds": null,
"pipeline": {},
"input_metadata": {
"n_files": 2,
"duration_seconds": 612.4,
"estimated_cost_seconds": 612.4,
"estimated_cost_usd": 5.1,
"filenames": ["alice.wav", "bob.wav"],
"stems": [
{
"stem": "alice-00",
"filename": "alice.wav",
"disk_name": "alice-00.wav",
"duration_seconds": 612.4,
"upload_id": "upl_9f2c7a41d0e8"
},
{
"stem": "bob-01",
"filename": "bob.wav",
"disk_name": "bob-01.wav",
"duration_seconds": 598.1,
"upload_id": "upl_4a81be03c5f7"
}
]
},
"callback_secret": null,
"queue_position": null,
"inputs": [
{
"index": 0,
"upload_id": "upl_9f2c7a41d0e8",
"filename": "alice.wav",
"duration_seconds": 612.4,
"stem": "alice-00",
"size_bytes": 91240044,
"bytes_received": 91240044,
"state": "ready",
"sample_rate": 48000,
"channels": 1,
"checksum_sha256": "e0af5102007dd599e888e2d935b824a386995cc..."
},
{
"index": 1,
"upload_id": "upl_4a81be03c5f7",
"filename": "bob.wav",
"duration_seconds": 598.1,
"stem": "bob-01",
"size_bytes": 89104720,
"bytes_received": 89104720,
"state": "ready",
"sample_rate": 48000,
"channels": 1,
"checksum_sha256": "0e4f2e77296420a01b54a4398738a48338cb908..."
}
],
"steps": [
{ "name": "run", "state": "pending", "progress": 0.0 }
],
"estimated_cost_seconds": 612.4,
"estimated_cost_usd": 5.1,
"billed_seconds": null,
"billed_usd": null,
"error": null,
"result": null,
"created_at": "2026-08-31T12:50:46Z",
"started_at": null,
"finished_at": null
}Output naming
A succeeded job returns 3 tracks for the minimum 2 microphones, and one more for every microphone beyond that — dominantStemCount(n) = n + 1. Each input comes back under its own stem name — the same name inputs[].stem already carries — holding its own source with the bleed from every other mic pushed down, plus one extra track named exactly "ambience" for the room itself: the bleed, the tails, and the space taken out of the tracks above it.
{
"status": "succeeded",
"stage": "done",
"progress": 1.0,
"billed_seconds": 612.4,
"billed_usd": 5.1,
"result": {
"stems": [
{ "name": "alice-00", "si_sdr": null },
{ "name": "bob-01", "si_sdr": null },
{ "name": "ambience", "si_sdr": null }
],
"sample_rate": 24000
}
}result.stemsis every input's stem name back, in input order, followed by"ambience"last. Take names fromresult.stems[].namerather than assuming an index — the same rule as Results.si_sdrisnullon every entry, always. SI-SDR is scored inside theseparationpipeline only; dominant separation has no scoring step to produce one.result.sample_rateis fixed at 24,000 Hz. There is nosuper_resolutiontoggle on this product to change it.
Downloading works exactly as described in Results: GET /v1/jobs/{id}/stems/{name} for each track named above, including ambience.
Billing
Tracking progress
/v1/jobs/{id}/v1/jobs/{id}/eventsOne shape returns everything about a job: where it is in the queue, which step is running and how far into it, and once it settles, its result. There are two ways to get that shape and they are interchangeable — poll GET /v1/jobs/{id} every second or two, or subscribe to GET /v1/jobs/{id}/events, where each frame is exactly the body the GET would have returned at that moment. A third option is to have us call you: register a callback_url on the job. All three are in Callbacks & streaming.
{
"id": "0f9c1a7e-...",
"status": "running",
"stage": "separation",
"progress": 0.62,
"eta_seconds": 34,
"queue_position": null,
"steps": [
{ "name": "vocal_separation", "state": "done", "progress": 1.0 },
{ "name": "separation", "state": "running", "progress": 0.42 },
{ "name": "enhancement", "state": "pending", "progress": 0.0 },
{ "name": "super_resolution", "state": "skipped", "progress": 0.0 },
{ "name": "si_sdr", "state": "pending", "progress": 0.0 }
],
"billed_seconds": null,
"billed_usd": null,
"result": null
}| Field | Semantics |
|---|---|
| status | queued, running, succeeded, failed, canceled. Treat anything but the first two as terminal and stop polling. |
| steps | The step-by-step account of the processing, in pipeline order. This is what a step indicator is built from. See below. |
| queue_position | How many jobs are ahead of this one. 0 means next to run; null means it is not waiting — already running, already finished, or just claimed by a worker. |
| progress | 0 to 1 across the whole job. It only moves forward, so it can drive a single bar without smoothing. Each step's own fraction is in steps[].progress. |
| stage | The label of the step running right now — the same string as the matching steps[].name. Kept for compatibility; steps says everything it says and more. |
| eta_seconds | Best-effort estimate of the processing time this job needs, never a deadline. It excludes queue wait, so it is null while other work is ahead of yours rather than counting down against a job that has not started — read queue_position for that. |
| inputs | One record per recording the job was made from, in input order, each naming the upload it came from. Present on every job resource. See below. |
The step list
steps is the API's own account of what is happening, not something a client works out from stage plus its own copy of the pipeline. It is always complete and always in order: a step your toggles switched off reads skipped rather than being omitted, so "this job is not doing super-resolution" is a fact you can show rather than a gap you have to notice.
// job.steps, on a job whose super_resolution toggle is off
[
{ "name": "vocal_separation", "state": "done", "progress": 1.0 },
{ "name": "separation", "state": "running", "progress": 0.42 },
{ "name": "enhancement", "state": "pending", "progress": 0.0 },
{ "name": "super_resolution", "state": "skipped", "progress": 0.0 },
{ "name": "si_sdr", "state": "pending", "progress": 0.0 }
]nameis the step label, matchingstagewhen that step is the running one. Duplex separation reports the five above; the single-stage products report one step calledrun.stateispending,running,done, orskipped.progressis that step's own0..1, not the job's.
steps. Do not keep a local list of the pipeline and match it against stage: a step added on our side would be invisible in your client until somebody remembered to add it, and two jobs with different toggles have different step counts. Never derive a percentage from a step index either — the steps are not equal in length.With every phase enabled the sequence is queued → vocal_separation → separation → enhancement → super_resolution → si_sdr → done. A cancelled job reports canceled and one that died inside a model reports failed; stage has a terminal value for every terminal status, so the last stage a job reports is never the phase it stopped in.
Waiting in the queue
Jobs run one at a time. While yours is queued, queue_position is how many are ahead of it and eta_seconds is null, because the wait depends on other people's jobs and the API would be guessing. Once nothing is ahead, queue_position goes null and eta_seconds is quoted — at which point counting it down as wall clock is correct.
{
"id": "0f9c1a7e-...",
"status": "queued",
"stage": "queued",
"progress": 0,
"queue_position": 3, // three jobs ahead; 0 means next to run
"eta_seconds": null, // excludes queue wait, so it is null behind a queue
"steps": [ /* every step "pending" or "skipped" */ ]
}# The four fields a status view is built from, plus the step list.
curl -sS -H "Authorization: Bearer $DUETA_API_KEY" https://drugs-introduction-exchanges-likely.trycloudflare.com/v1/jobs/$JOB_ID |
jq '{status, progress, eta_seconds, queue_position,
steps: [.steps[] | "\(.name) \(.state) \(.progress)"]}'
# Or subscribe instead of asking. Same body, pushed. -N so curl does not
# buffer the stream.
curl -sS -N -H "Authorization: Bearer $DUETA_API_KEY" \
https://drugs-introduction-exchanges-likely.trycloudflare.com/v1/jobs/$JOB_ID/eventsThe recordings a job is made from
inputs is a list, one entry per recording, in the order they were named. Each entry points at the upload_id it came from and reads that upload's measurements through rather than copying them, so a job and GET /v1/uploads/{id} can never disagree about the same file.
// job.inputs[0]
{
"index": 0,
"upload_id": "upl_9f2c7a41d0e8",
"filename": "alice.wav",
"duration_seconds": 612.4,
"stem": "alice-00",
"size_bytes": 91240044,
"bytes_received": 91240044,
"state": "ready",
"sample_rate": 48000,
"channels": 2,
"checksum_sha256": "b1946ac92492d2347c6235b4d2611184..."
}
// upload_id is null on a job created before uploads were a resource: its
// audio arrived inside the submission itself and there is nothing to point at.
{ "index": 0, "upload_id": null, "filename": "legacy.wav",
"duration_seconds": 210.0, "stem": "legacy-00", "size_bytes": null,
"bytes_received": null, "state": null, "sample_rate": null,
"channels": null, "checksum_sha256": null }| Field | Semantics |
|---|---|
| index | Position in the list you named, and the order the job ran them in. |
| upload_id | The upload this input came from — re-usable on another job. null on a job created before uploads were a resource: its audio arrived inside the submission itself and there is nothing to point at. |
| filename | The name, and duration_seconds the length, as of job creation: what this job ran on and what it was billed for, which must not move afterwards. |
| stem | The name this input downloads by, at GET /v1/jobs/{id}/inputs/{stem}. |
| size_bytes | Read through from the upload, along with bytes_received, state, sample_rate, channels and checksum_sha256. All null on the oldest jobs, which never recorded them. |
A transfer that is still in flight is watched on the upload, not here: a job is only ever created from uploads that are already ready.
Callbacks & streaming
Polling works and is documented, but it makes every client ask repeatedly for an answer that is usually "no change". There are two ways to be told instead: an HTTP callback we POST to you, and a server-sent event stream you subscribe to. Both carry the same resource body a GET returns, so nothing can learn a fact from one channel that another does not know.
Callbacks
Register a callback_url on an upload (POST /v1/uploads) or on a job (POST /v1/jobs/{product}). The response to that call carries a callback_secret — once, and never again, because a secret a GET would hand back is not a secret. Only http and https URLs are accepted, up to 1000 characters.
# On an upload. callback_secret is in THIS response and never again:
# a secret a GET would hand back is not a secret.
curl -sS -X POST https://drugs-introduction-exchanges-likely.trycloudflare.com/v1/uploads \
-H "Authorization: Bearer $DUETA_API_KEY" \
-H "Content-Type: application/json" \
-d '{"filename": "alice.wav", "size_bytes": 91240044,
"callback_url": "https://yours.example.com/hooks/dueta"}'
# -> {"id": "upl_9f2c...", "state": "pending", ...,
# "callback_secret": "whsec_...store-this-now"}
# On a job, the same field alongside the inputs.
curl -sS -X POST https://drugs-introduction-exchanges-likely.trycloudflare.com/v1/jobs/separation \
-H "Authorization: Bearer $DUETA_API_KEY" \
-H "Content-Type: application/json" \
-d '{"inputs": ["upl_9f2c..."],
"callback_url": "https://yours.example.com/hooks/dueta"}'Each state transition is POSTed to that URL as {"event", "sent_at", "type", "data"}, where data is the whole resource, not a diff — the same body GET /v1/uploads/{id} or GET /v1/jobs/{id} would return.
POST https://yours.example.com/hooks/dueta
Content-Type: application/json
X-Dueta-Event: upload.ready
X-Dueta-Timestamp: 1787881026
X-Dueta-Signature: v1=3f7c1a...64 hex chars
{
"event": "upload.ready",
"sent_at": 1787881026,
"type": "upload",
"data": {
"id": "upl_9f2c7a41d0e8",
"state": "ready",
"bytes_received": 91240044,
"duration_seconds": 612.4,
"sample_rate": 48000,
"channels": 2,
"checksum_sha256": "b1946ac92492d2347c6235b4d2611184..."
}
}
// A job delivery is the same envelope with type "job", and "data" is exactly
// what GET /v1/jobs/{id} would return — steps, queue_position and all.| Header | Value |
|---|---|
| X-Dueta-Event | The transition, e.g. upload.receiving, upload.ready, upload.failed. Also the event field of the body. |
| X-Dueta-Timestamp | Unix seconds at which this delivery was signed. Part of the signed material. |
| X-Dueta-Signature | v1=<hex>. v1 is a version tag so a future scheme can be added alongside this one rather than breaking every receiver on the day it lands. |
Verifying a delivery
The signature is HMAC-SHA256 over the exact bytes "{timestamp}." + raw_body, keyed with your callback_secret, hex-encoded and prefixed v1=.
Then refuse a delivery whose X-Dueta-Timestamp is more than 300 seconds from now. That window is what stops a captured delivery being replayed later. Compare the signature with a constant-time comparison, and answer any 2xx to accept it.
import hashlib, hmac, os, time
from flask import Flask, request
SECRET = os.environ["DUETA_CALLBACK_SECRET"] # the one returned once, at registration
TOLERANCE = 300 # seconds; matches the API's callback_tolerance_seconds
app = Flask(__name__)
@app.post("/hooks/dueta")
def hook():
raw = request.get_data() # THE RAW BYTES. Do not use request.json here.
timestamp = request.headers.get("X-Dueta-Timestamp", "")
signature = request.headers.get("X-Dueta-Signature", "")
# Refuse an old delivery, which is what stops a captured one being replayed.
try:
if abs(time.time() - int(timestamp)) > TOLERANCE:
return "", 400
except ValueError:
return "", 400
mac = hmac.new(
SECRET.encode(), f"{timestamp}.".encode() + raw, hashlib.sha256
).hexdigest()
if not hmac.compare_digest(signature, f"v1={mac}"):
return "", 401
payload = request.get_json()
# Delivery is BEST EFFORT and unordered. Treat this as "something changed"
# and trust the resource: re-reading it with a GET is always correct.
print(payload["event"], payload["data"]["id"], payload["data"].get("state"))
return "", 204 # any 2xx; anything else is retried a couple of timesDelivery is best effort
- A few immediate retries, then we stop. A non-2xx or a connection failure is retried a small number of times over a couple of seconds; after that the attempt is logged and dropped.
- There is no durable queue. No backoff over hours, no dead-letter store. An endpoint that is down for minutes will miss transitions.
- There is no ordering guarantee. Two transitions that happen close together may arrive in either order, which is the other reason each payload carries the whole resource rather than a delta.
- Nothing fails because your endpoint did. A transfer is not slower for a slow receiver and not failed by a broken one; the delivery is scheduled and the request answers immediately.
That boundary is deliberate. A real delivery guarantee is a piece of infrastructure with its own storage and operational surface, and claiming one we do not have would be worse than saying plainly that we do not.
Server-sent events
/v1/uploads/{id}/events/v1/jobs/{id}/eventsThe browser-shaped half of the same idea: text/event-stream, one frame per change. Each frame is event: upload or event: job followed by data: holding exactly the body that resource's GET would return at that moment — same fields, same steps, same queue_position. A client that subscribes and a client that polls render from identical state.
event: upload
data: {"id":"upl_9f2c...","state":"receiving","bytes_received":43122688,...}
: keepalive
event: upload
data: {"id":"upl_9f2c...","state":"ready","duration_seconds":612.4,...}
event: done
data: {"id":"upl_9f2c...","state":"ready"}
// A job stream's done frame carries "status", not "state" — the field the
// job resource itself uses. Everything above it (event: job, keepalives,
// data holding the full resource) is the same shape.
event: done
data: {"id":"0f9c1a7e-...","status":"succeeded"}: keepalivecomments are sent between frames so proxies see traffic. A client parsing events never sees them.- A final
event: donesays the stream ended because the resource settled — an upload reachingready,failedorexpired, or a job reaching a terminal status — rather than because something dropped it. - Only changes are sent. A frame missed for any reason is corrected by the next one, because every frame is the whole resource.
- A long stream ends on its own. Reconnect, or fall back to the GET; it is the same state either way.
EventSource cannot send an Authorization header. An API-key client therefore reads the stream with fetch and parses the frames itself, as below. EventSource works only where the browser already holds the session cookie — which is what our own console uses.# -N so curl prints frames as they arrive instead of buffering them.
curl -sS -N -H "Authorization: Bearer $DUETA_API_KEY" \
https://drugs-introduction-exchanges-likely.trycloudflare.com/v1/jobs/$JOB_ID/events
# The upload stream is the same shape, and ends at ready/failed/expired.
curl -sS -N -H "Authorization: Bearer $DUETA_API_KEY" \
https://drugs-introduction-exchanges-likely.trycloudflare.com/v1/uploads/$UPLOAD_ID/eventsResults
A succeeded job carries its output on result: two stems, and the rate they were written at. A stem's name starts from the uploaded filename and accumulates one suffix per phase it passed through, so it reads as the route the audio actually took.
{
"status": "succeeded",
"stage": "done",
"progress": 1,
"billed_seconds": 412.5,
"billed_usd": 3.43,
"result": {
"stems": [
{ "name": "conversation-00_vocals_spk1_enh_48k", "si_sdr": 14.8 },
{ "name": "conversation-00_vocals_spk2_enh_48k", "si_sdr": 14.8 }
],
"sample_rate": 48000
}
}result.stemsis always 2 entries,_spk1before_spk2. Take each name fromresult.stems[].namerather than composing it: the suffixes depend on which phases ran, withenhancementadding_enhandsuper_resolutionadding_48kafter the speaker suffix, so_spk1is not reliably the end of the string. Which person ended up on which track is arbitrary and does not carry across jobs.result.sample_rateis 48,000 Hz whensuper_resolutionran and 16,000 Hz when it did not. Read the field rather than assuming the number.si_sdrisnullon every stem whenscoringwas off. The key is not omitted, so a client that reads it does not need to change.
Downloading tracks
/v1/jobs/{id}/stems/{name}The job response carries stem names, not URLs, so build the URL from the job id and the name. The response is a mono WAV and needs the same bearer token as every other call, so it cannot be handed to a bare <audio src> or an anchor: fetch the bytes and hold them as a blob. The one exception is a shared result, whose audio is served on a public route precisely so it can go straight into an <audio> element.
Output is retained for 7 days.
# Stem URLs are built from the job id and the stem name;
# the job response carries names, not URLs.
curl -sS -H "Authorization: Bearer $DUETA_API_KEY" \
"https://drugs-introduction-exchanges-likely.trycloudflare.com/v1/jobs/$JOB_ID" | jq -r '.result.stems[].name' |
while read -r NAME; do
curl -sS -H "Authorization: Bearer $DUETA_API_KEY" \
-o "$NAME.wav" \
"https://drugs-introduction-exchanges-likely.trycloudflare.com/v1/jobs/$JOB_ID/stems/$NAME"
doneDownloading the original
/v1/jobs/{id}/inputs/{name}The recording the job was made from stays downloadable too, so a result can be played against its source without keeping a copy of the upload. Take the name from inputs[].stem on the job response — the same list described under the files a job is made from, which is on every job however it was submitted; the bytes come back exactly as they were sent, in the container they were sent in, and the call is bearer-authenticated like the stem route.
Inputs are retained for the same 7 days as the stems, so they expire together. A request for either afterwards answers 404.
Sharing a result
/v1/jobs/{id}/share/v1/shares/{token}A finished job's result can be published at a link that opens without an account. The share is a resource on the job: POST creates it, GET reads it, and DELETE revokes it. Creating a share twice returns the same link rather than a second one, so a client that has lost track of whether it shared a job does not scatter live links; pass {"rotate": true} to replace the link, which kills the old token at the moment the new one exists.
# 1. Publish a finished job's result. The default window is the same as the
# retention window, because a link must not outlive the audio it points at.
curl -sS -X POST https://drugs-introduction-exchanges-likely.trycloudflare.com/v1/jobs/$JOB_ID/share \
-H "Authorization: Bearer $DUETA_API_KEY" \
-H "Content-Type: application/json" -d '{}'
# -> {"share_id": "...", "job_id": "...", "token": "N1x...43 chars",
# "url": "https://.../s/N1x...", "include_filenames": false,
# "expires_at": "...", "created_at": "...", "revoked": false}
# 2. Read it the way a visitor does: no key, no cookie, no account.
curl -sS https://drugs-introduction-exchanges-likely.trycloudflare.com/v1/shares/$TOKEN
# 3. Play a track. Range requests are supported, so audio can be scrubbed.
curl -sS -r 0-1023 https://drugs-introduction-exchanges-likely.trycloudflare.com/v1/shares/$TOKEN/stems/1 -o head.wav
# 4. Read the current share, or 404 if the job is not shared.
curl -sS https://drugs-introduction-exchanges-likely.trycloudflare.com/v1/jobs/$JOB_ID/share -H "Authorization: Bearer $DUETA_API_KEY"
# 5. Withdraw it. Immediate, and it takes the audio with it.
curl -sS -X DELETE https://drugs-introduction-exchanges-likely.trycloudflare.com/v1/jobs/$JOB_ID/share \
-H "Authorization: Bearer $DUETA_API_KEY" -o /dev/null -w '%{http_code}\n'
# -> 204
curl -sS -o /dev/null -w '%{http_code}\n' https://drugs-introduction-exchanges-likely.trycloudflare.com/v1/shares/$TOKEN
# -> 404GET /v1/shares/{token} takes no credentials and returns only what a listener needs. There is no account information in it of any kind — no owner, no email, no key, no credit, no cost, and not even the job id.
{
"job_type": "separation",
"engine": "DUETA Duplex",
"created_at": "2026-08-28T04:11:52Z",
"duration_seconds": 182.4,
"sample_rate": 48000,
"stems": [
{"stem_id": "1", "label": "Track 1", "si_sdr_db": 14.2,
"audio_url": "/v1/shares/N1x.../stems/1"},
{"stem_id": "2", "label": "Track 2", "si_sdr_db": 14.2,
"audio_url": "/v1/shares/N1x.../stems/2"}
],
"source_filenames": null,
"expires_at": "2026-09-04T04:12:10Z",
"product": "DUETA",
"company": "MindLogic"
}- Filenames are hidden by default. A filename routinely carries a person's name, and a viewer needs none of it to listen, so stems are labelled
Track 1,Track 2and addressed by position. Pass{"include_filenames": true}at creation to show the real stem names and the uploaded filenames instead. The audio URL stays positional either way. - Expiry is real and bounded. A share expires after 7 days by default, which is also the maximum: that is how long the audio itself is kept, and a link dated past it would resolve to tracks that no longer exist. Send
expires_atto choose a shorter window; anything longer than the retention window is refused with a 422 that says so. - Revocation is immediate. There are no pre-signed URLs to outlive the revocation: every read of a shared result and every byte of shared audio re-checks the share first, so a
DELETEkills a page that is already open and a link already pasted somewhere. What it cannot undo is bytes a listener has already downloaded. - Gone is gone, and it does not say why. A token that is unknown, expired or revoked answers the same
404with the same sentence — never a403, which would confirm that a job exists and that somebody withdrew it. - The audio route supports range requests, so a shared track can be scrubbed rather than only played from the start, and the public routes are rate-limited per IP.
Reading SI-SDR
SI-SDR, or Scale-Invariant Signal-to-Distortion Ratio, estimates in decibels how much of the separated audio is the speakers versus leftover bleed and artifacts. It is scored once for the pair and reported on both tracks, so the two read the same number, and it is scale-invariant, so making a track louder or quieter does not move it.
- ≥ 10 dBScored high
- 5-10 dBScored mid
- < 5 dBScored low
Those are floors, not targets: a clean two-speaker recording commonly scores in the 40 dB range, so a result well above 10 dB is normal rather than suspicious. A score under 5 dB usually says more about the source, speakers far from the mic or takes that were never aligned, than about the model.
Treat the number as an estimate, not ground truth. It comes from a model with no reference recording to compare against, so it is most reliable for comparing runs of similar material and least reliable as an absolute grade: enhanced audio tends to score higher than its true separation quality, and narrow-band sources such as 8 kHz telephone audio can score higher than they sound.
Separation output is not bit-identical between runs, because GPU arithmetic is not deterministic, so scoring the same input twice can give slightly different numbers. Scoring itself is deterministic: the same pair of tracks always yields the same score.
Billing & credits
Credit is US dollars. A balance is an amount of money, every job type costs $0.50/min of input audio, and the charge is prorated by the second with no minimum. New accounts are granted $20 of credit on signup, which is 40 minutes of audio, and credit never expires.
- The billable length is the audio this service measured when the upload was decoded — never a duration a client reported. For a single-file job that is its duration; for a multi-microphone job it is the longest of the 2-10 files, not their sum, because the separation runs once over the aligned timeline.
POST /v1/jobs/quoteanswers that arithmetic before you create the job, on the same measurements, and holds nothing. It is what our own console shows you.- On submission the quote comes back as
estimated_cost_usd(with the length it was computed from inestimated_cost_seconds), and that amount is held. - On settlement the hold is released and the real charge is written as
billed_usd, alongside the measuredbilled_seconds. It never exceeds the quote. - A job that fails or is canceled is refunded: the hold is released and no charge is written.
- A run with too little balance to cover the quote is refused with a
402before it is queued, so nothing is held and no job exists to cancel.
hold, a positive hold_release, and the job charge. The other reasons are signup_bonus, purchase, and adjustment.GET /v1/credits returns the balance as balance_usd and the recent ledger, each row carrying id, delta_usd, reason, job_id, and created_at.
{
"balance_usd": 16.57,
"ledger": [
{ "id": "9c2f4a10-...", "delta_usd": -3.43, "reason": "job",
"job_id": "0f9c1a7e-...", "created_at": "2026-01-04T09:24:02Z" },
{ "id": "7b81de55-...", "delta_usd": 3.43, "reason": "hold_release",
"job_id": "0f9c1a7e-...", "created_at": "2026-01-04T09:24:02Z" },
{ "id": "41ac9f03-...", "delta_usd": -3.43, "reason": "hold",
"job_id": "0f9c1a7e-...", "created_at": "2026-01-04T09:21:44Z" },
{ "id": "0a5e7c22-...", "delta_usd": 20.0, "reason": "signup_bonus",
"job_id": null, "created_at": "2026-01-02T11:03:00Z" }
]
}GET /v1/usage aggregates the same charges per UTC day, with optional inclusive from and to query params in YYYY-MM-DD form. Each day carries both the measured billed_seconds and the billed_usd they cost, and the totals follow the same pair.
// GET /v1/usage?from=2026-01-01&to=2026-01-31
{
"days": [
{ "date": "2026-01-04", "billed_seconds": 210.3, "billed_usd": 1.75 },
{ "date": "2026-01-09", "billed_seconds": 140.0, "billed_usd": 1.16 }
],
"total_billed_seconds": 350.3,
"total_billed_usd": 2.91
}Topping up. Once purchasing opens, GET /v1/billing/topup states the bounds and the rate, so a client never hardcodes them. Any whole dollar amount from $5 to $500 is accepted; the presets are $10, $20, $50, $100, and a custom amount in range is treated identically. The object below is what it returns then.
// GET /v1/billing/topup
{
"min_usd": 5,
"max_usd": 500,
"presets_usd": [10, 20, 50, 100],
"rate_usd_per_minute": 0.5,
"price_currency": "USD",
"charge_currency": "USD",
"provider": "payple",
"charge_minor_per_usd": 100
}POST /v1/billing/checkout takes {"amount_usd": 20} and returns the order together with the payload the provider’s payment window is opened with. An amount outside the bounds, or one that is not a whole number of dollars, is refused with a 400 and nothing is created.
// POST /v1/billing/checkout {"amount_usd": 20}
// One flat object: everything the browser needs to open the payment window.
{
"order_id": "b8d02f61-...",
"provider": "payple_global",
"script_url": "https://.../common/js/gpay-1.0.1.js",
"entry_point": "paypleGpayPaymentRequest",
"payload": { /* the object the provider's payment window is opened with */ },
"charge_minor": 2000,
"charge_display": "$20.00",
"charge_currency": "USD",
"price_display": "$20.00",
"price_currency": "USD",
"topup_usd": 20,
"credit_usd": 20.0
}
// An amount outside 5..500, or one that is not a whole number -> 400
{ "detail": "amount_usd must be a whole number of dollars between 5 and 500" }GET /v1/billing/orders lists your orders and GET /v1/billing/orders/{id} reads one. An order carries the money paid as topup_usd and the credit it added as credit_usd. Credit is added once the provider confirms the payment, not when the window opens.
// GET /v1/billing/orders
// { "orders": [...] } — newest first, the whole list, no pagination.
{
"orders": [
{
"order_id": "b8d02f61-...",
"status": "paid",
"topup_usd": 20,
"provider": "payple_global",
"charge_minor": 2000,
"charge_display": "$20.00",
"charge_currency": "USD",
"credit_usd": 20.0,
"detail": null,
"created_at": "2026-01-06T14:02:11Z",
"updated_at": "2026-01-06T14:03:04Z"
}
]
}GET /v1/billing/topup and POST /v1/billing/checkout answer 503 with "Card payments are not available yet", and an account runs on its signup grant. That 503 is a state — purchasing is closed — not a transient outage, so a client should surface it and stop, not retry it: unlike the retryable 5xx in the error reference, backing off and trying again will only reach the same closed door until the feature ships. The shapes above are what these endpoints will answer with once purchasing opens.Rate limits
Job submission is limited per account, so the ceiling follows your key rather than the machine holding it.
- 20 submissions per minute. Exceeding it returns
429. - 5 jobs in progress at once. A sixth is refused with
429and "You already have 5 jobs in progress" until one finishes or is cancelled. Queued jobs count, so this bounds a batch before anything is running. Uploads do not: an upload queues nothing, so you can stage as many recordings as you like and create the jobs at your own pace.
A run holds one in-flight slot however many phases it uses. Batch submitters should cap their own concurrency at five and retry a 429 when one of their jobs completes, not on a fixed timer.
Three unauthenticated endpoints are additionally limited per IP address:
| Endpoint | Limit | Why |
|---|---|---|
| POST /v1/auth/signup | 5 / minute | Slows down bulk account creation for the signup credit. |
| POST /v1/auth/login | 10 / minute | Slows down password guessing. |
| POST /v1/contact | 3 / minute | Keeps the contact form from being used as a spam relay. |
Reading your budget from the response. Every rate-limited route — the per-account 20/minute submission limit, the per-IP endpoints in the table above, and the upload and share-read routes — carries three headers so a client never has to guess where it stands:
X-RateLimit-Limit— the ceiling for that route's window.X-RateLimit-Remaining— how many calls are left in the current window.X-RateLimit-Reset— whole seconds from now until the window refills. It is a delta, not a clock time or an epoch.
They ride on the route's successful response and on its 429, and also on any other 4xx that same limited route returns (a 401 or 404), so a caller can read its remaining budget without spending a request to earn a 429. A route that carries no limit sends none of them. On the 429 the API adds Retry-After — again whole seconds from now, the same unit as X-RateLimit-Reset, and on that response X-RateLimit-Remaining is 0 by definition. Wait the Retry-After and the oldest hit has aged out. The separate 5-jobs-in-progress ceiling answers its 429 with a Retry-After poll interval instead; retry that one when a job of yours finishes, as above.
Under normal operation these figures are exact: the counters live in shared storage, so every server process counts against the same budget and a limit survives a redeploy. If that store is unreachable the API keeps serving and falls back to approximate per-worker counting, so the effective ceiling can drift above the table and a backoff should be sized against the table figure either way.
Errors
Failures use FastAPI's shape: detail is a string for a rejected request, or an array of {loc, msg, type} objects for a 422 schema failure. A job that fails inside a model is not an HTTP error; the request succeeds, and the job reports status: "failed" with a message on error.
// A rejected request
{ "detail": "separation takes exactly 1 audio file (got 2)" }
// A JSON body that failed schema validation (422)
{ "detail": [ { "type": "value_error", "loc": ["body", "email"],
"msg": "value is not a valid email address: An email address must have an @-sign." } ] }| Status | Meaning | What to do |
|---|---|---|
| 400 | The request reached the endpoint but could not be turned into an upload or a job: an unsupported file type, the wrong number of inputs, an unparseable argument, a malformed Content-Range, audio that will not decode, is under 0.5 seconds, or is digital silence — or, at job creation, every input's duration added together exceeds the 7200-second (2-hour) per-job cap, e.g. "Total duration 8000s exceeds the cap of 7200s" (the same check runs inside POST /v1/jobs/quote and produces that identical sentence, reported as a reasons[] entry there instead of raising). | Read detail; it names the field or the file. Do not retry unchanged. A transfer whose length did not match its declared size leaves the upload failed with the reason — send the file again from byte 0. |
| 401 | No credential, a malformed one, or a key that has been revoked or rotated away. | Re-issue the key. A rotated key stops working the moment its replacement is returned. |
| 402 | Not enough credit to cover the job, checked before it is queued. Uploads are never charged, so this only ever comes from POST /v1/jobs/{product}. | Top up and create the job again from the SAME upload ids: nothing was charged, no job exists, and the uploads you spent minutes sending are untouched. POST /v1/jobs/quote answers sufficient_credit before you commit. |
| 404 | No upload or job with that id under your account, no stem by that name on the job, or an upload id named in inputs that does not exist. | Ids are scoped to the owning account. Take stem names from result.stems[].name and upload ids from the create-upload response or GET /v1/uploads. |
| 409 | The request is out of order. On PUT /v1/uploads/{id}/content: the piece did not start at the upload's own bytes_received, or the upload already has all of its bytes. On DELETE /v1/uploads/{id}: a job still references it. On POST /v1/jobs/{product}: an input is not in state ready. On cancel: the job already reached a terminal state. | For an offset mismatch, detail names the exact byte to send from — re-read bytes_received and continue there rather than restarting. For an upload in use, delete the jobs or wait for retention. For an input that is not ready, finish or re-send its bytes. For a cancellation, treat it as success: the job is not running and will not be billed. |
| 410 | The upload expired. An unused upload is swept with its bytes 24 hours after it was created. | Create a new upload and send the file again. Creating a job from an upload pushes its expiry out, and a job hardlinks the audio, so an expiry never takes a finished job's input away. |
| 413 | The body exceeded the 200 MB per-file cap. A size_bytes over the cap is refused at POST /v1/uploads before a byte moves; a body that grows past it is refused mid-stream rather than after it has all arrived. | Split or shorten the input. A refusal here leaves the upload failed with the reason, and sending the file again from byte 0 reopens it. |
| 422 | A JSON body failed schema validation. | detail is an array of {loc, msg, type}; loc points at the offending field. |
| 429 | A rate limit, or the ceiling on jobs in progress. Uploads share the per-account submission limit; they do not count against the in-flight job ceiling, because an upload queues nothing. | Back off. For the job ceiling, retry when one of your jobs finishes rather than on a timer. |
| 5xx | A fault on our side. The one exception is a 503 from GET /v1/billing/topup or POST /v1/billing/checkout while purchasing is not open yet ("Card payments are not available yet"): that is a state, not an outage. | Retry with exponential backoff. A failed job releases its hold and is never charged. Do not retry the billing 503 — it means purchasing is closed, so it stays 503 no matter how long you wait; surface it and stop until the feature ships. |
429 and 5xx with exponential backoff — the one exception is the billing 503 above, which means purchasing is not open yet and will not clear on retry; never retry a 400, 401, 402, or 404 unchanged. A retried submission is a second job and a second charge, so resubmit only once you have confirmed the first job does not exist.Endpoint reference
The full surface. Everything except key management accepts an API key; key management deliberately requires a session token. This page is the whole reference: the interactive Swagger / OpenAPI UI is switched off in production, so there is no /docs or /openapi.json to browse against the live API.
| Method | Path | Purpose |
|---|---|---|
| POST | /v1/uploads | Declare a recording. Answers 201 with an id, a content_url, and an events_url. |
| PUT | /v1/uploads/{id}/content | Send the bytes, raw. One request, or several with Content-Range to resume. |
| GET | /v1/uploads/{id} | What the server knows: state, bytes_received, duration, sample rate, checksum. |
| GET | /v1/uploads | List your uploads, newest first. limit, offset, and state= filter. |
| GET | /v1/uploads/{id}/events | Subscribe to one upload as server-sent events. |
| DELETE | /v1/uploads/{id} | Delete an upload and its bytes. 204, or 409 while a job uses it. |
| POST | /v1/jobs/quote | Price a set of uploads before creating the job. Reserves nothing. |
| POST | /v1/jobs/separation | Run Duplex separation over one upload: {"inputs": ["upl_..."]}. |
| POST | /v1/jobs/dominant-separation | Run dominant separation over 2-10 uploads. Their order is the alignment order. |
| GET | /v1/jobs/{id} | Read status, stage, progress, steps, queue_position, inputs, and result. |
| GET | /v1/jobs/{id}/events | Subscribe to one job as server-sent events. Same body the GET returns. |
| GET | /v1/jobs/{id}/stems/{name} | Download one output track as a WAV. |
| GET | /v1/jobs/{id}/inputs/{name} | Download the recording the job was made from. |
| POST | /v1/jobs/{id}/cancel | Cancel a queued or running job. |
| GET | /v1/jobs | List your jobs, newest first, paginated. |
| POST | /v1/jobs/{id}/share | Publish the result at a link anyone can open. |
| GET | /v1/jobs/{id}/share | Read the job's current share. |
| DELETE | /v1/jobs/{id}/share | Revoke the share immediately. |
| GET | /v1/shares/{token} | The shared result. Public: no key, no account. |
| GET | /v1/shares/{token}/stems/{stem_id} | Play one shared track. Public, and range-capable. |
| GET | /v1/credits | Current dollar balance and the recent ledger. |
| GET | /v1/usage | Billed seconds and dollars aggregated per UTC day. |
| GET | /v1/billing/topup | Top-up bounds, presets, and the per-minute rate. |
| POST | /v1/billing/checkout | Start a top-up for a whole dollar amount in range. |
| GET | /v1/billing/orders | List your top-up orders, newest first. |
| GET | /v1/billing/orders/{id} | Read one top-up order and its status. |
| POST | /v1/keys | Create an API key. The only response carrying the full secret. Session token only. |
| GET | /v1/keys | List key metadata: prefixes, never secrets. Session token only. |
| POST | /v1/keys/{id}/rotate | Replace a key in place. Session token only. |
| DELETE | /v1/keys/{id} | Revoke a key. Answers 204. Session token only. |
Cancelling a job
A queued job is cancelled synchronously and its hold released. A running job is cancelled cooperatively: the response still reads running until the worker reports canceled at its next checkpoint, so keep polling until the status turns over.
A job that already reached a terminal state answers 409. A cancelled job is never charged.
Cancelling is for the job. The recording it ran on is a separate resource: DELETE /v1/uploads/{id} removes an upload and its bytes, and is refused with 409 while any job still references it — a job's record of what it ran on and what it charged for must not be able to dissolve.
curl -X POST https://drugs-introduction-exchanges-likely.trycloudflare.com/v1/jobs/$JOB_ID/cancel \
-H "Authorization: Bearer $DUETA_API_KEY"Listing jobs
GET /v1/jobs returns your jobs, newest first, in a paginated envelope. limit defaults to 20 (max 100) and offset to 0. Every job carries steps here too, so the list and the detail route never answer different shapes; the live Redis overlay is only applied on the detail route, so poll GET /v1/jobs/{id} for a moving bar. GET /v1/uploads is the equivalent for recordings.
// GET /v1/jobs?limit=20&offset=0
{
"items": [ /* job objects, newest first */ ],
"total": 143,
"limit": 20,
"offset": 0
}Managing keys
POST /v1/keys takes {"name": "..."} and is the only response that ever contains the full key. GET /v1/keys lists metadata, DELETE /v1/keys/{id} revokes one and answers 204, and POST /v1/keys/{id}/rotate replaces one in place, and rotating an already-revoked key answers 409.
// POST /v1/keys -> 200
{
"id": "a41f...",
"name": "production-server",
"prefix": "mk_live_8fQ2",
"key": "mk_live_8fQ2...full_secret",
"created_at": "2026-01-04T09:00:00Z"
}
// GET /v1/keys -> 200
// revoked_at is set once a key is revoked; rotated_at records when its secret
// was last rotated in place (null if never — the id and created_at do not move).
[ { "id": "a41f...", "name": "production-server", "prefix": "mk_live_8fQ2",
"created_at": "2026-01-04T09:00:00Z", "revoked_at": null, "rotated_at": null } ]Client libraries
There is no SDK. The API is plain HTTP — JSON, raw bodies, server-sent events, and multipart where you want it — and the samples on this page are the supported path. Our own console is written against exactly these endpoints, with a session cookie in place of a bearer key.
Paths are provisional before GA. If one changes we will version or alias it rather than break you, and say so in the changelog.
Every sample on this page reads your key from DUETA_API_KEY.