Uploads

No endpoint accepts media bytes. Every byte moves directly between your client and object storage over a short-lived presigned URL; the API issues the URL, records what landed, and verifies the result. That is why a two-gigabyte file never waits on an application server.

An upload is a file you have; a recording is an upload whose parts arrive while it is being made. They differ in what the interface says, never in how the bytes are handled.

The flow

POST /v1/uploads              → upload_id, storage_mode, run_id
POST /v1/uploads/{id}/parts   → transfer targets         ┐ repeat
   PUT <presigned url>        → storage (not this API)   │ until
POST /v1/uploads/{id}/parts   → record what landed       ┘ done
POST /v1/uploads/{id}/complete→ verified; the run becomes queued

GET /v1/uploads/{id} is available at any point — it is what you ask after a crash — and DELETE /v1/uploads/{id} abandons.

1. Open

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}'
{"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}

declared_bytes is required for an upload and omitted for a recording, whose size is unknown when it opens. It is checked against your byte ceiling before a single URL is issued. sha256 of the whole file is optional for an upload — supply it and the cache can answer before a byte moves — and forbidden for a recording. filename and content_type are advisory: the media type is decided by content, never by what you called it.

The server picks storage_mode: single (one PUT, target returned inline in this response), multipart (large files, part_size bytes each) or parts (recordings, chunk by chunk). Store it; it never changes for an upload in flight.

2. Get targets, transfer, report

Ask for targets — up to 100 at once — and PUT each part to its URL with the headers given. part_index is 1-based and contiguous. A target expires in 15 minutes; asking again for an index you already sent returns a fresh URL and the re-transfer overwrites.

curl -sS -X POST "https://<host>/v1/uploads/0b5f2e63-83a1-4a30-9a35-64d2f0f9a7ab/parts" \
  -H "Authorization: Bearer $SCRIPTRIP_KEY" -H "Content-Type: application/json" \
  -d '{"parts":[{"part_index":1},{"part_index":2}]}'
{"upload_id":"0b5f2e63-83a1-4a30-9a35-64d2f0f9a7ab","targets":[{"part_index":1,"method":"PUT","url":"https://storage.<host>/…&partNumber=1&X-Amz-Signature=…","headers":{"Content-Type":"application/octet-stream"},"expires_at":"2026-03-11T18:19:11Z"},{"part_index":2,"method":"PUT","url":"https://storage.<host>/…&partNumber=2&X-Amz-Signature=…","headers":{"Content-Type":"application/octet-stream"},"expires_at":"2026-03-11T18:19:11Z"}]}

After a part lands, report it. The same endpoint, told apart by received: true. sha256 of the part is required in every mode; etag (from the storage response) only in multipart.

curl -sS -X POST "https://<host>/v1/uploads/0b5f2e63-83a1-4a30-9a35-64d2f0f9a7ab/parts" \
  -H "Authorization: Bearer $SCRIPTRIP_KEY" -H "Content-Type: application/json" \
  -d '{"parts":[{"part_index":1,"bytes":8388608,"sha256":"3f79bb7b435b05321651daefd374cdc681dc06faa65e374e38337b88ca046dea","etag":"\"9b2cf535f27731c974343645a3985328\"","received":true}]}'
{"upload_id":"0b5f2e63-83a1-4a30-9a35-64d2f0f9a7ab","status":"open","part_count":1,"received_bytes":8388608,"highest_contiguous_part":1}

Reporting is idempotent: a duplicate report cannot double-count. A report against an upload that is no longer open is 409 conflict naming its real status. Crossing the byte ceiling is 413 payload_too_large here, so a runaway client is stopped mid-transfer, not after it.

3. Complete

curl -sS -X POST "https://<host>/v1/uploads/0b5f2e63-83a1-4a30-9a35-64d2f0f9a7ab/complete" \
  -H "Authorization: Bearer $SCRIPTRIP_KEY" -H "Content-Type: application/json" \
  -d '{"part_count":18,"sha256":"b1946ac92492d2347c6235b4d2611184c4d3f0a1e6f2b8c9d0e1f2a3b4c5d6e7"}'
{"upload_id":"0b5f2e63-83a1-4a30-9a35-64d2f0f9a7ab","status":"complete","part_count":18,"received_bytes":148234901,"sha256":"b1946ac92492d2347c6235b4d2611184c4d3f0a1e6f2b8c9d0e1f2a3b4c5d6e7","run":{"id":"0195c8e4-8d02-7c19-b3e7-5a1f9d20c68b","status":"queued","…":"…"},"message":"18 parts verified. The run is queued."}

part_count must equal the number of parts recorded and every index 1..n must exist; otherwise 409 conflict names the cause — a mismatch, or a gap at index 12. sha256 over the whole media in part order is required for a recording and optional for an upload. duration_seconds is advisory and lets the duration ceiling be enforced now rather than after decoding.

What is verified when. Part indexes and counts, here, in one statement. Checksums are verified on the worker that reads every byte anyway, during fetch; a mismatch fails the run with error_class: "incomplete_upload" naming the part. Container, codec, audio presence and measured duration are established by normalize. Nothing hashes a gigabyte inside a request.

After a crash

GET /v1/uploads/{id} returns the complete list of received part indexes — a list, not a range, because a hole at index 12 must be expressible. Reconcile, re-request targets for what is missing, and continue.

curl -sS "https://<host>/v1/uploads/0b5f2e63-83a1-4a30-9a35-64d2f0f9a7ab" -H "Authorization: Bearer $SCRIPTRIP_KEY"
{"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,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,42],"highest_contiguous_part":11,"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"}

An open upload expires after 24 hours; each part report extends it.

Abort

curl -sS -X DELETE "https://<host>/v1/uploads/0b5f2e63-83a1-4a30-9a35-64d2f0f9a7ab" -H "Authorization: Bearer $SCRIPTRIP_KEY"
{"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."}

Aborting is never an error: 200 whether this request moved it or found it already aborted or expired. Only a complete upload cannot be aborted (409). The staged parts are deleted and a linked run still receiving becomes canceled.

Recording

A recording is the same protocol with kind: "recording", storage_mode: "parts", and parts sent as they are captured — a chunk every few seconds — so a two-hour take is visible in the list from its first second and survives a dropped connection: on reconnect, GET /v1/uploads/{id}, reconcile, and continue from the first missing index. Complete when the recording ends, with the whole-media sha256 and the duration.