POST /v1/projects/:projectId/content/upload
Upload finished content from URLs — one synchronous call, atomic across files.
/v1/projects/:projectId/content/upload- Auth
- Bearer
- 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.
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 instead. Full walkthrough, limits, and the signed-GET recipe for private buckets: 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
projectIdstring (uuid)requiredThe project to upload into.
Body
mediaarray (1–10)requiredOne entry per file. One video, one image, or 2–10 images (published as a slideshow). A video is always a single-file post.media[].urlstring (https URL)requiredFetchable URL — a short-lived signed GET URL from private storage works. Sign for at least 15 minutes. Must resolve to a public IP.captionstringrequiredPublished exactly as written, max 2,200 characters. May be empty — but must be present.
Request
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."
}'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.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()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:02:14Z",
"failedAt": null,
"lastError": null
}This is the full content-item shape (same as
GET /v1/content/:containerId).
The echo fields a generated item carries — hook, influencerId, sourceTiktokId,
mediaId, firstComment, format — are present but null on uploads.
{
"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
}
}
}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
- Sandbox (test keys): nothing is fetched. You get a
201completed item with fixture media and your caption verbatim. See Sandbox. - Uploaded content is created with
adsEnrollment: "opted_out"and is not used in paid campaigns — see Upload finished content for the ads posture. - The published media is byte-for-byte what we fetched. Check
platformFiton the response before scheduling — an unfit platform target returns422at schedule/publish time with the same issues.