# Upload finished content (/docs/api/guides/upload-finished-content)



You already have the finished post — a video or a set of images, plus the caption — and you want Layers to schedule and publish it. No generation involved. Two transports get the bytes into a project; both produce an ordinary content item (`creativeType: "uploaded"`) that schedules and publishes through [the same endpoints](/docs/api/reference/publishing/schedule-content) as generated content.

## Pick a transport [#pick-a-transport]

| Transport         | Calls                                                                                                                                                                                                                  | Use when                                                                                                                                 |
| ----------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------- |
| **URL-fetch**     | [`POST /v1/projects/:projectId/content/upload`](/docs/api/reference/content/upload-content)                                                                                                                            | Your media is already hosted somewhere fetchable. One call, synchronous `201`.                                                           |
| **Direct upload** | [`POST /v1/projects/:projectId/content/uploads`](/docs/api/reference/content/create-upload-session) → `PUT` each file → [`POST /v1/content/:containerId/finalize-upload`](/docs/api/reference/content/finalize-upload) | Files are large or private, or you don't want to host them at all. Bytes go from your client straight to storage, never through the API. |

If your media already sits on a CDN or in a bucket, use URL-fetch — one call and you're done. Use direct upload when hosting is the problem: it needs no public URL, and because the bytes bypass the API entirely, it's the transport that scales when the size cap rises.

## What Layers does with your upload [#what-layers-does-with-your-upload]

Layers publishes your media byte-for-byte as uploaded and your caption exactly as written. We validate and reject — we never crop, letterbox, transcode, or rewrite content per destination, and no AI modifies uploaded content. What you preview is what gets published.

## Size limits and formats [#size-limits-and-formats]

**Video files are limited to 100MB** (images 30MB). This limit is operational, not architectural: the direct-upload flow sends bytes straight to storage, so a future higher limit requires no integration changes on your side. If you hit the cap, export a platform-ready MP4 — standard social presets produce files well under 100MB.

