POST /v1/projects/:projectId/content/uploads
Start a presigned direct-upload session — the large-file transport. Bytes go client → storage, never through the API.
POST
/v1/projects/:projectId/content/uploadsstableidempotent
- Auth
- Bearer
- Scope
- content:write
Step 1 of the direct-upload transport. Declares the files, reserves the
content item(s) and their quota, and returns a presigned PUT URL per
file. Step 2 is your PUT; step 3 is
finalize-upload.
Use this transport when files are large or private, or when you don't want to host them. The bytes never transit the Layers API — which is exactly why this path is cap-proof: a future higher size limit changes configuration, not your integration. Full walkthrough: Upload finished content.
Path parameters
projectIdstring (uuid)requiredThe project to upload into.
Body
Body
filesarray (1–10)requiredOne entry per file you will PUT.files[].filenamestringrequiredUsed for the storage key extension. Max 512 chars.files[].contentTypestringrequiredOne of video/mp4, video/quicktime, image/jpeg, image/png, image/webp. Signed into the PUT URL — your PUT must send exactly this Content-Type.One of:video/mp4,video/quicktime,image/jpeg,image/png,image/webpfiles[].sizeBytesintegerrequiredDeclared size. Checked against the cap here (100MB video / 30MB image, bold-letter limit) and re-checked against actual bytes at finalize.groupingstringrequired`per-file` → one content item per file (videos single-file by construction). `slideshow` → one image-slideshow item; any video in a slideshow grouping is rejected.One of:per-file,slideshow
Request
curl -X POST https://api.layers.com/v1/projects/{projectId}/content/uploads \
-H "Authorization: Bearer $LAYERS_API_KEY" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: $(uuidgen)" \
-d '{
"files": [
{ "filename": "teaser.mp4", "contentType": "video/mp4", "sizeBytes": 48211234 }
],
"grouping": "per-file"
}'const res = await fetch(
`https://api.layers.com/v1/projects/${projectId}/content/uploads`,
{
method: 'POST',
headers: {
Authorization: `Bearer ${process.env.LAYERS_API_KEY}`,
'Content-Type': 'application/json',
'Idempotency-Key': crypto.randomUUID(),
},
body: JSON.stringify({
files: [
{ filename: 'teaser.mp4', contentType: 'video/mp4', sizeBytes: 48211234 },
],
grouping: 'per-file',
}),
},
);
const { uploads } = await res.json();
const [{ containerId, files }] = uploads;import os, uuid, httpx
r = httpx.post(
f"https://api.layers.com/v1/projects/{project_id}/content/uploads",
headers={
"Authorization": f"Bearer {os.environ['LAYERS_API_KEY']}",
"Idempotency-Key": str(uuid.uuid4()),
},
json={
"files": [
{"filename": "teaser.mp4", "contentType": "video/mp4", "sizeBytes": 48211234}
],
"grouping": "per-file",
},
)
uploads = r.json()["uploads"]Responses
201One entry per resulting content item; one presigned PUT per file.
{
"uploads": [
{
"containerId": "cnt_a3f8c2d1-7b4e-4f0a-9c6d-2e1b5a8f3c70",
"files": [
{
"uploadUrl": "https://<account>.r2.cloudflarestorage.com/layers-public/public-media/content-container/a3f8c2d1-.../media/upload-1.mp4?X-Amz-Signature=...",
"r2Key": "public-media/content-container/a3f8c2d1-.../media/upload-1.mp4",
"contentType": "video/mp4",
"expiresAt": "2026-06-12T15:30:00.000Z"
}
]
}
]
}409Per-project upload quota hit — sessions reserve quota at creation, so the check happens here, not at finalize.
{
"error": {
"code": "UPLOAD_QUOTA_EXCEEDED",
"message": "Upload quota reached for this project. Delete uploaded content to free quota, or contact support to raise the limits.",
"requestId": "req_...",
"details": {
"currentUploads": 250,
"maxUploads": 250,
"currentBytes": 18643214336,
"maxBytes": 26843545600
}
}
}Then: PUT each file
curl -X PUT "$UPLOAD_URL" \
-H "Content-Type: video/mp4" \
--data-binary @teaser.mp4- Send exactly the
contentTypeyou declared — it's part of the URL signature; a mismatch fails at the storage layer. - Finish before
expiresAt(15-minute lifetime). An expired URL fails with a storage-side signature error; there is no re-presign — start a new session. Abandoned sessions are reaped automatically within ~2 hours and free their quota. - A presigned PUT can't enforce size. Finalize re-checks the actual object against both the cap and your declared
sizeBytes, so don't bother lying aboutsizeBytes— it just moves the failure to step 3.
Errors
| Status | Code | When |
|---|---|---|
| 422 | VALIDATION | Content-Type not in the allowlist, more than 10 files, declared size over the cap, video in a slideshow grouping ("Slideshows are images only."). |
| 409 | UPLOAD_QUOTA_EXCEEDED | Quota hit at session creation (see above). |
| 401 / 403 / 404 | UNAUTHENTICATED / FORBIDDEN_SCOPE / NOT_FOUND | Standard auth, content:write scope, and org-scoping rules. |
Notes
- Sandbox (test keys): the session has the normal shape but the
uploadUrls are inert — skip or ignore the PUT; finalize short-circuits to fixture media. Byte validation is skipped entirely in sandbox. - Idempotency-Key replays return the cached session — same URLs, no new reservation.