# App media (/docs/api/concepts/media-library)



The partner-facing app-media surface stores four asset slots per project:

* **`logo`** — singleton; uploading a new one soft-deletes the previous.
* **`screenshot`** — append-many.
* **`demo-video`** — append-many. Required to unlock the `ugc-remix` content format.
* **`end-card`** — singleton; uploading pins the partner image as the live end-card and Layers' auto-generated end-card is bypassed on new generations.

Uploads are URL-based: hand Layers a public `https` URL and we fetch it through the SSRF guard, validate the response's `Content-Type` + size, persist the bytes to Cloudflare R2, and return the canonical asset row. There is no presign / finalize dance at the partner edge.

## Per-kind constraints [#per-kind-constraints]

| 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 pins as the live end-card. Layers also auto-generates an end-card per project; the partner upload wins. | `image/png`, `image/jpeg`, `image/webp`                  | 5 MB     |

`media_role` is a strict label — it does not float across uses. A `demo-video` will not be picked as a `screenshot`; a `logo` won't be substituted for a `screenshot`. Upload once per slot you need.

## Source URL requirements [#source-url-requirements]

* **`https` only.** `http://`, `file://`, `ftp://`, `gopher://`, `data:` are rejected with `VALIDATION`.
* **Publicly fetchable from Layers' egress.** The SSRF guard resolves the hostname and rejects anything that maps to a private / link-local / loopback / metadata range. Embedded credentials in the URL are rejected.
* **Redirects** up to 3 hops are followed; every hop is re-validated against the SSRF guard.
* **`Content-Type` is authoritative.** Layers does not sniff file magic — if your upstream returns `application/octet-stream` for an mp4, the request rejects with `VALIDATION`. Configure the host to return the correct mime.
* **Byte cap is per-kind** (see table). Upstream `Content-Length` header is honored when present; the stream is hard-capped at the same number, so a misleading header doesn't help.

## Endpoints [#endpoints]

* [`POST /v1/projects/:projectId/app-media`](/docs/api/reference/app-media/upload-app-media) — upload from a URL. Returns `201` with the canonical asset row.
* [`GET /v1/projects/:projectId/app-media`](/docs/api/reference/app-media/list-app-media) — list everything on the project, grouped by kind: `{ logo, screenshots[], demoVideos[], endCard }`.
* [`DELETE /v1/projects/:projectId/app-media/:mediaId`](/docs/api/reference/app-media/delete-app-media) — soft-delete. The R2 object is reaped within 24 hours; downstream content that referenced the asset retains its already-rendered output.

## Idempotency [#idempotency]

`POST /v1/projects/:projectId/app-media` honors the `Idempotency-Key` header. Replays return the cached `201` response, so safe to retry on connection errors without creating duplicate `media_library` rows.

## What partners do not get [#what-partners-do-not-get]

* **No presign + finalize flow.** Layers fetches the bytes server-side from your URL. There is no client-direct R2 PUT.
* **No per-asset metadata write.** The partner only sets `kind` + `url`. Width, height, codec, etc. are probed server-side at fetch time.

## See also [#see-also]

* [Upload app media](/docs/api/reference/app-media/upload-app-media)
* [List app media](/docs/api/reference/app-media/list-app-media)
* [Delete app media](/docs/api/reference/app-media/delete-app-media)
* [`POST /content/ugc-remix`](/docs/api/reference/content/ugc-remix) — consumes `media_role = 'app-demo'` rows.
