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



<Endpoint method="POST" path="/v1/projects/:projectId/content/upload" scope="content:write">
  Layers fetches each `media[].url` server-side, validates Content-Type and
  size, probes the media, and returns a completed content item with
  `creativeType: "uploaded"`. Synchronous `201` — no job to poll.
</Endpoint>

This is the URL-fetch transport: the right call when your media is already hosted. For large or private files, use the [direct-upload transport](/docs/api/reference/content/create-upload-session) instead. Full walkthrough, limits, and the signed-GET recipe for private buckets: [Upload finished content](/docs/api/guides/upload-finished-content).

**Atomic across files.** Every URL is fetched and validated before anything is written. One bad file — unreachable, wrong Content-Type, over the cap — means no content item is created.

## 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: 'media', type: 'array (1–10)', required: true, description: 'One entry per file. One video, one image, or 2–10 images (published as a slideshow). A video is always a single-file post.' },
  { name: 'media[].url', type: 'string (https URL)', required: true, description: 'Fetchable URL — a short-lived signed GET URL from private storage works. Sign for at least 15 minutes. Must resolve to a public IP.' },
  { name: 'caption', type: 'string', required: true, description: 'Published exactly as written, max 2,200 characters. May be empty — but must be present.' },
]"
/>

## Request [#request]

<Tabs items="['curl', 'TypeScript', 'Python']">
  <Tab value="curl">
    ```sh title="terminal"
    curl -X POST https://api.layers.com/v1/projects/{projectId}/content/upload \
      -H "Authorization: Bearer $LAYERS_API_KEY" \
      -H "Content-Type: application/json" \
      -H "Idempotency-Key: $(uuidgen)" \
      -d '{
        "media": [{ "url": "https://cdn.acmecoffee.com/social/launch-teaser.mp4" }],
        "caption": "The roast that started it all."
      }'
    ```
  </Tab>

  <Tab value="TypeScript">
    ```ts title="upload-content.ts"
    const res = await fetch(
      `https://api.layers.com/v1/projects/${projectId}/content/upload`,
      {
        method: 'POST',
        headers: {
          Authorization: `Bearer ${process.env.LAYERS_API_KEY}`,
          'Content-Type': 'application/json',
          'Idempotency-Key': crypto.randomUUID(),
        },
        body: JSON.stringify({
          media: [{ url: 'https://cdn.acmecoffee.com/social/launch-teaser.mp4' }],
          caption: 'The roast that started it all.',
        }),
      },
    );
    const item = await res.json();
    // item.status === 'completed' — ready to schedule immediately.
    ```
  </Tab>

  <Tab value="Python">
    ```py title="upload_content.py"
    import os, uuid, httpx

    r = httpx.post(
        f"https://api.layers.com/v1/projects/{project_id}/content/upload",
        headers={
            "Authorization": f"Bearer {os.environ['LAYERS_API_KEY']}",
            "Idempotency-Key": str(uuid.uuid4()),
        },
        json={
            "media": [{"url": "https://cdn.acmecoffee.com/social/launch-teaser.mp4"}],
            "caption": "The roast that started it all.",
        },
        timeout=120,
    )
    item = r.json()
    ```
  </Tab>
</Tabs>

## Responses [#responses]

<Response status="201" description="The completed content item.">
  ```json
  {
    "id": "cnt_a3f8c2d1-7b4e-4f0a-9c6d-2e1b5a8f3c70",
    "projectId": "prj_254a4ce1-f4ca-42b1-9e36-17ca45ef3d39",
    "status": "completed",
    "format": null,
    "hook": null,
    "influencerId": null,
    "sourceTiktokId": null,
    "mediaId": null,
    "caption": "The roast that started it all.",
    "firstComment": null,
    "assets": [
      {
        "assetId": "upload-1",
        "kind": "video",
        "url": "https://media.layers.com/public-media/content-container/a3f8c2d1-.../media/upload-1.mp4",
        "thumbnailUrl": null,
        "width": 1080,
        "height": 1920,
        "durationMs": 31000,
        "mimeType": "video/mp4",
        "sizeBytes": 18874368
      }
    ],
    "preview": {
      "kind": "video",
      "primaryUrl": "https://media.layers.com/public-media/content-container/a3f8c2d1-.../media/upload-1.mp4",
      "thumbnailUrl": null,
      "imageUrls": [],
      "videoUrl": "https://media.layers.com/public-media/content-container/a3f8c2d1-.../media/upload-1.mp4",
      "hlsUrl": null,
      "durationMs": 31000,
      "aspectRatio": "9:16"
    },
    "approvalStatus": "not_required",
    "creativeType": "uploaded",
    "adsEnrollment": "opted_out",
    "platformFit": [
      { "platform": "tiktok", "ok": true, "issues": [] },
      { "platform": "instagram", "ok": true, "issues": [] }
    ],
    "createdAt": "2026-06-12T14:02:11Z",
    "completedAt": "2026-06-12T14:02:14Z",
    "failedAt": null,
    "lastError": null
  }
  ```

  This is the full content-item shape (same as
  [`GET /v1/content/:containerId`](/docs/api/reference/content/get-container)).
  The echo fields a generated item carries — `hook`, `influencerId`, `sourceTiktokId`,
  `mediaId`, `firstComment`, `format` — are present but `null` on uploads.
</Response>

<Response status="409" description="Per-project upload quota hit. Details always carry usage AND limits.">
  ```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>

## Errors [#errors]

| Status          | Code                                                | When                                                                                                                                                                                                                                            |
| --------------- | --------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| 422             | `VALIDATION`                                        | URL rejected by the SSRF guard, Content-Type not in the allowlist, video mixed into a multi-file upload, caption over 2,200 chars, or the file is corrupt/unreadable at probe time.                                                             |
| 413             | `PAYLOAD_TOO_LARGE`                                 | A fetched body exceeds its cap — **100MB video / 30MB image**. `details` always carries `url` and `maxBytes`; `contentType` is added once the body's type is known (it's absent when the stream is cut off mid-fetch for blowing the hard cap). |
| 502             | `SCRAPE_FAILED`                                     | Upstream fetch failed or timed out (60s budget per URL, redirects included). Expired signed URLs land here.                                                                                                                                     |
| 409             | `UPLOAD_QUOTA_EXCEEDED`                             | Project upload quota hit (see above).                                                                                                                                                                                                           |
| 401 / 403 / 404 | `UNAUTHENTICATED` / `FORBIDDEN_SCOPE` / `NOT_FOUND` | Standard auth, `content:write` scope, and org-scoping rules.                                                                                                                                                                                    |

## Notes [#notes]

* **Sandbox** (test keys): nothing is fetched. You get a `201` completed item with fixture media and your caption verbatim. See [Sandbox](/docs/api/concepts/sandbox).
* Uploaded content is created with `adsEnrollment: "opted_out"` and is not used in paid campaigns — see [Upload finished content](/docs/api/guides/upload-finished-content) for the ads posture.
* The published media is byte-for-byte what we fetched. Check `platformFit` on the response before scheduling — an unfit platform target returns `422` at schedule/publish time with the same issues.

## See also [#see-also]

* [Upload finished content (guide)](/docs/api/guides/upload-finished-content)
* [Create an upload session (large files)](/docs/api/reference/content/create-upload-session)
* [Fix a caption: PATCH content](/docs/api/reference/content/patch-container)
