---
title: Media Uploads
description: Get a local file onto the Wireflow CDN so you can wire it into a workflow. Inline for small files, presigned direct-to-R2 for anything up to 25MB, or 100MB for video.
updated: 2026-10-03
---

Workflows consume media by URL. These endpoints put your file on `cdn.wireflow.ai`, which is already on the executor's host allowlist, so the returned `url` drops straight into an `input:image` / `input:video` / `input:audio` node or any media port.

All three doors return the **same response shape**, so pick one by file size and parse the result the same way:

```json
{
  "url": "https://cdn.wireflow.ai/uploads/api/42/a1b2....png",
  "mediaId": "a1b2....png",
  "contentType": "image/png",
  "width": 1024,
  "height": 1024,
  "duration": null,
  "bytes": 812345
}
```

## Which door to use

| Your file                      | Use                                          | Limit |
| ------------------------------ | -------------------------------------------- | ----- |
| Small, as a multipart file     | `POST /media/upload` with `file=@...`        | 4MB   |
| Small, as a base64 `dataUrl`   | `POST /media/upload` with `{ "dataUrl" }`    | 3MB   |
| Image or audio, bytes in hand  | `POST /media/upload-url` → PUT → `/complete` | 25MB  |
| Video, bytes in hand           | `POST /media/upload-url` → PUT → `/complete` | 100MB |
| Already hosted at a public URL | `POST /media/upload` with `{ "url": "..." }` | 25MB  |

> **Why 4MB inline, when the presigned flow takes more?** The API runs on serverless functions that reject a request body over roughly 4.5MB before any Wireflow code runs. Rather than let you discover that as an opaque platform error, the inline door refuses at 4MB and points you here. The presigned flow has no such ceiling because the bytes go straight from you to storage.
>
> **And why 3MB for a `dataUrl`?** Same 4MB limit, different units. base64 puts about 4/3 of the file on the wire, and the limit is measured on the encoded body, so a `dataUrl` tops out around 3MB of actual file. A 3.6MB image is under "4MB" and still too big to send this way — use multipart, or the presigned flow.

Scope for every endpoint on this page: `workflows:write`. Uploads are listed by `GET /media`.

## Inline upload

`POST /api/v1/media/upload`

Send one of:

- `multipart/form-data` with a `file` field, up to 4MB
- JSON `{ "dataUrl": "data:image/png;base64,..." }`, up to 3MB of file (see above)
- JSON `{ "url": "https://example.com/clip.mp4" }`, we fetch and rehost it, up to 25MB for every type. A video over 25MB goes through the presigned flow below.

```bash
curl -X POST https://wireflow.ai/api/v1/media/upload \
  -H "Authorization: Bearer $WIREFLOW_API_KEY" \
  -F "file=@hook.png"
```

Optional `width`, `height` and `duration` fields are accepted as hints. They are only used when we cannot read the value out of the file itself, and they never override what we measured.

## Presigned upload (up to 25MB, 100MB for video)

Three calls. **Finish within 24 hours:** an upload that never reaches step 3 is swept, along with its object, by a daily cleanup. The asset does not appear in `GET /media` until step 3 either.

**1. Ask for a URL.** `bytes` is the exact byte length of the file. A file over the limit for its type (25MB, or 100MB for `video/*`) is refused here with `file_too_large`, before anything is signed.

```bash
curl -X POST https://wireflow.ai/api/v1/media/upload-url \
  -H "Authorization: Bearer $WIREFLOW_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"contentType":"video/mp4","bytes":18874368,"filename":"broll.mp4"}'
```

```json
{
  "uploadUrl": "https://....r2.cloudflarestorage.com/...?X-Amz-Signature=...",
  "method": "PUT",
  "headers": {
    "Content-Type": "video/mp4",
    "Content-Length": "18874368",
    "Cache-Control": "public, max-age=31536000, immutable"
  },
  "mediaId": "a1b2c3d4-....mp4",
  "url": "https://cdn.wireflow.ai/uploads/api/42/a1b2c3d4-....mp4",
  "contentType": "video/mp4",
  "bytes": 18874368,
  "expiresAt": "2026-08-18T12:15:00.000Z"
}
```

**2. PUT the bytes to `uploadUrl` with exactly the headers you were given.** The content type and the byte length are part of the signature, so a mismatch is rejected by storage with `SignatureDoesNotMatch`. The URL is valid for 15 minutes.

