> ## Documentation Index
> Fetch the complete documentation index at: https://docs.thrads.ai/llms.txt
> Use this file to discover all available pages before exploring further.

# Upload Image

> Upload a creative image — by file or by URL — and get back a file_id to attach to an ad

Upload the image for an ad creative. The endpoint returns a `file_id`, which you then pass as `creative.file_id` when [creating an ad](/advertisers/api-reference/ads#create-an-ad). A `file_id` is a stable storage path — upload once, reference it on as many ads as you like.

<Note>
  Accepted formats are **PNG**, **JPEG**, and **WebP**. The maximum size is **5 MB**. The format is detected from the image bytes — the filename and `Content-Type` you send are never trusted to set it.
</Note>

## Upload Image

`POST /v1/upload`

Send the image one of two ways:

* **`multipart/form-data`** with a `file` part — upload raw bytes directly.
* **`application/json`** with `{"image_url": "https://..."}` — have Thrad fetch the image from a URL you control.

Send exactly one. If neither is present, the request fails with a `400`.

### Authorization

<ParamField header="Authorization" type="string" required>
  Your advertiser API key as a Bearer token: `Bearer ak_...`. One active key per organization, created in the Thrad Platform dashboard under **Settings → API keys**.
</ParamField>

### Multipart body

<ParamField body="file" type="file" required>
  The image to upload, as a `multipart/form-data` part. Must be a PNG, JPEG, or WebP no larger than 5 MB. Required unless you send `image_url` instead.
</ParamField>

### JSON body

<ParamField body="image_url" type="string" required>
  An absolute `http(s)` URL that Thrad fetches the image from. Required unless you upload a `file` instead.

  <Warning>
    The fetch is **SSRF-guarded**: the URL must be an absolute `http(s)` address whose host resolves only to public, globally-routable IPs. Hosts that resolve to loopback, private, link-local (cloud metadata), or other reserved ranges are rejected with `invalid_image_url`. Redirects are not followed. The fetched bytes must pass the same format and size checks as a direct upload.
  </Warning>
</ParamField>

<RequestExample>
  ```bash cURL — file upload theme={null}
  curl https://api.thrad.ai/v1/upload \
    -H "Authorization: Bearer $THRAD_ADS_API_KEY" \
    -F "file=@./creative.png"
  ```

  ```bash cURL — image URL theme={null}
  curl https://api.thrad.ai/v1/upload \
    -H "Authorization: Bearer $THRAD_ADS_API_KEY" \
    -H "Content-Type: application/json" \
    -d '{
      "image_url": "https://cdn.example.com/creatives/spring-sale.png"
    }'
  ```

  ```python Python — file upload theme={null}
  import os
  import requests

  resp = requests.post(
      "https://api.thrad.ai/v1/upload",
      headers={"Authorization": f"Bearer {os.environ['THRAD_ADS_API_KEY']}"},
      files={"file": open("./creative.png", "rb")},
  )
  file_id = resp.json()["file_id"]
  ```

  ```python Python — image URL theme={null}
  import os
  import requests

  resp = requests.post(
      "https://api.thrad.ai/v1/upload",
      headers={"Authorization": f"Bearer {os.environ['THRAD_ADS_API_KEY']}"},
      json={"image_url": "https://cdn.example.com/creatives/spring-sale.png"},
  )
  file_id = resp.json()["file_id"]
  ```
</RequestExample>

<ResponseExample>
  ```json 201 - Created theme={null}
  {
    "file_id": "uploads/2026/06/3f8c1a2b9d7e4f0a8c6b5d4e3f2a1b0c.png"
  }
  ```

  ```json 400 - Missing image theme={null}
  {
    "error": {
      "message": "Provide a multipart `file` or a JSON `image_url`.",
      "type": "invalid_request_error",
      "param": "file",
      "code": "invalid_value"
    }
  }
  ```

  ```json 400 - Invalid image theme={null}
  {
    "error": {
      "message": "Uploaded file is not a valid image.",
      "type": "invalid_request_error",
      "param": "file",
      "code": "invalid_file"
    }
  }
  ```
</ResponseExample>

### Response

<ResponseField name="file_id" type="string">
  The storage path of the uploaded image (e.g. `uploads/2026/06/<hex>.png`). Pass this value as `creative.file_id` when creating an ad. Treat it as an opaque token.
</ResponseField>

<Tip>
  Reuse a `file_id` across multiple ads instead of re-uploading the same image. Each ad that references it creates its own creative asset from the stored bytes.
</Tip>

## Errors

The endpoint returns a bare error object on failure. A missing or unreadable image, or an image that fails validation, returns a `400`.

| Status | `code`                 | Meaning                                                                                                                 |
| ------ | ---------------------- | ----------------------------------------------------------------------------------------------------------------------- |
| `400`  | `invalid_value`        | Neither a `file` part nor an `image_url` was supplied.                                                                  |
| `400`  | `invalid_file`         | The bytes are missing, unreadable, or not a decodable image.                                                            |
| `400`  | `file_too_large`       | The image exceeds the 5 MB limit.                                                                                       |
| `400`  | `unsupported_mimetype` | The image is not PNG, JPEG, or WebP.                                                                                    |
| `400`  | `invalid_image_url`    | `image_url` is empty, not an absolute `http(s)` URL, or its host is unresolvable or not publicly routable (SSRF guard). |
| `400`  | `image_fetch_failed`   | Fetching `image_url` failed — network error, timeout, or a non-success HTTP status.                                     |
| `400`  | `too_many_redirects`   | `image_url` returned a redirect (redirects are not followed).                                                           |
| `401`  | —                      | Missing or invalid API key.                                                                                             |
| `429`  | —                      | Rate limit exceeded (1000 requests/hour per key).                                                                       |
