Skip to content
DUETA
API reference

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.

https://drugs-introduction-exchanges-likely.trycloudflare.comBearer auth
01

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.

  1. 01vocal_separationoptional
  2. 02separationalways runs
  3. 03enhancementoptional
  4. 04super_resolutionoptional
  5. 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.

Note
Two speakers are assumed to be present, and two tracks always come back. Given a monologue the pipeline force-splits the single voice into two overlapping tracks rather than detecting it, so send it audio you expect to contain two people.
02

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.

Careful
The host in these samples, 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.
bash
# 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
  1. Upload. POST /v1/uploads declares the recording, then PUT /v1/uploads/{id}/content sends its raw bytes. When the last one lands the file is decoded here and the upload turns ready, carrying its real duration. Nothing is charged yet.
  2. Submit. POST /v1/jobs/separation with {"inputs": ["upl_…"]}. No bytes in this request. The response is a job that has not started yet.
  3. Follow it. GET /v1/jobs/{id} until status leaves queued/running, or subscribe to /v1/jobs/{id}/events. steps, queue_position, progress and eta_seconds say where it is.
  4. Download. Read the names from result.stems[].name and fetch each from GET /v1/jobs/{id}/stems/{name}.
Note
No key yet? Create an account and issue one from the console, or do both over the API. The calls are in Authentication.
03

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.

http
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.

bash
# 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..."
Careful
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.

04

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.

RequirementValueIf it is not met
count1 audio file for separation400: "separation takes exactly 1 audio file (got 2)"
formatWAV, MP3, FLAC, AIFF400: the rejected type is named in detail
duration (per file)At least 0.5 seconds400, 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 file400, at job creation (POST /v1/jobs/quote reports the identical sentence as a reasons entry instead): "Total duration 8000s exceeds the cap of 7200s"
sizeUp to 200 MB per file413, refused mid-stream rather than after the whole transfer
signalNot digital silence400: 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.

RouteWhen
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 urlUnchanged, 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.

Note
Our own console is an ordinary client of this API. It creates uploads, PUTs their bytes, resumes with 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.
05

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.

POST/v1/uploads
PUT/v1/uploads/{id}/content
GET/v1/uploads/{id}
GET/v1/uploads
GET/v1/uploads/{id}/events
DELETE/v1/uploads/{id}
Note
An upload holds no credit and queues nothing. Creating one, sending it, and deleting it never costs anything. The price is quoted and the credit held when you create a job from it.

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_bytes is 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_type is recorded, not trusted. The file is identified by decoding it.
  • callback_url returns a callback_secret — once, in this response only, and never echoed by a later read. See Callbacks & streaming.
json
// 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.

Careful
Resume from the server's count, never from your own. A connection that dropped left real bytes on our disk, and 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

StateMeaning
pendingDeclared. No bytes yet.
receivingBytes 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.
readyEvery byte is in and the file decoded. The only state a job can be created from.
failedRefused, with the reason in error in words a user can act on. Sending the file again from byte 0 reopens it.
expiredSwept, 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 answers 204. It is refused with 409 while 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

POST/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.

json
// 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.

json
// 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.

bash
# 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)"]}'
Note
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.
06

Running a separation

POST/v1/jobs/separation

The 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.

Note
Send 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

FieldTypeDefaultWhat it does
inputsstring[]requiredThe 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_separationbooleantrueStrips music, applause, and broadband background out of the mix before the speakers are told apart, so the separator only has to model voices.
enhancementbooleantrueDenoises each separated speaker track: hiss, hum, room tone, and handling noise are attenuated while the voice is left in place.
super_resolutionbooleantrueRestores high-frequency bandwidth and writes the output tracks at 48 kHz instead of 16 kHz.
scoringbooleantrueEstimates SI-SDR for the separated pair with a no-reference model and reports it on every track.
Careful
There is no 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.