```bash
curl -X PUT "$UPLOAD_URL" \
  -H "Content-Type: video/mp4" \
  -H "Content-Length: 18874368" \
  --data-binary @broll.mp4
```

**3. Finalize.**

```bash
curl -X POST https://wireflow.ai/api/v1/media/upload-url/complete \
  -H "Authorization: Bearer $WIREFLOW_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"mediaId":"a1b2c3d4-....mp4"}'
```

This verifies the object landed, checks its size against the same limit, confirms an image is really a decodable image, and returns the standard response above. It is safe to retry.

If it answers `unreadable_image`, the bytes you PUT were not the image type you asked for. The object is discarded — mint a new upload url and send the real file.

### Hourly budget

An account can mint up to 10GB of upload urls in a rolling hour. A mint counts for one hour from the moment it is made, whether or not you complete it. The count covers every upload made through these endpoints and through upload links. It is taken under a per-account lock, so a burst of parallel requests cannot get past the limit. Mints for one account are handled one at a time: a request that finds another one in progress waits its turn for up to about a second, and if it still has no turn it answers `429` with code `upload_busy` and `Retry-After: 1`. Retry it; nothing was signed and no row was written. Past the budget, `POST /media/upload-url` answers `429` with code `upload_budget_exceeded` and a `Retry-After` in seconds, and the message says when the oldest upload leaves the hour and room starts to come back. Nothing is signed and no row is written for a refused mint.

### Metadata on the presigned path

Images get their `width` and `height` read from the file. Video and audio return `null` for `width`, `height` and `duration` unless you send them yourself on the `/complete` call. We will not download the file just to measure it, and a video can be 100MB. Sent values are bounded and are only ever used to fill a gap.

## Accepted types

Images `jpg`, `png`, `gif`, `webp`, `avif` · video `mp4`, `webm`, `mov` · audio `mp3`, `wav`, `m4a`.

Anything else is refused with `unsupported_type`.

### SVG

`svg` (`image/svg+xml`, max 1MB) is accepted by the inline door only, through any of its three forms (multipart, `dataUrl`, `{ "url": "..." }`). It is made for brand logos.

We never store the file you sent. It is parsed and rebuilt from an allowlist of shape, gradient, clip, mask and `use` elements, and only that rebuilt markup is stored. Scripts, event handlers, `foreignObject`, `<image>`, links, animation, external and `data:` urls, `@import`, and DOCTYPE or entity declarations are removed. `href` survives only as a same-document `#id`. The returned `bytes` is the size of the stored, sanitized file. An SVG over 1MB, with more than 20,000 elements, or nested more than 64 deep is refused, never truncated.

The stored file is served as `image/svg+xml` with `Content-Disposition: attachment`, so it renders in an `<img>` or a composition but downloads if opened in a tab.

The presigned flow refuses SVG: it never sees the bytes, so it cannot sanitize them.

A paid image model needs pixels. When an SVG is wired into one, it is rasterized to PNG before the run is charged; if that cannot be done the run is refused, uncharged.

## Errors

| Code                     | Meaning                                                                                              |
| ------------------------ | ---------------------------------------------------------------------------------------------------- |
| `unsupported_type`       | The content type is not in the list above                                                            |
| `file_too_large`         | Over the limit for the door and file type you used                                                   |
| `upload_budget_exceeded` | More than 10GB of upload urls minted in the last hour (HTTP 429)                                     |
| `upload_busy`            | Another mint for this account kept its turn for about a second (HTTP 429, retry after `Retry-After`) |
| `invalid_bytes`          | `bytes` is missing or is not a positive whole number                                                 |
| `invalid_media_id`       | The `mediaId` is not one we minted                                                                   |
| `upload_not_found`       | Unknown `mediaId`, the PUT never happened, or the 24h window lapsed                                  |
| `unreadable_image`       | The bytes are not a decodable image of the type you declared                                         |
| `empty_file`             | Zero bytes                                                                                           |
| `invalid_svg`            | Sent as SVG, but the root element is not `<svg>`                                                     |
| `svg_too_complex`        | SVG with more than 20,000 elements or nested more than 64 deep                                       |

---

Documentation index: fetch https://www.wireflow.ai/llms.txt for the full list of Wireflow docs.
