Uploads
Bytes move directly to storage over presigned URLs; this API only registers what landed.
POST /v1/uploads
Open an upload and get a transfer target. No endpoint accepts media bytes: every byte moves directly to storage over a short-lived presigned URL. declared_bytes is required for upload and omitted for recording; sha256 is optional for upload and forbidden for recording. create_run defaults true and creates the linked run in status receiving in the same transaction.
Scope: uploads:write · accepts Idempotency-Key
Parameters
| Name | In | Type | Notes |
|---|---|---|---|
Idempotency-Key | header | string | A value you will not reuse by accident — a UUID. A repeat within 24 hours returns the original response with its original status code (07 §3). |
Request body
| Field | Type | Required | Notes |
|---|---|---|---|
kind | upload | recording | yes | |
filename | string | Advisory; becomes the run title after sanitising. Never decides media type. | |
content_type | string | Advisory, recorded for diagnostics, never trusted. | |
declared_bytes | integer | Required for upload, omitted for recording. Checked against the byte ceiling before any URL is issued. | |
sha256 | string | Optional for upload; forbidden for recording. Lets the cached lane answer before a byte moves. | |
part_size | integer | Requested part size in bytes; clamped to [5 MiB, 64 MiB] for multipart. | |
create_run | boolean | Default true. | |
run | object |
{
"kind": "upload",
"filename": "board-meeting.m4a",
"content_type": "audio/mp4",
"declared_bytes": 148234901,
"create_run": true
}Responses
201 — The upload is open. For storage_mode single the one transfer target is inline.
| Field | Type | Required | Notes |
|---|---|---|---|
upload_id | uuid | yes | |
kind | upload | recording | yes | |
status | UploadStatus | yes | |
storage_mode | StorageMode | yes | |
part_size | integer | null | ||
max_parts | integer | yes | |
expires_at | date-time | yes | |
run_id | uuid | null | yes | null when create_run was false. |
limits | object | yes | |
target | TransferTarget | null | Present for storage_mode single: the one transfer target, inline. |
{
"upload_id": "0b5f2e63-83a1-4a30-9a35-64d2f0f9a7ab",
"kind": "upload",
"status": "open",
"storage_mode": "multipart",
"part_size": 8388608,
"max_parts": 10000,
"expires_at": "2026-03-12T18:04:11Z",
"run_id": "0195c8e4-8d02-7c19-b3e7-5a1f9d20c68b",
"limits": {
"max_run_bytes": 2147483648,
"max_run_seconds": 28800
},
"target": null
}Errors: invalid_request (400), unauthenticated (401), forbidden (403), rate_limited (429), internal_error (500), payload_too_large (413), quota_exceeded (429), engine_unavailable (503)
cURL
curl -sS -X POST https://<host>/v1/uploads \
-H "Authorization: Bearer $SCRIPTRIP_KEY" -H "Content-Type: application/json" \
-H "Idempotency-Key: $(uuidgen)" \
-d '{"kind":"upload","filename":"board-meeting.m4a","declared_bytes":148234901,"create_run":true}'GET /v1/uploads/{id}
State of an upload. What a client asks after a crash. received_part_indexes is the complete list, so reconciliation is set arithmetic.
Scope: uploads:write
Parameters
| Name | In | Type | Notes |
|---|---|---|---|
id | path | uuid | The upload id. |
Responses
200 — The upload.
| Field | Type | Required | Notes |
|---|---|---|---|
upload_id | uuid | yes | |
kind | upload | recording | yes | |
status | UploadStatus | yes | |
storage_mode | StorageMode | yes | |
part_count | integer | yes | |
received_bytes | integer | yes | |
received_part_indexes | integer[] | yes | The complete list, not a range: a hole at index 12 must be expressible. |
highest_contiguous_part | integer | yes | |
has_more_parts | boolean | true only when the list was truncated at 10 000. | |
run_id | uuid | null | yes | |
created_at | date-time | yes | |
expires_at | date-time | yes |
{
"upload_id": "0b5f2e63-83a1-4a30-9a35-64d2f0f9a7ab",
"kind": "recording",
"status": "open",
"storage_mode": "parts",
"part_count": 41,
"received_bytes": 4975104,
"received_part_indexes": [
1,
2,
3,
4,
5,
6,
7,
8,
9,
10,
11,
12,
13,
14,
15,
16,
17,
18,
19,
20,
21,
22,
23,
24,
25,
26,
27,
28,
29,
30,
31,
32,
33,
34,
35,
36,
37,
38,
39,
40,
41
],
"highest_contiguous_part": 41,
"has_more_parts": false,
"run_id": "0195c8e4-8d02-7c19-b3e7-5a1f9d20c68b",
"created_at": "2026-03-11T17:12:04Z",
"expires_at": "2026-03-12T17:12:04Z"
}Errors: unauthenticated (401), forbidden (403), rate_limited (429), internal_error (500), not_found (404)
cURL
curl -sS "https://<host>/v1/uploads/$UPLOAD_ID" -H "Authorization: Bearer $SCRIPTRIP_KEY"POST /v1/uploads/{id}/parts
Get transfer targets, or record parts that landed. One endpoint, two operations, told apart by the presence of received. part_index is 1-based and contiguous; at most 100 targets per request; a target expires in 15 minutes. sha256 is required on every reported part; etag only in multipart mode.
Scope: uploads:write · Per account, 600 per minute — a recorder draining a backlog is a legitimate burst.
Parameters
| Name | In | Type | Notes |
|---|---|---|---|
id | path | uuid | The upload id. |
Request body
| Field | Type | Required | Notes |
|---|---|---|---|
parts | object[] | yes |
{
"parts": [
{
"part_index": 1,
"bytes": 8388608,
"sha256": "3f79bb7b435b05321651daefd374cdc681dc06faa65e374e38337b88ca046dea",
"etag": "\"9b2cf535f27731c974343645a3985328\"",
"received": true
}
]
}Responses
200 — Targets, when the request asked for them; progress, when it recorded landed parts.
Shape 1
| Field | Type | Required | Notes |
|---|---|---|---|
upload_id | uuid | yes | |
targets | TransferTarget[] | yes |
Shape 2
| Field | Type | Required | Notes |
|---|---|---|---|
upload_id | uuid | yes | |
status | UploadStatus | yes | |
part_count | integer | yes | |
received_bytes | integer | yes | |
highest_contiguous_part | integer | yes |
{
"upload_id": "0b5f2e63-83a1-4a30-9a35-64d2f0f9a7ab",
"status": "open",
"part_count": 1,
"received_bytes": 8388608,
"highest_contiguous_part": 1
}Errors: invalid_request (400), unauthenticated (401), forbidden (403), rate_limited (429), internal_error (500), not_found (404), conflict (409), payload_too_large (413)
cURL
curl -sS -X POST "https://<host>/v1/uploads/$UPLOAD_ID/parts" \
-H "Authorization: Bearer $SCRIPTRIP_KEY" -H "Content-Type: application/json" \
-d '{"parts":[{"part_index":1},{"part_index":2}]}'POST /v1/uploads/{id}/complete
Finish and verify. One statement with every guard in its predicate. The run becomes queued. A retried completion promotes nothing a second time and says so. 409 conflict names the actual cause: already complete, aborted, expired, a part-count mismatch, or a gap at index n.
Scope: uploads:write
Parameters
| Name | In | Type | Notes |
|---|---|---|---|
id | path | uuid | The upload id. |
Request body
| Field | Type | Required | Notes |
|---|---|---|---|
part_count | integer | yes | |
sha256 | string | Over the concatenated media in part order. Required for recording, optional for upload. | |
duration_seconds | number | Advisory; lets the duration ceiling be enforced now. Overwritten by the measured value. |
{
"part_count": 18,
"sha256": "b1946ac92492d2347c6235b4d2611184c4d3f0a1e6f2b8c9d0e1f2a3b4c5d6e7",
"duration_seconds": 3612
}Responses
200 — Verified; the run is queued.
| Field | Type | Required | Notes |
|---|---|---|---|
upload_id | uuid | yes | |
status | UploadStatus | yes | |
part_count | integer | yes | |
received_bytes | integer | yes | |
sha256 | string | null | yes | |
run | Run | null | yes | |
message | string | yes |
{
"upload_id": "0b5f2e63-83a1-4a30-9a35-64d2f0f9a7ab",
"status": "complete",
"part_count": 18,
"received_bytes": 148234901,
"sha256": "b1946ac92492d2347c6235b4d2611184c4d3f0a1e6f2b8c9d0e1f2a3b4c5d6e7",
"run": {
"id": "0195c8e4-8d02-7c19-b3e7-5a1f9d20c68b",
"source_type": "upload",
"source_url": null,
"source_upload_id": "0b5f2e63-83a1-4a30-9a35-64d2f0f9a7ab",
"title": null,
"author_name": null,
"author_url": null,
"description": null,
"published_on": null,
"duration_seconds": null,
"thumbnail_url": null,
"status": "queued",
"lane": null,
"engine": null,
"current_step": null,
"step_log": [],
"language": null,
"requested_language": null,
"name_speakers": true,
"segments": null,
"segment_count": null,
"speaker_count": null,
"word_count": null,
"speaker_labels": null,
"cached_from_run_id": null,
"media_fingerprint": "b1946ac92492d2347c6235b4d2611184c4d3f0a1e6f2b8c9d0e1f2a3b4c5d6e7",
"error": null,
"error_class": null,
"retry_after": null,
"idempotency_key": null,
"created_at": "2026-03-11T18:04:11Z",
"updated_at": "2026-03-11T18:04:11Z",
"completed_at": null,
"transcript_url": null,
"expected_steps": null,
"seconds_until_modal": 79,
"live_workers": {
"count": 2,
"kind": "utility"
},
"worker_label": null,
"machine_quiet": false
},
"message": "18 parts verified. The run is queued."
}Errors: invalid_request (400), unauthenticated (401), forbidden (403), rate_limited (429), internal_error (500), not_found (404), conflict (409), payload_too_large (413), unprocessable_source (422)
cURL
curl -sS -X POST "https://<host>/v1/uploads/$UPLOAD_ID/complete" \
-H "Authorization: Bearer $SCRIPTRIP_KEY" -H "Content-Type: application/json" \
-d '{"part_count":18}'DELETE /v1/uploads/{id}
Abort. Aborting is never an error: 200 whether this request moved it or found it already aborted or expired. A complete upload cannot be aborted: 409 conflict. A linked run in receiving becomes canceled.
Scope: uploads:write
Parameters
| Name | In | Type | Notes |
|---|---|---|---|
id | path | uuid | The upload id. |
Responses
200 — The state, and whether this request changed it.
| Field | Type | Required | Notes |
|---|---|---|---|
upload_id | uuid | yes | |
status | UploadStatus | yes | |
run | object | null | yes | |
message | string | yes |
{
"upload_id": "0b5f2e63-83a1-4a30-9a35-64d2f0f9a7ab",
"status": "aborted",
"run": {
"id": "0195c8e4-8d02-7c19-b3e7-5a1f9d20c68b",
"status": "canceled"
},
"message": "The upload was aborted and its run was canceled."
}Errors: unauthenticated (401), forbidden (403), rate_limited (429), internal_error (500), not_found (404), conflict (409)
cURL
curl -sS -X DELETE "https://<host>/v1/uploads/$UPLOAD_ID" -H "Authorization: Bearer $SCRIPTRIP_KEY"