> ## Documentation Index
> Fetch the complete documentation index at: https://apidocs.scripe.io/llms.txt
> Use this file to discover all available pages before exploring further.

# Uploads

# Uploads

`POST /v1/uploads` mints a **presigned S3 PUT URL** for a single file.
This is the entry point for any endpoint that takes an `uploadId`
(currently `POST /v1/sources` with `type: "file"` and `POST
/v1/knowledge` with `type: "file"`).

The flow is two-step on purpose:

1. **`POST /v1/uploads`** → returns `{ id: "upl_...", uploadUrl, ... }`.
2. **`PUT <uploadUrl>`** with your file bytes (no auth needed; the
   signed URL carries it). The S3 service receives the bytes directly.
3. **`POST /v1/sources` / `POST /v1/knowledge`** with `uploadId: "upl_..."`.

Why two steps:

* File payloads bypass the API edge entirely — Vercel and Cloudflare
  function tiers cap request bodies at \~4 MB, so a single-shot upload
  would not work for podcasts or PDFs.
* S3 enforces the size cap and content type at PUT time, so we don't
  pay egress on a rejected upload.
* The PUT can be resumed (S3 multi-part) without coordinating with
  Scripe's API.

The returned `uploadId` is **opaque** — treat it as a reference, don't
parse it. It's bound to your workspace; another workspace can't use it
even if they guess it.

***

## `POST /v1/uploads`

Required scope: any of `sources:write`, `knowledge:write` (we accept
either since the same upload can be used for both endpoints).

```bash theme={null}
curl -i https://api.scripe.io/v1/uploads \
  -X POST \
  -H "Authorization: Bearer scripe_sk_live_…" \
  -H "Scripe-Api-Version: 2026-08-01" \
  -H "Content-Type: application/json" \
  -d '{
    "contentType": "audio/mpeg",
    "maxSizeBytes": 104857600
  }'
```

### Request body

| Field          | Type    | Required | Notes                                                                                                                 |
| -------------- | ------- | -------- | --------------------------------------------------------------------------------------------------------------------- |
| `contentType`  | string  | yes      | The MIME type you'll PUT. Must be one of the allow-listed types below.                                                |
| `maxSizeBytes` | integer | no       | The size you commit to PUTting at most. Must be `>= 1` and `<=` the per-type cap below. Defaults to the per-type cap. |

### Allowed content types and size caps

| Family    | Examples                                                                                                                                 | Cap per object |
| --------- | ---------------------------------------------------------------------------------------------------------------------------------------- | -------------- |
| Audio     | `audio/mpeg`, `audio/wav`, `audio/mp4`                                                                                                   | 500 MB         |
| Video     | `video/mp4`, `video/quicktime`                                                                                                           | 1 GB           |
| Documents | `application/pdf`, `text/plain`, `text/markdown`, `text/html`, `application/vnd.openxmlformats-officedocument.wordprocessingml.document` | 50 MB          |
| Images    | `image/png`, `image/jpeg`                                                                                                                | 25 MB          |

If your file exceeds the cap, S3 will reject the PUT with a 412 — there
is no fallback path. Either compress, transcode, or chunk the upload at
your end (the dashboard does this for video clips).

If you supply a `contentType` that isn't in the table, the call fails
with `422 unprocessable`.

### Response

```http theme={null}
HTTP/1.1 200 OK
Content-Type: application/json

{
  "data": {
    "id": "upl_ws_01J9ZK…--upl_01J9ZL…",
    "uploadUrl": "https://scripe-uploads.s3.amazonaws.com/api-uploads/ws_01J9ZK…/upl_01J9ZL…?X-Amz-Algorithm=…",
    "method": "PUT",
    "contentType": "audio/mpeg",
    "maxSizeBytes": 104857600,
    "expiresAt": "2026-05-15T08:36:00.000Z"
  }
}
```

### Field reference

| Path           | Type    | Notes                                                                                                    |
| -------------- | ------- | -------------------------------------------------------------------------------------------------------- |
| `id`           | string  | Opaque handle; pass to `/v1/sources` or `/v1/knowledge` as `uploadId`.                                   |
| `uploadUrl`    | string  | Presigned S3 PUT URL. **Do not** add an `Authorization` header on the PUT — the signature is in the URL. |
| `method`       | string  | Always `"PUT"` in v1.                                                                                    |
| `contentType`  | string  | Echo of the type you signed for. Send the **exact** same value as the `Content-Type` header on the PUT.  |
| `maxSizeBytes` | integer | Echo of the cap you signed for. The PUT is rejected if you send more bytes.                              |
| `expiresAt`    | string  | ISO 8601 UTC. Sign-time + 15 minutes. After this, the URL fails the signature check.                     |

### Doing the PUT

```bash theme={null}
curl -i -X PUT "$UPLOAD_URL" \
  -H "Content-Type: audio/mpeg" \
  --data-binary @./episode-12.mp3
```

Notes:

* The `Content-Type` header on the PUT **must match** `contentType`
  from the response — S3 enforces it as part of the signature.
* The body must be the raw file bytes. No multipart/form-data wrapping.
* A successful PUT returns `200 OK` with an empty body.
* A 403 with `SignatureDoesNotMatch` usually means the `Content-Type`
  header diverged or the URL was URL-decoded somewhere in your client.

### Errors

| Status | Code                                                                                 |
| ------ | ------------------------------------------------------------------------------------ |
| 400    | `invalid_request` (missing `contentType`)                                            |
| 401    | `unauthenticated`, `invalid_token`, `key_revoked`, `key_expired`                     |
| 403    | `scope_missing` (need `sources:write` or `knowledge:write`), `plan_not_eligible`     |
| 422    | `unprocessable` (unsupported `contentType`, `maxSizeBytes` over cap or non-positive) |
| 429    | `rate_limited`                                                                       |
| 503    | `service_unavailable` (S3 misconfiguration on our side; see status page)             |

***

## What's NOT here (yet)

* **Listing or deleting upload handles.** Handles expire after 15
  minutes if not redeemed, and a successful ingest job deletes the
  underlying S3 object. There's no surface to list pending uploads —
  treat them as fire-and-forget.
* **Resumable / multi-part uploads.** Use the underlying AWS multi-part
  signing if your client library supports it; the URL we return is
  still a single-PUT signed URL. Phase 6 may add multi-part minting.
* **GET on uploads.** We do not expose the file once it's uploaded; the
  download path is through the resource that consumed it (`Source`
  surfaces a transcript preview, `KnowledgeDocument` surfaces summary
  metadata).
