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.
/v1/content/:containerId/finalize-upload- 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
containerIdstring (cnt_uuid)requiredThe containerId returned by the upload session.
Body
captionstringrequiredPublished exactly as written, max 2,200 characters. May be empty — but must be present.
Request
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." }'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();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
{
"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 —
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.
{
"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
| 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
- Idempotent by state, not just by key. A second finalize on a completed upload returns
200with the existing item even without anIdempotency-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.