FieldWhen to change it
vocal_separationTurn it off when the recording is already dry speech: a studio double-ender, a headset call, a meeting-room capture with nothing playing.
enhancementTurn it off on already-clean studio material, where denoising can only risk softening a voice that had nothing wrong with it.
super_resolutionTurn it off when your pipeline consumes 16 kHz and would only resample the extra bandwidth away, as most ASR, telephony and VoIP paths do.
scoringTurn it off when you never read the number, such as an automated pipeline that keeps whatever comes back.
Everything onAnything you did not record yourself: field recordings, podcasts over a music bed, phone-in interviews.
"inputs": ["upl_…"]
Send nothing and every phase runs. Output at 48 kHz with an SI-SDR estimate on each track.
Clean speech inA studio double-ender or a headset call: two dry voices, nothing musical behind them.
vocal_separation=false
Skips a pass that has nothing to remove, and with it the small amount of transient smoothing that pass costs on already-clean speech.
Feeding a 16 kHz modelThe tracks go straight into ASR, diarisation, or a telephony pipeline, and nobody listens to them.
super_resolution=falsescoring=falseenhancement=false
Output stays at 16 kHz, so there is nothing to resample downstream, and si_sdr comes back null. Enhancement is switched off too, which is usually what an ASR path wants.
Note
Turning phases off shortens the run; it does not reduce the charge. A job costs $0.50/min of its input audio whatever the pipeline did; see Billing & credits.

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.

bash
# 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.

json
{
  "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
}
07

Running a dominant separation

POST/v1/jobs/dominant-separation

The 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.

Careful
Order is not a formality. 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:

RuleValueIf it is not met
count2-10 recordings, one per source in the room400: "dominant-separation takes 2-10 audio files (got 1)"
no duplicatesEvery upload id named exactly once400: "The same upload is named more than once: …"
same takeThe longest recording may be at most 2x the shortest400 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.

bash
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 {}.

json
{
  "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.

json
{
  "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.stems is every input's stem name back, in input order, followed by "ambience" last. Take names from result.stems[].name rather than assuming an index — the same rule as Results.
  • si_sdr is null on every entry, always. SI-SDR is scored inside the separation pipeline only; dominant separation has no scoring step to produce one.
  • result.sample_rate is fixed at 24,000 Hz. There is no super_resolution toggle 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

Note
Dominant separation costs $0.50/min of its longest input, once — not the sum of the 2-10 recordings, because the separation runs a single pass over every mic aligned to one timeline. See Billing & credits for how that figure is quoted, held, and settled.
08

Tracking progress

GET/v1/jobs/{id}
GET/v1/jobs/{id}/events

One 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.

json
{
  "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
}
FieldSemantics
statusqueued, running, succeeded, failed, canceled. Treat anything but the first two as terminal and stop polling.
stepsThe step-by-step account of the processing, in pipeline order. This is what a step indicator is built from. See below.
queue_positionHow 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.
progress0 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.
stageThe 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_secondsBest-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.
inputsOne 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.

json
// 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 }
]
  • name is the step label, matching stage when that step is the running one. Duplex separation reports the five above; the single-stage products report one step called run.
  • state is pending, running, done, or skipped.
  • progress is that step's own 0..1, not the job's.
Careful
Render the indicator from 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_separationseparationenhancementsuper_resolutionsi_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.

json
{
  "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" */ ]
}
bash
# 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/events

The 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.

json
// 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 }
FieldSemantics
indexPosition in the list you named, and the order the job ran them in.
upload_idThe 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.
filenameThe 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.
stemThe name this input downloads by, at GET /v1/jobs/{id}/inputs/{stem}.
size_bytesRead 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.

09

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.

bash
# 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.

http
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.
HeaderValue
X-Dueta-EventThe transition, e.g. upload.receiving, upload.ready, upload.failed. Also the event field of the body.
X-Dueta-TimestampUnix seconds at which this delivery was signed. Part of the signed material.
X-Dueta-Signaturev1=<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=.

Careful
Compute it over the raw body, exactly as received — never over a re-serialised parse. A parsed-and-re-dumped body differs by a space and fails for a reason nobody enjoys finding. In practice that means reading the request as bytes before your framework's JSON middleware touches it.

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.

python
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 times

Delivery is best effort

Careful
A callback is a hint that something changed, not a ledger. Trust the resource it names: every payload carries it in full, and re-reading it with a GET is always correct.
  • 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

GET/v1/uploads/{id}/events
GET/v1/jobs/{id}/events

