On this page
- 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
/v1/uploadsAn 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.
| Field | Type | Meaning |
|---|---|---|
| filename | string | Decides the format by extension. Video extensions are refused here. |
| size_bytes | int | Optional 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_type | string | Recorded, not trusted. The file is identified by decoding it. |
| callback_url | string | Optional. POSTed this upload's state changes; see Receive callbacks. |
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
/v1/uploads/{id}/contentThe 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.
// 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." }# 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.
# 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
/v1/uploads/{id}/v1/uploads/{id}/eventsPoll 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.
| state | Meaning |
|---|---|
| pending | Declared, no bytes yet. |
| receiving | Bytes are arriving. bytes_received is live. |
| ready | Decoded. Duration, sample rate, channels and checksum are set. |
| failed | Refused after bytes were written; error says why. Send again from byte 0. |
| expired | Unused for 24 hours and swept with its bytes. Create a new upload. |
# 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/eventsCases
- 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}is204, or409while a job still references it.GET /v1/uploads?state=ready&limit=20lists 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.