Layers
Partner APIAPI referenceContent

POST /v1/projects/:projectId/content/upload

Upload finished content from URLs — one synchronous call, atomic across files.

View as Markdown
POST/v1/projects/:projectId/content/upload
stableidempotent
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

  • projectId
    string (uuid)required
    The project to upload into.

Body

Body
  • media
    array (1–10)required
    One entry per file. One video, one image, or 2–10 images (published as a slideshow). A video is always a single-file post.
  • media[].url
    string (https URL)required
    Fetchable URL — a short-lived signed GET URL from private storage works. Sign for at least 15 minutes. Must resolve to a public IP.
  • 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/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."
  }'
upload-content.ts
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.
upload_content.py
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

201The completed content item.
{
  "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.

409Per-project upload quota hit. Details always carry usage AND limits.
{
  "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

StatusCodeWhen
422VALIDATIONURL 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.
413PAYLOAD_TOO_LARGEA 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).
502SCRAPE_FAILEDUpstream fetch failed or timed out (60s budget per URL, redirects included). Expired signed URLs land here.
409UPLOAD_QUOTA_EXCEEDEDProject upload quota hit (see above).
401 / 403 / 404UNAUTHENTICATED / FORBIDDEN_SCOPE / NOT_FOUNDStandard auth, content:write scope, and org-scoping rules.

Notes

  • Sandbox (test keys): nothing is fetched. You get a 201 completed 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 platformFit on the response before scheduling — an unfit platform target returns 422 at schedule/publish time with the same issues.

See also

On this page