uploads — Presigned Upload API for Trusted Backends¶
Direct-to-B2 presigned uploads for trusted internal Scenarix backends. One endpoint. The backend never sees the bytes — it only signs a URL the caller PUTs to.
Endpoint¶
POST /api/apiViaKey/uploads/presign-upload
API-key auth only — there is no JaduAuth mount. Studio's own frontend uses
POST /api/assetGen/presign-upload instead, which has a different key layout and is not this contract.
| Header | Required | Notes |
|---|---|---|
x-api-key |
yes | Must appear in the comma-separated API_REQUEST_KEYS env var |
x-user-id |
yes | Recorded as the Sentry user. Caller-asserted — trusted callers only |
Content-Type |
yes | application/json |
Request¶
Every field is required.
curl -X POST https://<host>/api/apiViaKey/uploads/presign-upload \
-H "x-api-key: $API_KEY" \
-H "x-user-id: 8f2c1b0e-1234-4a56-9876-abcdef012345" \
-H "Content-Type: application/json" \
-d '{
"assetType": "image",
"contentType": "image/png",
"fileSize": 1024,
"folder": "storydesk/story-123"
}'
| Field | Notes |
|---|---|
assetType |
image, audio, or script. Drives the MIME allowlist and the size cap. video is not exposed |
contentType |
Must belong to the declared assetType (see below) |
fileSize |
Exact byte count of the body you are about to PUT, not a maximum. See "Why fileSize is exact" |
folder |
Your destination folder inside studio's root. See "Where the file lands" |
Supported contentType per assetType:
| assetType | contentType | Cap |
|---|---|---|
image |
image/jpeg, image/jpg, image/png, image/gif, image/webp, image/svg+xml |
10 MB |
audio |
see SharedConstants.AUDIO_MIME_TO_EXTENSION (audio/mpeg, audio/webm, …) |
50 MB |
script |
application/pdf, application/msword, …wordprocessingml.document, text/plain |
10 MB |
Response¶
{
"isSuccess": true,
"message": "Presigned upload URL generated successfully",
"data": {
"uploadUrl": "https://…",
"publicUrl": "https://<bucket>.s3.<region>.backblazeb2.com/assetUpload/storydesk/story-123/upload-<uuid>.png",
"key": "assetUpload/storydesk/story-123/upload-<uuid>.png",
"contentType": "image/png",
"contentLength": 1024,
"maxUploadSize": 10485760,
"expiresIn": 900
}
}
maxUploadSize is the per-object cap for that assetType, echoed so you can message your own users.
expiresIn is seconds until uploadUrl stops working (15 min).
Step 2 — the PUT¶
curl -X PUT "$UPLOAD_URL" \
-H "Content-Type: image/png" \
-H "Content-Length: 1024" \
--data-binary @file.png
Both headers are signed and must match the response exactly. Any mismatch — a different content
type, or a body of a different length — makes B2 reject the PUT with SignatureDoesNotMatch. An
expired URL returns 403.
Studio is not involved in this step; failures here are between you and B2 and surface as B2's own S3 error codes.
After a successful PUT the bytes are readable at publicUrl. Keep it — studio stores no record of
it, so there is no way to look the URL up later.
Where the file lands¶
assetUpload/<your folder>/upload-<uuid>.<ext>
assetUpload/ is studio-owned and prepended server-side. Your folder nests inside it and cannot
escape it.
The filename is always generated by studio, so you cannot choose it and cannot overwrite an existing
object — every call produces a new file. The extension is derived from contentType.
folder rules — violations are rejected with a 400, never silently cleaned up:
- 1–200 characters
/-separated, at most 5 segments- each segment matches
[A-Za-z0-9._-]+ - no leading or trailing
/, no empty segments, no.or..segment
contentType is matched against the allowlist above by own-property lookup, so JavaScript
Object.prototype key names (constructor, toString, __proto__, …) are rejected like any other
unsupported type.
Why fileSize is exact¶
A presigned PUT can only sign an exact Content-Length. The only S3 mechanism that expresses a
maximum is a presigned POST policy (content-length-range), and Backblaze's S3-compatible API
lists POST-based presigned uploads among its unsupported features.
So fileSize is what makes the cap real: it is bound into the signature, and B2 itself rejects an
oversized body. Sending it also means you get a clean 400 before transferring anything instead of a
SignatureDoesNotMatch after uploading the whole file. For a server-to-server caller holding the
buffer it is just buffer.length.
Errors¶
All errors use the standard envelope: { "isSuccess": false, "message": "…", "data": {} }.
| Status | Cause |
|---|---|
| 400 | Validation failure — unknown assetType, contentType not valid for that assetType, fileSize non-positive/non-integer/over the cap, or folder missing or violating a naming rule |
| 401 | Missing or invalid x-api-key, or missing x-user-id |
| 500 | Unexpected server error, including a B2 signing failure |
There is no 502: signing is local, so there is no upstream generation call to fail.
Design notes¶
publicUrlis world-readable and permanent. It is a plain public B2 object URL with no expiry and no auth. The only protection is that the uuid in the path is unguessable. Do not upload anything that must stay private.- Every issued URL is logged.
key,folder,assetType,fileSizeand the asserted user id are written to the request log, so an object in the bucket can be traced back to a call. There is no database record and nologId; correlate byrequestId, method and path. - Nothing prunes
assetUpload/. There is no lifecycle rule, so uploads accumulate indefinitely. folderis caller-asserted, exactly likex-user-id.API_REQUEST_KEYSis a flat env allowlist with no per-key identity, so any valid key can write into any folder andfoldermust never be treated as proof of origin. Acceptable for trusted internal infrastructure; must be revisited before any external partner is given a key. Deriving the folder root from the API key would fix it.- No rate limiting.
- Uploads always target the production bucket.
testModeMiddlewareexists but is not mounted, so thex-test-modeheader has no effect on any route. - Shares
B2Service.generatePresignedPutUrlwithassetGenvia itskeyPrefixoption. The studio route passes no prefix and keeps its historicalimages//audios//scripts/layout and itsmaxBytesresponse field; this route sets a prefix and renames that field tomaxUploadSize. The two contracts are deliberately independent.
Full design: 2026-08-18-presign-upload-api-via-key-design.md, which lives outside this
repository — in the parent studio/ workspace at studio/docs/superpowers/specs/, one level above
this git root (studio-backend/). A clone of studio-backend alone will not contain it.