Media Uploads
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.
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:
{
"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 adataUrltops 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-datawith afilefield, 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.
curl -X POST https://wireflow.ai/api/v1/media/upload \
-H "Authorization: Bearer $WIREFLOW_API_KEY" \
-F "[email protected]"
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.
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"}'
{
"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.
curl -X PUT "$UPLOAD_URL" \
-H "Content-Type: video/mp4" \
-H "Content-Length: 18874368" \
--data-binary @broll.mp4
3. Finalize.
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 |