Layers
Partner APIAPI referenceApp media

POST /v1/projects/:projectId/app-media

Upload a logo, screenshot, demo video, or end-card from a public URL.

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

KindDB roleAppend vs. replaceMime allowlistByte cap
logologoReplace — uploading another logo soft-deletes the previous one.image/png, image/jpeg, image/webp, image/svg+xml5 MB
screenshotapp-screenshotAppend.image/png, image/jpeg, image/webp, image/heic10 MB
demo-videoapp-demoAppend. Unlocks the ugc-remix content format.video/mp4, video/quicktime, video/webm200 MB
end-cardend-cardReplace — 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/webp5 MB

Path parameters

  • projectId
    string (uuid)required
    The project to attach media to.

Body

Body
  • kind
    stringrequired
    Which asset slot to upload into.
    One of: logo, screenshot, demo-video, end-card
  • url
    string (https URL)required
    Publicly 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"
  }'
upload-app-media.ts
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

StatusCodeWhen
422VALIDATIONkind not in enum, url malformed, URL resolves to a private IP, or upstream Content-Type not allowed for kind.
413PAYLOAD_TOO_LARGEUpstream body exceeds the per-kind byte cap.
502SCRAPE_FAILEDUpstream URL returned a 4xx/5xx or timed out.
401UNAUTHENTICATEDMissing or invalid key.
403FORBIDDEN_SCOPEKey lacks projects:write.
404NOT_FOUNDProject 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-Type is the source of truth. We don't sniff file magic — if your upstream returns application/octet-stream for an mp4, the request rejects with 422. Configure the host to return the correct mime.
  • Idempotency-Key replays return the cached 201 response — partners can safely retry on connection errors without creating duplicate media_library rows.

See also

On this page