| Limit                 | Value                                                                   |
| --------------------- | ----------------------------------------------------------------------- |
| Accepted types        | `video/mp4`, `video/quicktime`, `image/jpeg`, `image/png`, `image/webp` |
| Video file size       | 100MB                                                                   |
| Image file size       | 30MB                                                                    |
| Files per upload call | 1–10                                                                    |
| Video posts           | Single file — a video is never grouped with other files                 |
| Caption               | Required, may be empty, max 2,200 characters                            |
| Per-project quota     | 250 uploads / 25GB (see [Quota](#quota))                                |

## Grouping: one post per file, or one slideshow [#grouping-one-post-per-file-or-one-slideshow]

The direct-upload session takes a `grouping`:

* `per-file` — every file becomes its own content item. Images and videos can mix; each video is its own single-file post by construction.
* `slideshow` — all files become **one** image-slideshow item. Images only: any video in a `slideshow` grouping is rejected with `422 VALIDATION` ("Slideshows are images only."). A `slideshow` of exactly one image is accepted and comes back as an `image` item.

On URL-fetch there is no `grouping` field — the `media` array is always one content item: one video, one image, or 2–10 images as a slideshow.

## Transport A: URL-fetch [#transport-a-url-fetch]

One call. Layers fetches each URL server-side, validates Content-Type and size, probes the media, and returns the completed content item synchronously.

```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. ☕"
  }'
```

`201` returns the standard content item — `id` (`cnt_…`), `status: "completed"`, `creativeType: "uploaded"`, `adsEnrollment: "opted_out"`, [`platformFit`](#platform-fit), assets with probe-measured dimensions. It's ready to schedule immediately.

**The call is atomic.** Every URL is fetched and validated before anything is written — if any one file fails (unreachable URL, wrong Content-Type, over the cap), no content item is created. There is no partial success to clean up.

The fetch budget is 60 seconds per URL, redirects included. The `Content-Type` your host returns is the source of truth — we don't sniff file magic, so a server that returns `application/octet-stream` for an MP4 gets a `422`.

### Private buckets: the signed-GET recipe [#private-buckets-the-signed-get-recipe]

The URL doesn't have to be permanently public — a short-lived signed GET URL from your own storage works and is the right pattern for private media:

<Tabs items="['AWS S3', 'Google Cloud Storage']">
  <Tab value="AWS S3">
    ```sh title="terminal"
    SIGNED_URL=$(aws s3 presign s3://my-bucket/posts/launch-teaser.mp4 --expires-in 900)

    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\": \"$SIGNED_URL\"}], \"caption\": \"The roast that started it all.\"}"
    ```
  </Tab>

  <Tab value="Google Cloud Storage">
    ```sh title="terminal"
    SIGNED_URL=$(gcloud storage sign-url gs://my-bucket/posts/launch-teaser.mp4 \
      --duration=15m --private-key-file=key.json | grep signed_url | cut -d' ' -f2)

    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\": \"$SIGNED_URL\"}], \"caption\": \"The roast that started it all.\"}"
    ```
  </Tab>
</Tabs>

Sign for **at least 15 minutes**, not 60 seconds. The signature must outlive the whole call — a 100MB video on a slow origin can take most of the 60-second fetch budget, and a retry reuses the same URL. An expired signature surfaces as `502 SCRAPE_FAILED`, which reads like a flaky origin and wastes your debugging time.

## Transport B: presigned direct upload [#transport-b-presigned-direct-upload]

Three steps. This is the large-file path — your bytes never transit the Layers API, so the per-file cap is the only ceiling and it's configuration, not architecture.

### Step 1 — create the session [#step-1--create-the-session]

```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"
  }'
```

`201` returns one entry per resulting content item:

```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"
        }
      ]
    }
  ]
}
```

The session reserves the content item up front — quota is checked here, and the `containerId` is yours from this moment.

### Step 2 — PUT each file [#step-2--put-each-file]

```sh title="terminal"
curl -X PUT "$UPLOAD_URL" \
  -H "Content-Type: video/mp4" \
  --data-binary @teaser.mp4
```

The `Content-Type` header must be exactly the `contentType` you declared in step 1 — it's signed into the URL, and a mismatch fails the signature check at the storage layer. Finish every PUT before `expiresAt`.

### Step 3 — finalize [#step-3--finalize]

```sh title="terminal"
curl -X POST https://api.layers.com/v1/content/cnt_a3f8c2d1-7b4e-4f0a-9c6d-2e1b5a8f3c70/finalize-upload \
  -H "Authorization: Bearer $LAYERS_API_KEY" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: $(uuidgen)" \
  -d '{ "caption": "The roast that started it all." }'
```

Finalize verifies every declared file landed, re-checks actual size against the cap and your declared `sizeBytes`, probes the media, and completes the item — `200` with the standard content item. Sessions that produced multiple items (`per-file` with several files) finalize each `containerId` separately.

### Retries and expiry [#retries-and-expiry]

* **Finalize before the PUT landed → `409 UPLOAD_INCOMPLETE`.** The message names the missing key ("did the PUT complete?"). Complete the PUT, then call finalize again.
* **`uploadUrl` expired** (default lifetime 15 minutes): the PUT fails at the storage layer with a signature error. There is no re-presign for an existing session — start a new one. Sessions are cheap, and abandoned ones are cleaned up automatically within about two hours, freeing their quota.
* **Finalize is idempotent.** Re-finalizing a completed upload returns `200` with the same item. Safe to retry on timeouts with the same `Idempotency-Key` or without one.
* **Finalize on a session that was already reaped** (you waited past the abandonment window) → `409 CONFLICT`. Start a new session.

<Callout type="warn">
  The finalize-retry body is **ignored**. The first successful finalize wins; a retry with a "corrected" caption is a silent no-op. To fix a caption after finalize, use [`PATCH /v1/content/:containerId`](/docs/api/reference/content/patch-container) — that's what it's for.
</Callout>

<Callout type="warn">
  An `uploadUrl` stays valid until its `expiresAt`, even after finalize. Don't re-PUT to a finalized upload's URL — finalize already probed and checksummed the original bytes, and a later overwrite makes the published file diverge from what the API reported. If you need different media, upload a new item.
</Callout>

## Platform fit [#platform-fit]

`platformFit` is advisory and per-platform. An upload is never rejected because it doesn't fit a particular platform — rejection at upload time only happens for invalid files (unsupported format, over the size limit, unreadable media). Scheduling or publishing to a platform the content doesn't fit returns 422 with the same issues listed in platformFit, so you can detect misfit at upload time rather than at publish time.

Every uploaded content item carries the computed field on the upload/finalize response and on every GET:

```json
"platformFit": [
  {
    "platform": "tiktok",
    "ok": false,
    "issues": ["800x800 image resolution too low (min 1080px on the short edge)"]
  },
  {
    "platform": "instagram",
    "ok": false,
    "issues": ["800x800 image resolution too low (min 1080px on the short edge)"]
  }
]
```

The launch platform set is `tiktok` and `instagram`. The set is additive — new platforms appear as new array entries, never as changes to existing ones.

Content is platform-agnostic, so the rules are **asset-intrinsic** — one set per media kind, shared across the launch set (the same `issues` appear on every platform an asset doesn't fit). We admit only what comes out clean on both platforms after our publish-time transform:

| Media  | Rule                                                                                                                                                                                                     |
| ------ | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| Images | Short edge ≥ 1080px. Aspect is **not** gated — we crop per platform at publish, so any aspect is admitted.                                                                                               |
| Videos | MP4 or MOV · H.264 or HEVC · AAC audio (or silent) · 3s–10min · ≥ 360px on both edges · ≤ 1920px wide · 23–60fps · ≤ 25 Mbps · ≤ 100MB. Landscape video is allowed (aspect is advisory, never a reject). |

Images are re-encoded and cropped per platform at publish, so the only intrinsic gate is a resolution floor (a sub-1080 source upscales to mush). Videos are published as-is — no transform — so the gate enforces the full container/codec/audio/duration/dimension intersection of both platforms.

Scheduling or publishing an unfit upload to that platform returns `422 VALIDATION` carrying the same issues:

```json
{
  "error": {
    "code": "VALIDATION",
    "message": "This uploaded content does not fit instagram. See platformFit on the content item.",
    "requestId": "req_...",
    "details": {
      "platform": "instagram",
      "socialAccountId": "sa_9b2e4f6a-...",
      "issues": [
        { "path": "targets", "message": "800x800 image resolution too low (min 1080px on the short edge)" }
      ]
    }
  }
}
```

The gate is per-target: the same item publishes to TikTok without complaint. Check `platformFit` right after upload and route content accordingly — don't wait for the 422.

## Fix a caption after the fact [#fix-a-caption-after-the-fact]

[`PATCH /v1/content/:containerId`](/docs/api/reference/content/patch-container) updates `caption` on uploaded content. It's restricted to `creativeType: "uploaded"` — generated content echoes its hook from generation inputs and is corrected by regenerating, so the PATCH returns `422` there.

## Reading uploads back [#reading-uploads-back]

Every content item and list item now carries two fields:

* `creativeType` — `"generated"` or `"uploaded"`.
* `adsEnrollment` — `"auto"` (generated), `"opted_in"`, or `"opted_out"`. Uploads are born `"opted_out"`.

Uploaded items additionally carry `platformFit` on the full GET, and their `preview.aspectRatio` is measured from the actual file — a landscape upload reports `16:9`, not an assumed vertical.

Filter the list by origin with `creativeType`:

```sh title="terminal"
curl "https://api.layers.com/v1/projects/{projectId}/content?creativeType=uploaded&status=completed" \
  -H "Authorization: Bearer $LAYERS_API_KEY"
```

## Quota [#quota]

Each project holds up to 250 uploads / 25GB. Exceeding either returns `409 UPLOAD_QUOTA_EXCEEDED`, and the details always name your current usage **and** the limit — you never have to guess what to free:

```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
    }
  }
}
```

The count includes in-flight direct-upload sessions (a session reserves its slot the moment it's created) and archived items. Deleting an upload frees its quota immediately; abandoned sessions free theirs automatically when the cleanup sweep reaps them (\~2 hours).

<Callout type="info">
  **Freeing quota today happens in the Layers app**: open your project's **Assets → Uploaded Content** tab and archive the items you no longer need (or contact support to raise your limits). A partner API delete endpoint for uploaded content (`DELETE /v1/content/:id`) is a committed follow-up — your integration won't need to change when it arrives.
</Callout>

## Errors [#errors]

| Status | Code                    | When                                                                                                                                                                              |
| ------ | ----------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| 422    | `VALIDATION`            | Unsupported Content-Type, grouping rule broken ("Slideshows are images only."), URL rejected by the SSRF guard, caption over 2,200 chars, unreadable/corrupt media at probe time. |
| 413    | `PAYLOAD_TOO_LARGE`     | A file is over its per-type cap (100MB video / 30MB image), at fetch time or at finalize.                                                                                         |
| 502    | `SCRAPE_FAILED`         | URL-fetch upstream returned an error or timed out — includes expired signed URLs. Retryable once you've fixed the URL.                                                            |
| 409    | `UPLOAD_INCOMPLETE`     | Finalize arrived before every declared PUT landed. Complete the PUTs, finalize again.                                                                                             |
| 409    | `UPLOAD_QUOTA_EXCEEDED` | Per-project quota hit. Details carry usage + limits.                                                                                                                              |
| 409    | `CONFLICT`              | The session is no longer finalizable (reaped after abandonment, or state changed mid-call). Start a new session.                                                                  |
| 404    | `NOT_FOUND`             | Container not in your org, or not an upload (`finalize-upload` is uploads-only).                                                                                                  |

All in the standard [error envelope](/docs/api/operational/errors).

## Uploads and ads [#uploads-and-ads]

Uploaded content is created with adsEnrollment: "opted\_out" and is not used in paid campaigns. Before ad budget can go behind customer-supplied content, it must pass an explicit opt-in and safety review — that enrollment flow ships in an upcoming release as an additive API (your integration won't change).

## Sandbox [#sandbox]

Both transports work with a test key (`lp_test_…`), with zero real fetches and zero storage writes — see [Sandbox](/docs/api/concepts/sandbox):

* **URL-fetch**: no fetch happens. You get a `201` completed item backed by fixture media, with your caption verbatim.
* **Direct upload**: the session response has the normal shape, but the `uploadUrl`s are inert — **skip or ignore the PUT in sandbox**; a PUT against them fails harmlessly and nothing reads it. Finalize short-circuits to fixture media and returns the completed item with your caption. Byte validation is skipped entirely in sandbox: size caps, Content-Type checks, and probing only run against live keys.

Sandbox uploads never consume live quota — run CI loops freely.

## See also [#see-also]

* [Upload from URLs (Transport A reference)](/docs/api/reference/content/upload-content)
* [Create an upload session (Transport B reference)](/docs/api/reference/content/create-upload-session)
* [Schedule publishing](/docs/api/reference/publishing/schedule-content)
* [Errors](/docs/api/operational/errors)
