Layers
Partner APIAPI referenceContent

POST /v1/projects/:projectId/content/uploads

Start a presigned direct-upload session — the large-file transport. Bytes go client → storage, never through the API.

View as Markdown
POST/v1/projects/:projectId/content/uploads
stableidempotent
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

  • projectId
    string (uuid)required
    The project to upload into.

Body

Body
  • files
    array (1–10)required
    One entry per file you will PUT.
  • files[].filename
    stringrequired
    Used for the storage key extension. Max 512 chars.
  • files[].contentType
    stringrequired
    One 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/webp
  • files[].sizeBytes
    integerrequired
    Declared size. Checked against the cap here (100MB video / 30MB image, bold-letter limit) and re-checked against actual bytes at finalize.
  • grouping
    stringrequired
    `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

terminal
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"
  }'
create-upload-session.ts
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;
create_upload_session.py
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

terminal
curl -X PUT "$UPLOAD_URL" \
  -H "Content-Type: video/mp4" \
  --data-binary @teaser.mp4
  • Send exactly the contentType you 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 about sizeBytes — it just moves the failure to step 3.

Errors

StatusCodeWhen
422VALIDATIONContent-Type not in the allowlist, more than 10 files, declared size over the cap, video in a slideshow grouping ("Slideshows are images only.").
409UPLOAD_QUOTA_EXCEEDEDQuota hit at session creation (see above).
401 / 403 / 404UNAUTHENTICATED / FORBIDDEN_SCOPE / NOT_FOUNDStandard 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.

See also

On this page