Upload finished content
Bring your own finished posts. Two transports, byte-for-byte publishing, advisory platform fit, and a quota that always names its limits.
You already have the finished post — a video or a set of images, plus the caption — and you want Layers to schedule and publish it. No generation involved. Two transports get the bytes into a project; both produce an ordinary content item (creativeType: "uploaded") that schedules and publishes through the same endpoints as generated content.
Pick a transport
| Transport | Calls | Use when |
|---|---|---|
| URL-fetch | POST /v1/projects/:projectId/content/upload | Your media is already hosted somewhere fetchable. One call, synchronous 201. |
| Direct upload | POST /v1/projects/:projectId/content/uploads → PUT each file → POST /v1/content/:containerId/finalize-upload | Files are large or private, or you don't want to host them at all. Bytes go from your client straight to storage, never through the API. |
If your media already sits on a CDN or in a bucket, use URL-fetch — one call and you're done. Use direct upload when hosting is the problem: it needs no public URL, and because the bytes bypass the API entirely, it's the transport that scales when the size cap rises.
What Layers does with your upload
Layers publishes your media byte-for-byte as uploaded and your caption exactly as written. We validate and reject — we never crop, letterbox, transcode, or rewrite content per destination, and no AI modifies uploaded content. What you preview is what gets published.
Size limits and formats
Video files are limited to 100MB (images 30MB). This limit is operational, not architectural: the direct-upload flow sends bytes straight to storage, so a future higher limit requires no integration changes on your side. If you hit the cap, export a platform-ready MP4 — standard social presets produce files well under 100MB.
| Limit | Value |
|---|---|
| Accepted types | video/mp4, video/quicktime, image/jpeg, image/png, image/webp |
| Video file size | 100MB |
| Image file size | 30MB |
| Files per upload call | 1–10 |
| Video posts | Single file — a video is never grouped with other files |
| Caption | Required, may be empty, max 2,200 characters |
| Per-project quota | 250 uploads / 25GB (see Quota) |
Grouping: one post per file, or one slideshow
The direct-upload session takes a grouping:
per-file— every file becomes its own content item. Images and videos can mix; each video is its own single-file post by construction.slideshow— all files become one image-slideshow item. Images only: any video in aslideshowgrouping is rejected with422 VALIDATION("Slideshows are images only."). Aslideshowof exactly one image is accepted and comes back as animageitem.
On URL-fetch there is no grouping field — the media array is always one content item: one video, one image, or 2–10 images as a slideshow.
Transport A: URL-fetch
One call. Layers fetches each URL server-side, validates Content-Type and size, probes the media, and returns the completed content item synchronously.
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. ☕"
}'201 returns the standard content item — id (cnt_…), status: "completed", creativeType: "uploaded", adsEnrollment: "opted_out", platformFit, assets with probe-measured dimensions. It's ready to schedule immediately.
The call is atomic. Every URL is fetched and validated before anything is written — if any one file fails (unreachable URL, wrong Content-Type, over the cap), no content item is created. There is no partial success to clean up.
The fetch budget is 60 seconds per URL, redirects included. The Content-Type your host returns is the source of truth — we don't sniff file magic, so a server that returns application/octet-stream for an MP4 gets a 422.
Private buckets: the signed-GET recipe
The URL doesn't have to be permanently public — a short-lived signed GET URL from your own storage works and is the right pattern for private media:
SIGNED_URL=$(aws s3 presign s3://my-bucket/posts/launch-teaser.mp4 --expires-in 900)
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\": \"$SIGNED_URL\"}], \"caption\": \"The roast that started it all.\"}"SIGNED_URL=$(gcloud storage sign-url gs://my-bucket/posts/launch-teaser.mp4 \
--duration=15m --private-key-file=key.json | grep signed_url | cut -d' ' -f2)
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\": \"$SIGNED_URL\"}], \"caption\": \"The roast that started it all.\"}"Sign for at least 15 minutes, not 60 seconds. The signature must outlive the whole call — a 100MB video on a slow origin can take most of the 60-second fetch budget, and a retry reuses the same URL. An expired signature surfaces as 502 SCRAPE_FAILED, which reads like a flaky origin and wastes your debugging time.
Transport B: presigned direct upload
Three steps. This is the large-file path — your bytes never transit the Layers API, so the per-file cap is the only ceiling and it's configuration, not architecture.
Step 1 — create the session
curl -X POST https://api.layers.com/v1/projects/{projectId}/content/uploads \
-H "Authorization: Bearer $LAYERS_API_KEY" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: $(uuidgen)" \
-d '{
"files": [
{ "filename": "teaser.mp4", "contentType": "video/mp4", "sizeBytes": 48211234 }
],
"grouping": "per-file"
}'201 returns one entry per resulting content item:
{
"uploads": [
{
"containerId": "cnt_a3f8c2d1-7b4e-4f0a-9c6d-2e1b5a8f3c70",
"files": [
{
"uploadUrl": "https://<account>.r2.cloudflarestorage.com/layers-public/public-media/content-container/a3f8c2d1-.../media/upload-1.mp4?X-Amz-Signature=...",
"r2Key": "public-media/content-container/a3f8c2d1-.../media/upload-1.mp4",
"contentType": "video/mp4",
"expiresAt": "2026-06-12T15:30:00.000Z"
}
]
}
]
}The session reserves the content item up front — quota is checked here, and the containerId is yours from this moment.
Step 2 — PUT each file
curl -X PUT "$UPLOAD_URL" \
-H "Content-Type: video/mp4" \
--data-binary @teaser.mp4The Content-Type header must be exactly the contentType you declared in step 1 — it's signed into the URL, and a mismatch fails the signature check at the storage layer. Finish every PUT before expiresAt.
Step 3 — finalize
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 verifies every declared file landed, re-checks actual size against the cap and your declared sizeBytes, probes the media, and completes the item — 200 with the standard content item. Sessions that produced multiple items (per-file with several files) finalize each containerId separately.
Retries and expiry
- Finalize before the PUT landed →
409 UPLOAD_INCOMPLETE. The message names the missing key ("did the PUT complete?"). Complete the PUT, then call finalize again. uploadUrlexpired (default lifetime 15 minutes): the PUT fails at the storage layer with a signature error. There is no re-presign for an existing session — start a new one. Sessions are cheap, and abandoned ones are cleaned up automatically within about two hours, freeing their quota.- Finalize is idempotent. Re-finalizing a completed upload returns
200with the same item. Safe to retry on timeouts with the sameIdempotency-Keyor without one. - Finalize on a session that was already reaped (you waited past the abandonment window) →
409 CONFLICT. Start a new session.
The finalize-retry body is ignored. The first successful finalize wins; a retry with a "corrected" caption is a silent no-op. To fix a caption after finalize, use PATCH /v1/content/:containerId — that's what it's for.
An uploadUrl stays valid until its expiresAt, even after finalize. Don't re-PUT to a finalized upload's URL — finalize already probed and checksummed the original bytes, and a later overwrite makes the published file diverge from what the API reported. If you need different media, upload a new item.
Platform fit
platformFit is advisory and per-platform. An upload is never rejected because it doesn't fit a particular platform — rejection at upload time only happens for invalid files (unsupported format, over the size limit, unreadable media). Scheduling or publishing to a platform the content doesn't fit returns 422 with the same issues listed in platformFit, so you can detect misfit at upload time rather than at publish time.
Every uploaded content item carries the computed field on the upload/finalize response and on every GET:
"platformFit": [
{
"platform": "tiktok",
"ok": false,
"issues": ["800x800 image resolution too low (min 1080px on the short edge)"]
},
{
"platform": "instagram",
"ok": false,
"issues": ["800x800 image resolution too low (min 1080px on the short edge)"]
}
]The launch platform set is tiktok and instagram. The set is additive — new platforms appear as new array entries, never as changes to existing ones.
Content is platform-agnostic, so the rules are asset-intrinsic — one set per media kind, shared across the launch set (the same issues appear on every platform an asset doesn't fit). We admit only what comes out clean on both platforms after our publish-time transform:
| Media | Rule |
|---|---|
| Images | Short edge ≥ 1080px. Aspect is not gated — we crop per platform at publish, so any aspect is admitted. |
| Videos | MP4 or MOV · H.264 or HEVC · AAC audio (or silent) · 3s–10min · ≥ 360px on both edges · ≤ 1920px wide · 23–60fps · ≤ 25 Mbps · ≤ 100MB. Landscape video is allowed (aspect is advisory, never a reject). |
Images are re-encoded and cropped per platform at publish, so the only intrinsic gate is a resolution floor (a sub-1080 source upscales to mush). Videos are published as-is — no transform — so the gate enforces the full container/codec/audio/duration/dimension intersection of both platforms.
Scheduling or publishing an unfit upload to that platform returns 422 VALIDATION carrying the same issues:
{
"error": {
"code": "VALIDATION",
"message": "This uploaded content does not fit instagram. See platformFit on the content item.",
"requestId": "req_...",
"details": {
"platform": "instagram",
"socialAccountId": "sa_9b2e4f6a-...",
"issues": [
{ "path": "targets", "message": "800x800 image resolution too low (min 1080px on the short edge)" }
]
}
}
}The gate is per-target: the same item publishes to TikTok without complaint. Check platformFit right after upload and route content accordingly — don't wait for the 422.
Fix a caption after the fact
PATCH /v1/content/:containerId updates caption on uploaded content. It's restricted to creativeType: "uploaded" — generated content echoes its hook from generation inputs and is corrected by regenerating, so the PATCH returns 422 there.
Reading uploads back
Every content item and list item now carries two fields:
creativeType—"generated"or"uploaded".adsEnrollment—"auto"(generated),"opted_in", or"opted_out". Uploads are born"opted_out".
Uploaded items additionally carry platformFit on the full GET, and their preview.aspectRatio is measured from the actual file — a landscape upload reports 16:9, not an assumed vertical.
Filter the list by origin with creativeType:
curl "https://api.layers.com/v1/projects/{projectId}/content?creativeType=uploaded&status=completed" \
-H "Authorization: Bearer $LAYERS_API_KEY"Quota
Each project holds up to 250 uploads / 25GB. Exceeding either returns 409 UPLOAD_QUOTA_EXCEEDED, and the details always name your current usage and the limit — you never have to guess what to free:
{
"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
}
}
}The count includes in-flight direct-upload sessions (a session reserves its slot the moment it's created) and archived items. Deleting an upload frees its quota immediately; abandoned sessions free theirs automatically when the cleanup sweep reaps them (~2 hours).
Freeing quota today happens in the Layers app: open your project's Assets → Uploaded Content tab and archive the items you no longer need (or contact support to raise your limits). A partner API delete endpoint for uploaded content (DELETE /v1/content/:id) is a committed follow-up — your integration won't need to change when it arrives.
Errors
| Status | Code | When |
|---|---|---|
| 422 | VALIDATION | Unsupported Content-Type, grouping rule broken ("Slideshows are images only."), URL rejected by the SSRF guard, caption over 2,200 chars, unreadable/corrupt media at probe time. |
| 413 | PAYLOAD_TOO_LARGE | A file is over its per-type cap (100MB video / 30MB image), at fetch time or at finalize. |
| 502 | SCRAPE_FAILED | URL-fetch upstream returned an error or timed out — includes expired signed URLs. Retryable once you've fixed the URL. |
| 409 | UPLOAD_INCOMPLETE | Finalize arrived before every declared PUT landed. Complete the PUTs, finalize again. |
| 409 | UPLOAD_QUOTA_EXCEEDED | Per-project quota hit. Details carry usage + limits. |
| 409 | CONFLICT | The session is no longer finalizable (reaped after abandonment, or state changed mid-call). Start a new session. |
| 404 | NOT_FOUND | Container not in your org, or not an upload (finalize-upload is uploads-only). |
All in the standard error envelope.
Uploads and ads
Uploaded content is created with adsEnrollment: "opted_out" and is not used in paid campaigns. Before ad budget can go behind customer-supplied content, it must pass an explicit opt-in and safety review — that enrollment flow ships in an upcoming release as an additive API (your integration won't change).
Sandbox
Both transports work with a test key (lp_test_…), with zero real fetches and zero storage writes — see Sandbox:
- URL-fetch: no fetch happens. You get a
201completed item backed by fixture media, with your caption verbatim. - Direct upload: the session response has the normal shape, but the
uploadUrls are inert — skip or ignore the PUT in sandbox; a PUT against them fails harmlessly and nothing reads it. Finalize short-circuits to fixture media and returns the completed item with your caption. Byte validation is skipped entirely in sandbox: size caps, Content-Type checks, and probing only run against live keys.
Sandbox uploads never consume live quota — run CI loops freely.
See also
Connect your first social account
A 15-minute walkthrough — from "Connect TikTok" button to a live socialAccountId you can schedule against. The full server glue, not just the curl calls.
Publish to learn
After the first few posts land, read metrics, pick top performers, and commission more of what's working.