POST /v1/projects/:projectId/app-media
Upload a logo, screenshot, demo video, or end-card from a public URL.
POST
/v1/projects/:projectId/app-mediastableidempotent
- Auth
- Bearer
- Scope
- projects:write
Server-side download. Send a kind and a public url; Layers fetches the URL through the SSRF guard, validates Content-Type + size, stores the body in Cloudflare R2, and creates the media_library row. Returns the canonical asset on 201.
Behavior per kind
| Kind | DB role | Append vs. replace | Mime allowlist | Byte cap |
|---|---|---|---|---|
logo | logo | Replace — uploading another logo soft-deletes the previous one. | image/png, image/jpeg, image/webp, image/svg+xml | 5 MB |
screenshot | app-screenshot | Append. | image/png, image/jpeg, image/webp, image/heic | 10 MB |
demo-video | app-demo | Append. Unlocks the ugc-remix content format. | video/mp4, video/quicktime, video/webm | 200 MB |
end-card | end-card | Replace — uploading another end-card soft-deletes the previous partner-uploaded one. Layers auto-generates a default end-card per project; uploading one here pins the partner-supplied image as the live end-card and the auto-generated row is bypassed on new generations. | image/png, image/jpeg, image/webp | 5 MB |
Path parameters
projectIdstring (uuid)requiredThe project to attach media to.
Body
Body
kindstringrequiredWhich asset slot to upload into.One of:logo,screenshot,demo-video,end-cardurlstring (https URL)requiredPublicly fetchable URL. Must resolve to a non-private IP. Redirects (≤ 3 hops) are followed.
Request
curl -X POST https://api.layers.com/v1/projects/$PROJECT_ID/app-media \
-H "Authorization: Bearer $LAYERS_API_KEY" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: $(uuidgen)" \
-d '{
"kind": "logo",
"url": "https://cdn.acmecoffee.com/brand/logo-512.png"
}'const res = await fetch(
`https://api.layers.com/v1/projects/${projectId}/app-media`,
{
method: "POST",
headers: {
Authorization: `Bearer ${process.env.LAYERS_API_KEY}`,
"Content-Type": "application/json",
"Idempotency-Key": crypto.randomUUID(),
},
body: JSON.stringify({
kind: "logo",
url: "https://cdn.acmecoffee.com/brand/logo-512.png",
}),
},
);
const item = await res.json();
// { id: "med_…", kind: "logo", url: "https://media-library.layers.com/<projectId>/<med>.png", mimeType, byteSize, createdAt }Response
201Asset stored in R2 and indexed in media_library.
{
"id": "med_01HZ...",
"kind": "logo",
"url": "https://media-library.layers.com/9cb958b5-…/3e0b…png",
"mimeType": "image/png",
"byteSize": 23491,
"createdAt": "2026-05-11T19:08:44.317Z"
}Errors
| Status | Code | When |
|---|---|---|
| 422 | VALIDATION | kind not in enum, url malformed, URL resolves to a private IP, or upstream Content-Type not allowed for kind. |
| 413 | PAYLOAD_TOO_LARGE | Upstream body exceeds the per-kind byte cap. |
| 502 | SCRAPE_FAILED | Upstream URL returned a 4xx/5xx or timed out. |
| 401 | UNAUTHENTICATED | Missing or invalid key. |
| 403 | FORBIDDEN_SCOPE | Key lacks projects:write. |
| 404 | NOT_FOUND | Project not in this org. |
Notes
- The URL must be publicly fetchable from Layers' egress. The SSRF guard rejects internal addresses, link-local, private ranges, and any URL whose hostname resolves into those.
Content-Typeis the source of truth. We don't sniff file magic — if your upstream returnsapplication/octet-streamfor an mp4, the request rejects with 422. Configure the host to return the correct mime.- Idempotency-Key replays return the cached
201response — partners can safely retry on connection errors without creating duplicatemedia_libraryrows.
See also
- List app media
- Delete app media
- Getting started — where app-media fits in the partner flow