Layers
Partner APIAPI referenceContent

POST /v1/content/:containerId/finalize-upload

Step 3 of the direct-upload transport — verify the PUTs landed, probe the media, complete the content item. Idempotent.

View as Markdown
POST/v1/content/:containerId/finalize-upload
stableidempotent
Auth
Bearer
Scope
content:write

Verifies every file declared in the 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.

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.

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.

Path parameters

  • containerId
    string (cnt_uuid)required
    The containerId returned by the upload session.

Body

Body
  • caption
    stringrequired
    Published exactly as written, max 2,200 characters. May be empty — but must be present.

Request

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-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();
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()

Responses

200The completed content item — creativeType uploaded, adsEnrollment opted_out, platformFit computed from the probed media.
{
  "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/:containerIdfinalize-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.

409Finalize arrived before the PUT landed. Complete the PUT, then finalize again.
{
  "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_..."
  }
}

Errors

StatusCodeWhen
409UPLOAD_INCOMPLETEA declared file isn't in storage yet — finalize raced ahead of (or was called instead of) the PUT.
413PAYLOAD_TOO_LARGEThe actual object is over its cap (100MB video / 30MB image) or larger than the sizeBytes you declared at session time.
422VALIDATIONThe uploaded file can't be probed — corrupt, or not actually the declared type.
409CONFLICTThe session is no longer finalizable — typically reaped by the abandonment sweep (~2 hours). Start a new session.
404NOT_FOUNDContainer not in your org, or not an upload — finalize-upload never touches generated content.

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.
  • 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

On this page