Upload images from anywhere.
imageupload.io exposes two equivalent programmatic interfaces: a classic HTTP REST API, and an MCP (Model Context Protocol) server so LLM-based agents can upload on your behalf. Both use the same API key and share the same monthly quota.
Overview
Every registered user gets a personal API key from their profile page. That one key authenticates both the REST API and the MCP server. You don't need separate credentials for each.
Classic HTTP multipart upload. Works from any language with an HTTP client - curl, Python, PHP, Node, Go, etc.
Reference →JSON-RPC 2.0 over HTTP with the standard MCP tool schema. Use this with Claude Desktop, Cursor, Windsurf, or any other MCP-compatible agent host.
Reference →Authentication
Both the REST API and the MCP server accept a Bearer token in the Authorization header. Your API key lives on your profile page.
- Log in to your dashboard.
- Go to Profile → API key.
- Copy the key that starts with
iu_live_. It's about 40 characters long. - Use it as the Bearer token in every request.
Authorization: Bearer iu_live_XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX
Rotating your key. If you think your key has been leaked, visit your profile and click Regenerate. The old key stops working immediately - any AI agent or script still using the old one will start getting 401 Invalid API key until you paste in the new one.
Security. Never commit your key to a git repository. Never paste it into a public chat. If you must share code that uses the key, use an environment variable: IMAGEUPLOAD_API_KEY.
Quotas & limits
| Limit | Free | Starter | Pro | Business |
|---|---|---|---|---|
| API/MCP uploads per month | 10 | 50 | Unlimited | Unlimited |
| Storage | 500 MB | 5 GB | 50 GB | 500 GB |
| Max file size | 25 MB | 25 MB | 50 MB | 100 MB |
| Max expiration | 1 month | No expiration | No expiration | No expiration |
| Custom date expiration | — | — | Yes | Yes |
| Custom short links | — | — | Yes | Yes |
| API calls per minute | 60 | 60 | 60 | 60 |
| MCP calls per minute | 120 | 120 | 120 | 120 |
The monthly quota counter resets at 00:00 UTC on the 1st of each month. You can check your current usage from your dashboard sidebar, or programmatically via the MCP get_quota tool.
Every successful API response includes three HTTP headers:
X-Quota-Used: 3
X-Quota-Limit: 10
X-Quota-Remaining: 7
When you exceed the quota, the API returns HTTP 429 with a JSON body:
{
"error": "Monthly API upload quota exceeded",
"quota": {
"used": 10,
"limit": 10,
"remaining": 0,
"plan": "free",
"upgrade_url": "/dashboard/profile#upgrade"
}
}
Errors
The REST API returns standard HTTP status codes with a JSON body of shape { error: "..." }. The MCP server returns JSON-RPC 2.0 error objects with these codes:
| HTTP | JSON-RPC | Meaning |
|---|---|---|
| 400 | -32602 | Invalid or missing parameters (e.g. bad MIME type, missing filename) |
| 401 | -32001 | Missing or invalid API key |
| 403 | -32603 | Storage quota exceeded, Pro-only feature requested on Free, or content matches a moderation blocklist |
| 404 | -32004 | Image slug not found or not owned by this key |
| 413 | -32602 | File exceeds max upload size |
| 415 | -32602 | Unsupported MIME type |
| 429 | -32002 / -32029 | Rate limit (−32002) or monthly quota exceeded (−32029) |
| 502 | -32004 | Upstream storage backend error |
| 503 | -32003 | Upload queue overloaded - retry shortly |
| 500 | -32000 | Internal server error |
POST /api/upload
Upload a single image. Content type: multipart/form-data.
Request fields
| Field | Type | Required | Description |
|---|---|---|---|
file | File | yes | The image bytes. PNG, JPEG, WebP, GIF, or AVIF. |
expiration | string | no | One of 1d (default), 1w, 1mo, burn, views, forever. See expiration modes. |
view_limit | integer | conditional | Required when expiration=views. Integer from 1-999. |
password | string | no | Optional viewer password (1-256 chars). |
folder | string | no | Slug of a folder you own. Omit to upload to the root. |
Example
curl -X POST https://imageupload.io/api/upload \
-H "Authorization: Bearer $IMAGEUPLOAD_API_KEY" \
-F "[email protected]" \
-F "expiration=1w" \
-F "password=secret123"
Response shape
Every successful upload returns the same JSON shape. You get both a direct URL (for <img> tags) and a share URL (for human-facing landing pages), plus three ready-to-paste embed snippets.
{
"ok": true,
"slug": "aB3xQf9K",
"ext": "jpg",
"filename": "photo.jpg",
"mime": "image/jpeg",
"size": 184732,
"width": 1920,
"height": 1080,
"expiration": "1w",
"expires_at": 1776288000,
"view_limit": null,
"password_protected": true,
"folder": null,
"share_url": "https://imageupload.io/i/aB3xQf9K",
"direct_url": "https://imageupload.io/f/aB3xQf9K.jpg",
"embed": {
"html": "<img src=\"https://imageupload.io/f/aB3xQf9K.jpg\" alt=\"photo.jpg\" />",
"bbcode": "[img]https://imageupload.io/f/aB3xQf9K.jpg[/img]",
"markdown": ""
},
"upload_source": "api",
"quota": {
"used": 4,
"limit": 10,
"remaining": 6,
"plan": "free",
"unlimited": false
}
}
Use direct_url when you want to display the image on a page or in an HTML email.
Use share_url when you want to send a person to a landing page (which honors passwords, view limits, and delete-after-view expiration).
Code examples
Every example uploads photo.jpg and prints the direct URL. Click a language tab on the right to switch, and the Copy button copies the currently visible example.
curl -X POST https://imageupload.io/api/upload \
-H "Authorization: Bearer $IMAGEUPLOAD_API_KEY" \
-F "[email protected]" \
-F "expiration=1mo" | jq -r .direct_url
import { readFile } from 'node:fs/promises';
const buf = await readFile('photo.jpg');
const fd = new FormData();
fd.append('file', new Blob([buf], { type: 'image/jpeg' }), 'photo.jpg');
fd.append('expiration', '1mo');
const res = await fetch('https://imageupload.io/api/upload', {
method: 'POST',
headers: { Authorization: 'Bearer ' + process.env.IMAGEUPLOAD_API_KEY },
body: fd,
});
const json = await res.json();
if (!res.ok) throw new Error(json.error || 'upload failed');
console.log(json.direct_url);
import os, requests
with open('photo.jpg', 'rb') as f:
r = requests.post(
'https://imageupload.io/api/upload',
headers={'Authorization': 'Bearer ' + os.environ['IMAGEUPLOAD_API_KEY']},
files={'file': ('photo.jpg', f, 'image/jpeg')},
data={'expiration': '1mo'},
)
r.raise_for_status()
print(r.json()['direct_url'])
<?php
$ch = curl_init('https://imageupload.io/api/upload');
curl_setopt_array($ch, [
CURLOPT_RETURNTRANSFER => true,
CURLOPT_HTTPHEADER => ['Authorization: Bearer ' . getenv('IMAGEUPLOAD_API_KEY')],
CURLOPT_POSTFIELDS => [
'file' => new CURLFile('photo.jpg', 'image/jpeg', 'photo.jpg'),
'expiration' => '1mo',
],
]);
$response = curl_exec($ch);
if (curl_errno($ch)) { throw new Exception(curl_error($ch)); }
curl_close($ch);
$data = json_decode($response, true);
echo $data['direct_url'] . PHP_EOL;
require 'net/http'
require 'uri'
require 'json'
uri = URI('https://imageupload.io/api/upload')
req = Net::HTTP::Post.new(uri)
req['Authorization'] = "Bearer #{ENV['IMAGEUPLOAD_API_KEY']}"
form = [
['file', File.open('photo.jpg'), { filename: 'photo.jpg', content_type: 'image/jpeg' }],
['expiration', '1mo'],
]
req.set_form(form, 'multipart/form-data')
res = Net::HTTP.start(uri.hostname, uri.port, use_ssl: true) { |h| h.request(req) }
puts JSON.parse(res.body)['direct_url']
package main
import (
"bytes"
"encoding/json"
"fmt"
"io"
"mime/multipart"
"net/http"
"os"
)
func main() {
f, _ := os.Open("photo.jpg")
defer f.Close()
var buf bytes.Buffer
mw := multipart.NewWriter(&buf)
fw, _ := mw.CreateFormFile("file", "photo.jpg")
io.Copy(fw, f)
mw.WriteField("expiration", "1mo")
mw.Close()
req, _ := http.NewRequest("POST", "https://imageupload.io/api/upload", &buf)
req.Header.Set("Authorization", "Bearer "+os.Getenv("IMAGEUPLOAD_API_KEY"))
req.Header.Set("Content-Type", mw.FormDataContentType())
res, err := http.DefaultClient.Do(req)
if err != nil { panic(err) }
defer res.Body.Close()
var out map[string]any
json.NewDecoder(res.Body).Decode(&out)
fmt.Println(out["direct_url"])
}
// Cargo.toml:
// reqwest = { version = "0.12", features = ["multipart", "json", "blocking"] }
// serde_json = "1"
use std::env;
fn main() -> Result<(), Box<dyn std::error::Error>> {
let token = env::var("IMAGEUPLOAD_API_KEY")?;
let form = reqwest::blocking::multipart::Form::new()
.file("file", "photo.jpg")?
.text("expiration", "1mo");
let res: serde_json::Value = reqwest::blocking::Client::new()
.post("https://imageupload.io/api/upload")
.bearer_auth(token)
.multipart(form)
.send()?
.json()?;
println!("{}", res["direct_url"]);
Ok(())
}
#!/usr/bin/perl
use strict;
use warnings;
use LWP::UserAgent;
use HTTP::Request::Common qw(POST);
use JSON::PP;
my $ua = LWP::UserAgent->new;
my $req = POST 'https://imageupload.io/api/upload',
Content_Type => 'form-data',
Authorization => 'Bearer ' . $ENV{'IMAGEUPLOAD_API_KEY'},
Content => [
file => ['photo.jpg', 'photo.jpg', 'Content-Type' => 'image/jpeg'],
expiration => '1mo',
];
my $res = $ua->request($req);
die $res->status_line unless $res->is_success;
print decode_json($res->decoded_content)->{'direct_url'}, "\n";
$form = @{
file = Get-Item 'photo.jpg'
expiration = '1mo'
}
$headers = @{ Authorization = "Bearer $env:IMAGEUPLOAD_API_KEY" }
$response = Invoke-RestMethod -Uri 'https://imageupload.io/api/upload' -Method Post -Form $form -Headers $headers
Write-Host $response.direct_url
MCP server
MCP (Model Context Protocol) is an open standard for giving AI agents access to tools. If your agent host supports MCP over HTTP, you can plug imageupload.io in as a tool server and let your agent upload images on your behalf.
The MCP endpoint is https://imageupload.io/mcp. It speaks JSON-RPC 2.0 and implements three methods: initialize, tools/list, and tools/call.
Connecting
Send an initialize request to confirm the server is up, then tools/list to see the available tools.
curl -X POST https://imageupload.io/mcp \
-H "Authorization: Bearer $IMAGEUPLOAD_API_KEY" \
-H "Content-Type: application/json" \
-d '{"jsonrpc":"2.0","id":1,"method":"initialize"}'
curl -X POST https://imageupload.io/mcp \
-H "Authorization: Bearer $IMAGEUPLOAD_API_KEY" \
-H "Content-Type: application/json" \
-d '{"jsonrpc":"2.0","id":2,"method":"tools/list"}'
Tools reference
Upload a base64-encoded image. Returns both the direct URL and share URL, plus embed snippets.
filename(string, required)mime(string, required) - one ofimage/png,image/jpeg,image/webp,image/gif,image/avifdata_base64(string, required) - standard base64expiration(string) -1d|1w|1mo|burn|views|foreverview_limit(int 1-999) - required whenexpiration="views"password(string 1-256)folder(string) - slug of a folder you ownprefer(string) -"share"(default) or"direct", controls which URL is highlighted in the content summary
List images owned by this API key, paginated. Optionally filter by folder.
folder(string) - folder slug or"unsorted"limit(int 1-100, default 50)offset(int, default 0)
Get full metadata for a single image (owner only).
slug(string, required)
Delete an image (owner only). Irreversible.
List folders owned by this API key with image counts.
Create a new folder. Returns the kebab-case slug.
name(string, required) - 1-40 visible characters
Check your monthly API/MCP upload quota. Use this to decide whether to proceed or warn the user.
Claude Desktop configuration
imageupload.io ships a small stdio wrapper around the HTTP MCP endpoint (@imageupload/mcp) so Claude Desktop - which only speaks stdio MCP - can use it. Add this to your Claude Desktop config file:
macOS: ~/Library/Application Support/Claude/claude_desktop_config.json
Windows: %APPDATA%\\Claude\\claude_desktop_config.json
{
"mcpServers": {
"imageupload": {
"command": "npx",
"args": ["-y", "@imageupload/mcp"],
"env": {
"IMAGEUPLOAD_API_KEY": "iu_live_XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX"
}
}
}
}
Restart Claude Desktop. Your image-hosting tools will appear in the model's tool picker, and you can ask things like "Upload this screenshot to imageupload.io with delete after view and give me the share link."
Expiration modes
Every upload picks one of these expiration modes. They match exactly what the web uploader offers.
| Value | Meaning | Notes |
|---|---|---|
1d | Expires 24 hours after upload | Default if you don't specify one |
1w | Expires 7 days after upload | |
1mo | Expires 30 days after upload | Max for Free tier |
burn | Delete after view | The first viewer sees the image; every subsequent request returns 404. The image is permanently deleted. |
views | Expires after view_limit unique viewers | Unique = distinct browser fingerprint. Same viewer refreshing doesn't count as a new view. Capped at 30 days regardless of views. |
forever | No expiration (image stays until you delete it) | Pro only. Free-tier uploads have a 3-month maximum. |
Supported file types
Images are stripped of EXIF metadata on upload (including GPS coordinates). Max raw file size is 25 MB.
| MIME type | Extension |
|---|---|
image/png | .png |
image/jpeg | .jpg |
image/webp | .webp |
image/gif | .gif |
image/avif | .avif |
Folders
Folders are a convenience for organizing your gallery - flat buckets with kebab-case slugs. They don't affect storage quotas or expirations. Images uploaded without a folder land in "Default".
Create and list folders via the MCP tools create_folder and list_folders, then pass the returned slug to upload_image's folder argument (or the REST API's folder form field).
Max 200 folders per user. Names are 1-40 visible characters.