Skip to main content

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: POST /v1/media (an image for a post), POST /v1/sources with type: "file" and POST /v1/knowledge with type: "file". The handle is not a media key. PATCH /v1/posts/{postId}/media rejects it — turn the uploaded image into a library asset with POST /v1/media first, and attach the img_… id that returns. 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/media / 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.
  • The content type is part of the signature, so S3 rejects a PUT that sends a different one.
  • 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.
MCP hosts don’t need this flow for small files (≤ ~3 MB decoded) — the create_source_file and add_to_knowledge_base tools accept file bytes inline as base64. Anything larger MUST come through this flow: bigger inline payloads are rejected at the platform edge as a bare HTTP 413.

POST /v1/uploads

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

Request body

Allowed content types and size caps

The families above are the whole allow-list. Notably text/markdown and text/html are not on it — sign them as text/plain, or create the source through POST /v1/sources instead. The cap is not signed into the presigned URL — AWS has no “at most N bytes” condition for a PutObject signature. An oversized PUT is therefore not guaranteed to fail fast: a bucket policy may deny it at the S3 edge (a 403), and if it gets through, the object is rejected later when the ingest worker checks its size, surfacing as a failed job rather than a failed upload. Stay under the cap yourself — compress, transcode, or chunk 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

Semantics

The ticket’s fields are in the OpenAPI reference tab (Upload). The sharp edges it doesn’t carry:
  • id is the handle you pass as uploadId — to POST /v1/media, POST /v1/sources, or POST /v1/knowledge.
  • Do not send an Authorization header on the PUT — the signature is already in uploadUrl, and an extra header breaks it.
  • contentType and maxSizeBytes echo what you signed for. maxSizeBytes is not enforced by the signature; see the caps section above for where it actually is enforced.
  • id is upl_<workspaceId>--<uploadId>. Both halves are bare ids with no prefix of their own: the workspace half is the internal 16-hex id for an API-key caller or an org_… id for an OAuth caller, and the upload half is a 16-hex id. Treat the whole string as opaque rather than splitting it.
  • Past expiresAt the URL fails the signature check rather than returning a friendly error.

Doing the PUT

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


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.
  • 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).