# POST /v1/projects/:projectId/content/uploads (/docs/api/reference/content/create-upload-session)



<Endpoint method="POST" path="/v1/projects/:projectId/content/uploads" 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`](/docs/api/reference/content/finalize-upload).
</Endpoint>

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](/docs/api/guides/upload-finished-content).

## Path parameters [#path-parameters]

<Parameters
  rows="[
  { name: 'projectId', type: 'string (uuid)', required: true, description: 'The project to upload into.' },
]"
/>

## Body [#body]

<Parameters
  title="Body"
  rows="[
  { name: 'files', type: 'array (1–10)', required: true, description: 'One entry per file you will PUT.' },
  { name: 'files[].filename', type: 'string', required: true, description: 'Used for the storage key extension. Max 512 chars.' },
  { name: 'files[].contentType', type: 'string', required: true, description: '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.', enum: ['video/mp4', 'video/quicktime', 'image/jpeg', 'image/png', 'image/webp'] },
  { name: 'files[].sizeBytes', type: 'integer', required: true, description: 'Declared size. Checked against the cap here (100MB video / 30MB image, bold-letter limit) and re-checked against actual bytes at finalize.' },
  { name: 'grouping', type: 'string', required: true, description: '`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.', enum: ['per-file', 'slideshow'] },
]"
/>

## Request [#request]

<Tabs items="['curl', 'TypeScript', 'Python']">
  <Tab value="curl">
    ```sh title="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"
      }'
    ```
  </Tab>

  <Tab value="TypeScript">
    ```ts title="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;
    ```
  </Tab>

  <Tab value="Python">
    ```py title="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"]
    ```
  </Tab>
</Tabs>

## Responses [#responses]

<Response status="201" description="One entry per resulting content item; one presigned PUT per file.">
  ```json
  {
    "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"
          }
        ]
      }
    ]
  }
  ```
</Response>

<Response status="409" description="Per-project upload quota hit — sessions reserve quota at creation, so the check happens here, not at finalize.">
  ```json
  {
    "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
      }
    }
  }
  ```
</Response>

## Then: PUT each file [#then-put-each-file]

```sh title="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 [#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 [#notes]

* **Sandbox** (test keys): the session has the normal shape but the `uploadUrl`s are inert — skip or ignore the PUT; [finalize](/docs/api/reference/content/finalize-upload) 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 [#see-also]

* [Finalize the upload (step 3)](/docs/api/reference/content/finalize-upload)
* [Upload finished content (guide)](/docs/api/guides/upload-finished-content)
* [Upload from URLs (one-call alternative)](/docs/api/reference/content/upload-content)