The 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.

text
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"}
  • : keepalive comments are sent between frames so proxies see traffic. A client parsing events never sees them.
  • A final event: done says the stream ended because the resource settled — an upload reaching ready, failed or expired, 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.
Careful
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.
bash
# -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/events
10

Results

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.

json
{
  "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.stems is always 2 entries, _spk1 before _spk2. Take each name from result.stems[].name rather than composing it: the suffixes depend on which phases ran, with enhancement adding _enh and super_resolution adding _48k after the speaker suffix, so _spk1 is not reliably the end of the string. Which person ended up on which track is arbitrary and does not carry across jobs.
  • result.sample_rate is 48,000 Hz when super_resolution ran and 16,000 Hz when it did not. Read the field rather than assuming the number.
  • si_sdr is null on every stem when scoring was off. The key is not omitted, so a client that reads it does not need to change.

Downloading tracks

GET/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.

bash
# 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"
done

Downloading the original

GET/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

POST/v1/jobs/{id}/share
GET/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.

bash
# 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
# -> 404

GET /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.

json
{
  "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 2 and 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_at to 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 DELETE kills 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 404 with the same sentence — never a 403, 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.

11

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/quote answers 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 in estimated_cost_seconds), and that amount is held.
  • On settlement the hold is released and the real charge is written as billed_usd, alongside the measured billed_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 402 before it is queued, so nothing is held and no job exists to cancel.
Note
The ledger is append-only and denominated in dollars, and one job writes up to three rows: a negative 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.

json
{
  "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.

json
// 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.

json
// 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.

json
// 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.

json
// 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"
    }
  ]
}
Careful
Purchasing is not open yet while the payment provider integration is pending. Until it opens, 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.
12

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 429 and "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.

Careful
Jobs execute one at a time on a single GPU slot in submission order, so submitting several at once is fine and none are lost, but wall-clock time for a batch is the sum of its parts. Talk to us if you need dedicated capacity.

Three unauthenticated endpoints are additionally limited per IP address:

EndpointLimitWhy
POST /v1/auth/signup5 / minuteSlows down bulk account creation for the signup credit.
POST /v1/auth/login10 / minuteSlows down password guessing.
POST /v1/contact3 / minuteKeeps 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-Resetwhole 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.

13

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.

json
// 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." } ] }
StatusMeaningWhat to do
400The 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.
401No 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.
402Not 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.
404No 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.
409The 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.
410The 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.
413The 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.
422A JSON body failed schema validation.detail is an array of {loc, msg, type}; loc points at the offending field.
429A 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.
5xxA 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.
Careful
Retry 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.
14

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.

MethodPathPurpose
POST/v1/uploadsDeclare a recording. Answers 201 with an id, a content_url, and an events_url.
PUT/v1/uploads/{id}/contentSend 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/uploadsList your uploads, newest first. limit, offset, and state= filter.
GET/v1/uploads/{id}/eventsSubscribe 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/quotePrice a set of uploads before creating the job. Reserves nothing.
POST/v1/jobs/separationRun Duplex separation over one upload: {"inputs": ["upl_..."]}.
POST/v1/jobs/dominant-separationRun 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}/eventsSubscribe 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}/cancelCancel a queued or running job.
GET/v1/jobsList your jobs, newest first, paginated.
POST/v1/jobs/{id}/sharePublish the result at a link anyone can open.
GET/v1/jobs/{id}/shareRead the job's current share.
DELETE/v1/jobs/{id}/shareRevoke 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/creditsCurrent dollar balance and the recent ledger.
GET/v1/usageBilled seconds and dollars aggregated per UTC day.
GET/v1/billing/topupTop-up bounds, presets, and the per-minute rate.
POST/v1/billing/checkoutStart a top-up for a whole dollar amount in range.
GET/v1/billing/ordersList your top-up orders, newest first.
GET/v1/billing/orders/{id}Read one top-up order and its status.
POST/v1/keysCreate an API key. The only response carrying the full secret. Session token only.
GET/v1/keysList key metadata: prefixes, never secrets. Session token only.
POST/v1/keys/{id}/rotateReplace 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.

bash
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.

json
// 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.

json
// 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.