# POST /v1/content/:containerId/finalize-upload (/docs/api/reference/content/finalize-upload)



<Endpoint method="POST" path="/v1/content/:containerId/finalize-upload" scope="content:write">
  Verifies every file declared in the
  [upload session](/docs/api/reference/content/create-upload-session)
  landed in storage, re-checks sizes, probes the media, and completes the
  content item. `200` with the standard item. Idempotent — re-finalizing a
  completed upload returns it unchanged.
</Endpoint>

Call this once per `containerId` after every PUT for that item succeeded. A `per-file` session with three files produced three container ids — finalize each one.

<Callout type="warn">
  The retry body is **ignored**: the first successful finalize's caption
  wins, and a retry with different text is a silent no-op (you still get
  `200`). To correct a caption after finalize, use
  [`PATCH /v1/content/:containerId`](/docs/api/reference/content/patch-container).
</Callout>

## Path parameters [#path-parameters]

<Parameters
  rows="[
  { name: 'containerId', type: 'string (cnt_uuid)', required: true, description: 'The containerId returned by the upload session.' },
]"
/>

## Body [#body]

<Parameters
  title="Body"
  rows="[
  { 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/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." }'
    ```
  </Tab>

  <Tab value="TypeScript">
    ```ts title="finalize-upload.ts"
    const res = await fetch(
      `https://api.layers.com/v1/content/${containerId}/finalize-upload`,
      {
        method: 'POST',
        headers: {
          Authorization: `Bearer ${process.env.LAYERS_API_KEY}`,
          'Content-Type': 'application/json',
          'Idempotency-Key': crypto.randomUUID(),
        },
        body: JSON.stringify({
          caption: 'The roast that started it all.',
        }),
      },
    );
    const item = await res.json();
    ```
  </Tab>

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

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

## Responses [#responses]

<Response status="200" description="The completed content item — creativeType uploaded, adsEnrollment opted_out, platformFit computed from the probed media.">
  ```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:05:40Z",
    "failedAt": null,
    "lastError": null
  }
  ```

  This is the same content-item shape as
  [`GET /v1/content/:containerId`](/docs/api/reference/content/get-container) —
  `finalize-upload` returns the full record, not a subset. The media you just
  uploaded is on `assets[]` and `preview`; `assetId` is derived from the stored
  object key (`upload-1`), not a `med_`/`ast_` prefix.
</Response>

<Response status="409" description="Finalize arrived before the PUT landed. Complete the PUT, then finalize again.">
  ```json
  {
    "error": {
      "code": "UPLOAD_INCOMPLETE",
      "message": "Upload not found in storage — did the PUT complete? key=public-media/content-container/a3f8c2d1-.../media/upload-1.mp4",
      "requestId": "req_..."
    }
  }
  ```
</Response>

## Errors [#errors]

| Status | Code                | When                                                                                                                      |
| ------ | ------------------- | ------------------------------------------------------------------------------------------------------------------------- |
| 409    | `UPLOAD_INCOMPLETE` | A declared file isn't in storage yet — finalize raced ahead of (or was called instead of) the PUT.                        |
| 413    | `PAYLOAD_TOO_LARGE` | The actual object is over its cap (100MB video / 30MB image) or larger than the `sizeBytes` you declared at session time. |
| 422    | `VALIDATION`        | The uploaded file can't be probed — corrupt, or not actually the declared type.                                           |
| 409    | `CONFLICT`          | The session is no longer finalizable — typically reaped by the abandonment sweep (\~2 hours). Start a new session.        |
| 404    | `NOT_FOUND`         | Container not in your org, or not an upload — finalize-upload never touches generated content.                            |

## Notes [#notes]

* **Idempotent by state, not just by key.** A second finalize on a completed upload returns `200` with the existing item even without an `Idempotency-Key` — safe to blind-retry on timeouts.
* **Sandbox** (test keys): finalize short-circuits to fixture media with your caption verbatim — no storage reads, byte validation skipped. See [Sandbox](/docs/api/concepts/sandbox).
* Sizes are re-checked here against the real object. A presigned PUT can't enforce size, so this is where an oversized PUT fails.

## See also [#see-also]

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