Uploads
Media bytes never pass through the API. You upload them in parts straight to storage, using presigned URLs that the API hands out. The flow has four calls:
POST /mediaregisters the file and returns how to split it.POST /media/{id}/partsreturns a presigned URL for each part you ask for. YouPUTthe bytes to that URL.POST /media/{id}/completeassembles the parts and queues the transcription.- Optionally,
DELETE /media/{id}aborts the upload.
Quickstart runs these calls end to end.
Limits
Section titled “Limits”- Largest file: 5 GB.
- Longest file: 10 hours. A longer file is accepted, but only the first part up to that duration is transcribed, and the transcription has
truncatedset totrue. - Transcriptions per day: No daily cap. A transcription counts when you complete the upload, not when you create the media record.
- Fair use: 300 audio hours per month. Past that, jobs move to the standard queue. They are not rejected.
- A limited number of uploads and jobs can be in progress at once. Your count and limit are in
inFlighton Usage. - A pending upload is open for 24 hours after you create it. After that it is rejected with
rejectReasonset toupload_expired. - Presigned part URLs are valid for about one hour. The response has an
expiresAtfor each batch.
For the file types Scribewren accepts, see Uploading files.
Create media
Section titled “Create media”POST /media
Registers a file and starts a multipart upload. Send a JSON body.
| Field | Type | Required | Notes |
|---|---|---|---|
filename |
string | Yes | 1 to 255 characters. |
sizeBytes |
integer | Yes | The exact size of the file in bytes, at least 1. |
contentType |
string | No | Up to 255 characters. Defaults to application/octet-stream. |
lastModified |
string or null | No | UTC ISO 8601 timestamp of the file. |
durationMs |
integer or null | No | Duration reported by your client. It is untrusted and used only as a routing hint. |
options |
object | Yes | See the next table. Send {} to take the defaults. |
The options object takes these keys. Leave out a key to use its default.
| Key | Type | Default | Notes |
|---|---|---|---|
language |
string | auto |
auto detects the language. Otherwise use a transcription code from GET /languages. An unknown code is VALIDATION_ERROR with reason invalid_enum. |
diarization |
boolean | true |
Label who speaks when. |
expectedSpeakers |
integer or null | null |
A hint from 1 to 10. The current transcription backend may ignore it. |
Unknown fields anywhere in the body are rejected.
curl -sS -X POST "https://api.scribewren.com/v1/media" \ -H "Authorization: Bearer $SCRIBEWREN_API_KEY" \ -H "Content-Type: application/json" \ -d '{"filename":"interview.mp3","sizeBytes":734003200,"contentType":"audio/mpeg","options":{"language":"auto","diarization":true}}'The response is 201 with a Media object. The upload object tells you how to split the file.
{ "success": true, "code": 201, "message": "Created", "data": { "id": "019a4f2d-e5a0-7b42-8c11-6d2f0e9a7b13", "status": "pending_upload", "filename": "interview.mp3", "sizeBytes": 734003200, "contentType": "audio/mpeg", "lastModified": null, "options": { "language": "auto", "diarization": true, "expectedSpeakers": null }, "upload": { "uploadId": "EXAMPLE-UPLOAD-ID", "partSizeBytes": 8388608, "partCount": 88, "expiresAt": "2026-12-02T09:41:07.512Z" }, "sourceDurationMs": null, "rejectReason": null, "createdAt": "2026-12-01T09:41:07.512Z", "uploadedAt": null, "processedAt": null }, "error": null, "meta": { "requestId": "019a4f30-1c22-7a10-b5d3-42f1c0a8e777", "timestamp": "2026-12-01T09:41:07.512Z" }}Split the file into partCount parts of exactly partSizeBytes bytes each. The last part holds the remainder. partCount is at most 9000.
| Error | Status | When |
|---|---|---|
VALIDATION_ERROR |
400 | A field is missing, has the wrong type or is out of range, or the body has an unknown field. |
FILE_TOO_LARGE |
400 | sizeBytes is above the plan’s maximum. error.details.maxBytes has the limit. |
UNSUPPORTED_MEDIA_TYPE |
400 | The extension is not supported and contentType is not audio/* or video/*. error.details has extension and contentType. |
UNSUPPORTED_MEDIA_TYPE |
415 | The request Content-Type is not application/json. The status tells the two cases apart. |
PLAN_LIMIT_EXCEEDED |
403 | A plan limit blocks the upload, such as too many uploads and jobs in progress (details.limit is concurrency). |
ACCOUNT_RESTRICTED |
403 | The account cannot start new processing. |
SERVICE_UNAVAILABLE |
503 | New uploads are paused (details.reason is uploads_paused). Retry later. |
RATE_LIMITED |
429 | See Rate limits. |
Get part URLs
Section titled “Get part URLs”POST /media/{id}/parts
| Field | Type | Required | Notes |
|---|---|---|---|
partNumbers |
array of integers | Yes | 1 to 100 numbers, each from 1 to 9000. |
The response lists one presigned URL per part and when the URLs expire.
{ "parts": [ { "partNumber": 1, "url": "https://storage.example.com/EXAMPLE-PRESIGNED-URL-1" }, { "partNumber": 2, "url": "https://storage.example.com/EXAMPLE-PRESIGNED-URL-2" } ], "expiresAt": "2026-12-01T10:41:07.512Z"}This object is the data field of the usual envelope. Upload each part with an HTTP PUT to its URL. Send the raw bytes as the body, and no Authorization header: the signature is part of the URL. A successful response has an ETag header. Keep it exactly as returned, including any quotes, because complete needs it.
Presigned URLs are secrets for their lifetime. Do not log them or share them.
You can ask for URLs in batches of up to 100, or one at a time just before each upload. If a URL expires, ask for a new one for that part number.
The media must be in pending_upload. Otherwise the call returns 409 INVALID_STATE_TRANSITION with details.reason set to not_pending.
List stored parts
Section titled “List stored parts”GET /media/{id}/parts
Returns the parts that storage already holds, so you can resume an interrupted upload.
{ "parts": [ { "partNumber": 1, "etag": "\"EXAMPLEETAG1\"", "sizeBytes": 8388608 } ]}The same 409 applies when the media is not pending. If storage has already dropped the upload, details.reason is upload_expired.
Complete the upload
Section titled “Complete the upload”POST /media/{id}/complete
| Field | Type | Required | Notes |
|---|---|---|---|
parts |
array of objects | Yes | One { "partNumber", "etag" } object for every part, ordered from 1 to partCount. |
The body can be up to 1 MiB. A larger body returns 413 PAYLOAD_TOO_LARGE.
On success the API assembles the file, checks that its size matches sizeBytes, creates the transcription and returns both objects with status 201.
{ "media": { "id": "019a4f2d-e5a0-7b42-8c11-6d2f0e9a7b13", "status": "uploaded" }, "transcription": { "id": "019a4f2e-7c1b-7d3a-9f00-3c5e8a1b2d4f", "status": "queued" }}The example shows only the main fields. media is a full Media object and transcription is a full Transcription object, as described in Transcriptions.
Completing is idempotent. If you call it again for a media that is already complete, you get 200 with the same result and no second transcription.
| Error | Status | When |
|---|---|---|
UPLOAD_INVALID |
400 | The parts do not match the upload. details.reason is part_count_mismatch, invalid_parts, missing_object or size_mismatch. After size_mismatch the media is rejected. |
INVALID_STATE_TRANSITION |
409 | The media is no longer pending (details.reason is not_pending). |
PLAN_LIMIT_EXCEEDED |
403 | A plan limit is reached, such as too many uploads and jobs open at once (details.limit is concurrency). |
ACCOUNT_RESTRICTED |
403 | The account cannot start new processing. |
PAYLOAD_TOO_LARGE |
413 | The body is over 1 MiB. |
SERVICE_UNAVAILABLE |
503 | New uploads are paused. |
Abort an upload
Section titled “Abort an upload”DELETE /media/{id}
Aborts a pending upload and discards the parts. The response is 200 with data set to null. If the media is not pending, the call returns 409 INVALID_STATE_TRANSITION.
To delete a finished transcription, use DELETE /transcriptions/{id} instead. See Transcriptions.
List pending uploads
Section titled “List pending uploads”GET /media?status=pending_upload
Returns up to 50 uploads that are still open, newest first, so a client can find an upload to resume. The status query parameter is required and pending_upload is the only accepted value. Anything else is VALIDATION_ERROR with reason invalid_enum.
The Media object
Section titled “The Media object”| Field | Type | Notes |
|---|---|---|
id |
string | UUID. |
status |
string | pending_upload, uploaded, processed or rejected. |
filename |
string | As you sent it. Only you can read it. |
sizeBytes |
integer | |
contentType |
string | Lower-cased. |
lastModified |
string or null | |
options |
object | language, diarization and expectedSpeakers. |
upload |
object or null | Set only while status is pending_upload. Has uploadId, partSizeBytes, partCount and expiresAt. |
sourceDurationMs |
integer or null | The full length of the media, once it is known. |
rejectReason |
string or null | Set when status is rejected. One of user_aborted, upload_expired, size_mismatch, unsupported_type, unreadable or no_audio_stream. |
createdAt, uploadedAt, processedAt |
string or null | UTC timestamps. |
Can't find what you need? Contact support, email [email protected], or open Help and then Contact in the app. scribewren.com