---
title: "Upload or rehost an image"
method: POST
path: "/cms/media"
tags: ["CMS"]
---

# Upload or rehost an image

`POST /cms/media`

## Request

- Base URL: `https://next.frase.io/api/v1`
- URL: `https://next.frase.io/api/v1/cms/media`
- Auth: API key in header `X-API-KEY`

Upload an image and get back a public URL you can use in `featured_image`, `og_image` or post body HTML. JSON only — there is no multipart form. Two mutually exclusive shapes: pass `url` to rehost a public HTTPS image onto Frase storage, or pass `data` (base64-encoded image bytes) together with `content_type`. Supplying both is a 400. Requires the `write:content` scope, and the site must belong to the authenticated organization (404 otherwise). Allowed types are `image/jpeg`, `image/png`, `image/webp` and `image/gif`; the maximum size is 2 MB. The rehost fetch is SSRF-guarded (HTTPS only, private and metadata address ranges blocked, redirects rejected, 15 second timeout) and size-bounded while streaming; every fetch failure returns the same generic message so the endpoint cannot be used as a DNS-existence oracle. The storage path is derived from the authenticated organization plus the ownership-checked site and a content hash of the bytes — never from a caller-supplied path. Nothing is persisted in a media table: the response URL is the only record, so store it yourself.

## Request body

- union
  - object
    - `site_id` string, uuid, required
    - `url` string, uri, required: Public HTTPS image URL to fetch and rehost.
  - object
    - `site_id` string, uuid, required
    - `data` string, byte, required: Base64-encoded image bytes. Must decode to at most 2 MB and be non-empty.
    - `content_type` 'image/jpeg' | 'image/png' | 'image/webp' | 'image/gif', required: MIME type of the bytes. Any `; charset=…` suffix is stripped before the check.

## Response `201`

Image stored.

- object
  - `data` object
    - `url` string, uri: Public URL of the stored image.
    - `content_type` string: The normalized image MIME actually stored.
    - `bytes` integer: Stored image size in bytes.

## Other responses

- `400`: Invalid request. Two shapes reach this status: parameter validation (body is not JSON, `site_id` missing or not a UUID, both `url` and `data` supplied, neither supplied, `data` without `content_type`) returns the standard error envelope; upload validation (unsupported image type, empty image, over 2 MB, or a source URL that could not be fetched or returned a non-2xx) returns a plain message under `data.error`.
- `401`: Authentication failed
- `403`: Forbidden - action not allowed on this resource
- `404`: Resource not found
- `429`: Rate limit exceeded
- `500`: Storage or unexpected failure.

---

[API](https://skmtc.dev/frase/apis/frase-api.md) · [All operations](https://skmtc.dev/frase/apis/frase-api/llms.txt) · [OpenAPI document](https://skmtc.dev/frase/apis/frase-api/revisions/7fb75d89f7a4?raw)
