# POST /v1/projects/:projectId/app-media (/docs/api/reference/app-media/upload-app-media)



<Endpoint method="POST" path="/v1/projects/:projectId/app-media" 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 [#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 [#path-parameters]

<Parameters
  rows="[
  { name: 'projectId', type: 'string (uuid)', required: true, description: 'The project to attach media to.' },
]"
/>

## Body [#body]

<Parameters
  title="Body"
  rows="[
  { name: 'kind', type: 'string', required: true, description: 'Which asset slot to upload into.', enum: ['logo', 'screenshot', 'demo-video', 'end-card'] },
  { name: 'url', type: 'string (https URL)', required: true, description: 'Publicly fetchable URL. Must resolve to a non-private IP. Redirects (≤ 3 hops) are followed.' },
]"
/>

## Request [#request]

<Tabs items="['curl', 'TypeScript']">
  <Tab value="curl">
    ```bash
    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"
      }'
    ```
  </Tab>

  <Tab value="TypeScript">
    ```ts title="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 }
    ```
  </Tab>
</Tabs>

## Response [#response]

<Response status="201" description="Asset stored in R2 and indexed in media_library.">
  ```json
  {
    "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"
  }
  ```
</Response>

## Errors [#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 [#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 [#see-also]

* [List app media](/docs/api/reference/app-media/list-app-media)
* [Delete app media](/docs/api/reference/app-media/delete-app-media)
* [Getting started](/docs/api/getting-started/overview) — where app-media fits in the partner flow
