Skip to content
DUETA
Docs

Upload audio

Declare a file, send its bytes, resume if the transfer drops, and wait for ready.

On this page
You need
  • An API key. See Authenticate.
  • One audio file: WAV, AIFF, FLAC, MP3, M4A / AAC, OGG / Opus, WMA, CAF, or AMR. Up to 200 MB, at least 0.5 s, not silence.

Declare the file

POST/v1/uploads

An upload is a file we hold for you. It costs nothing, queues nothing, and can be used by any number of jobs. The response is the upload in state pending with the URL to send the bytes to.

FieldTypeMeaning
filenamestringDecides the format by extension. Video extensions are refused here.
size_bytesintOptional and worth sending: checked against the cap before a byte moves, and a body that does not match it is refused instead of stored short.
content_typestringRecorded, not trusted. The file is identified by decoding it.
callback_urlstringOptional. POSTed this upload's state changes; see Receive callbacks.
bash
curl -X POST https://dueta.ai/v1/uploads \
  -H "Authorization: Bearer $DUETA_API_KEY" \
  -H "Content-Type: application/json" \
  -d "{\"filename\": \"conversation.wav\", \"size_bytes\": $(wc -c < conversation.wav)}"
# -> 201 {"id": "upl_9f2c7a41d0e8", "state": "pending",
#         "content_url": "/v1/uploads/upl_9f2c7a41d0e8/content", ...}

Send the bytes

PUT/v1/uploads/{id}/content

The body is the file itself, raw, not multipart. When the last byte lands the file is decoded on our disk and the upload becomes ready with its real duration, sample rate, channel count and SHA-256. Those are the numbers every later step uses.

json
// POST /v1/uploads -> 201
{
  "id": "upl_9f2c7a41d0e8",
  "filename": "conversation.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
}

// After the last byte: decoded on our disk, every number measured.
{ "state": "ready", "bytes_received": 91240044, "duration_seconds": 612.4,
  "sample_rate": 48000, "channels": 2, "checksum_sha256": "b1946ac9...", "error": null }

// A refusal leaves the upload readable, with the reason.
{ "state": "failed", "bytes_received": 0,
  "error": "tiny.wav is only 0.10s long. Audio must be at least 0.5s long." }
bash
# Raw body, not multipart. The response is the upload, now "ready".
curl -X PUT https://dueta.ai/v1/uploads/$UPLOAD_ID/content \
  -H "Authorization: Bearer $DUETA_API_KEY" \
  -H "Content-Type: application/octet-stream" \
  --data-binary @conversation.wav
# -> {"state": "ready", "duration_seconds": 612.4, "sample_rate": 48000, ...}

Resume a transfer

Read the upload, take bytes_received, and send the rest with Content-Range: bytes {start}-{end}/{total}. The next piece must start exactly where the server's count ends. A piece that starts anywhere else is a 409 naming the offset it should have used, and the upload is untouched.

bash
# Ask the server what it has, then send from exactly there.
HAVE=$(curl -sS -H "Authorization: Bearer $DUETA_API_KEY" https://dueta.ai/v1/uploads/$UPLOAD_ID | jq -r .bytes_received)
SIZE=$(wc -c < conversation.wav)

tail -c +$((HAVE + 1)) conversation.wav | curl -X PUT https://dueta.ai/v1/uploads/$UPLOAD_ID/content \
  -H "Authorization: Bearer $DUETA_API_KEY" \
  -H "Content-Type: application/octet-stream" \
  -H "Content-Range: bytes $HAVE-$((SIZE - 1))/$SIZE" \
  --data-binary @-

Wait for ready

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

Poll the upload, or subscribe to it as server-sent events: each frame is the same body the GET returns, and the stream ends at a settled state.

stateMeaning
pendingDeclared, no bytes yet.
receivingBytes are arriving. bytes_received is live.
readyDecoded. Duration, sample rate, channels and checksum are set.
failedRefused after bytes were written; error says why. Send again from byte 0.
expiredUnused for 24 hours and swept with its bytes. Create a new upload.
bash
# Poll...
curl -H "Authorization: Bearer $DUETA_API_KEY" https://dueta.ai/v1/uploads/$UPLOAD_ID | jq '{state, bytes_received, error}'

# ...or subscribe. Same body, pushed; ends at ready / failed / expired.
curl -N -H "Authorization: Bearer $DUETA_API_KEY" https://dueta.ai/v1/uploads/$UPLOAD_ID/events

Cases

415
Video by name (.mp4 .mov .mkv .webm .avi .m4v) or by content. Export the audio track (M4A, WAV, MP3) and try again.
413
Over 200 MB. A declared size over the cap is refused at creation; a body that grows past it is refused mid-stream and the upload is left failed.
400
The file does not decode, is under 0.5 s, is digital silence, the filename is unusable, or Content-Range is malformed. The body is not read when the headers are wrong.
400 length
Fewer bytes than declared: the transfer stopped early, send again. More than declared: this upload was made for a different file; create one at the real size.
409
The piece did not start at bytes_received, or the upload already has all of its bytes. Re-read the upload and continue from its count.
410
The upload expired. Creating a job from an upload pushes its expiry out, and a job hardlinks the audio, so a finished job never loses its input.
DELETE
DELETE /v1/uploads/{id} is 204, or 409 while a job still references it. GET /v1/uploads?state=ready&limit=20 lists yours.
Rate limit
Creating uploads shares the 20 a minute per-account budget with job submission. Uploads do not count against the in-flight job ceiling.