Upload a local file
Send any image from disk, including files too large for base64. Ask for a one-time upload URL, PUT the bytes, then check or optimize it by id.
Upload in three steps
Ask for an upload URL
Send the file's exact size in bytes and its content type. A file over the base64 limit works too, and so does a video up to the video limit, whose id you send as video_upload_id.
curl -X POST "https://realpng.com/api/v1/uploads" \
-H "X-API-Key: $REALPNG_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"size": 8804226,
"content_type": "image/png"
}'{
"id": "9b2e4c71-6d3a-4e8f-a1b5-3c7d0e9f2a48",
"upload_url": "https://uploads.example/uploads/user_2pQx7Lk9cV3mT8rW1yZ4nB6hJ0d/9b2e4c71-6d3a-4e8f-a1b5-3c7d0e9f2a48?X-Amz-Algorithm=AWS4-HMAC-SHA256&X-Amz-Content-Sha256=UNSIGNED-PAYLOAD&X-Amz-Credential=ASIAU6GDYLPAHEXAMPLE%2F20260925%2Feu-north-1%2Fs3%2Faws4_request&X-Amz-Date=20260925T101500Z&X-Amz-Expires=600&X-Amz-Security-Token=IQoJb3JpZ2luX2VjEPT%2F%2F%2F%2F%2F%2F%2F%2F%2F%2FwEaCmV1LW5vcnRoLTEiRzBFAiEA3kQ9v2x7bJ1cW8nR4tY6uE0aZ5sD2fG3hK%2BmL7pQ%3D&X-Amz-Signature=3f1c9a2e7b4d8f6051c2a9e3d7b6f4a8c1e5d9b2f7a3c6e0d4b8a1f5c9e2d7b3&X-Amz-SignedHeaders=content-length%3Bcontent-type%3Bhost%3Bif-none-match&x-id=PutObject",
"method": "PUT",
"headers": {
"Content-Type": "image/png",
"If-None-Match": "*"
},
"max_bytes": 26214400,
"expires_at": "2026-09-25T10:25:00.000Z"
}POST /uploads answers 201 with these fields:
| Field | What it holds |
|---|---|
id | The upload id. You send it as image_upload_id in the last step |
upload_url | Where to PUT the file. Treat it as opaque. Its host and query can change |
method | Always PUT |
headers | The headers to send with the PUT, exactly as given |
max_bytes | The largest file an upload takes |
expires_at | When upload_url stops working, as an ISO date and time |
The call needs an API key and counts toward your requests a minute and the 60 upload URLs an account gets a minute. It spends no credits. The check or optimization that reads the upload does.
PUT the file
Send the raw bytes to upload_url. Here upload is the body POST /uploads returned, which the previous step reads into data. The cURL tab reads upload_url from $UPLOAD_URL.
curl -T photo.png -H "Content-Type: image/png" -H "If-None-Match: *" "$UPLOAD_URL"Storage checks the PUT against what you asked for:
- Send every header in
headers.Content-Typemust equal thecontent_typeyou declared. - Send a
Content-Lengthequal tosize. A chunked body with no length is refused, so send the bytes or a file with a known length, never a stream. - Send the raw bytes, not base64 or a multipart form.
- Send no API key. The PUT goes to storage, not to the realpng API.
- Send it before
expires_at. - Send it once. Each URL takes one PUT, and the
If-None-Matchheader inheadersmakes storage refuse a second one.
A PUT that worked answers 200 with an empty body. Run it from a server, a script or the command line. Storage accepts browser PUTs only from realpng's own site, so a web page on another site cannot send it.
Send the id
Pass id as image_upload_id, in place of image_url or image_base64.
curl -X POST "https://realpng.com/api/v1/audit" \
-H "X-API-Key: $REALPNG_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"image_upload_id": "9b2e4c71-6d3a-4e8f-a1b5-3c7d0e9f2a48"
}'{
"verdict": "uncertain",
"score": 37,
"decided_by": "pixels",
"lanes": {
"pixels": {
"verdict": "uncertain",
"score": 37
},
"watermark": {
"verdict": "none",
"score": 5
},
"provenance": {
"verdict": "nothing",
"issuer": null
}
},
"media": "image"
}POST /optimize takes the same field. See Optimize an image.
Upload and check in one program
import { readFile } from "node:fs/promises";
const api = "https://realpng.com/api/v1";
const headers = { "X-API-Key": process.env.REALPNG_API_KEY, "Content-Type": "application/json" };
const bytes = await readFile("photo.png");
// 1. Ask for an upload URL for this exact size and type.
const res = await fetch(`${api}/uploads`, {
method: "POST",
headers,
body: JSON.stringify({ size: bytes.length, content_type: "image/png" }),
});
const upload = await res.json();
if (!res.ok) throw new Error(`${upload.code}: ${upload.message}`);
// 2. PUT the bytes to storage. A Buffer body sends an exact Content-Length.
const put = await fetch(upload.upload_url, {
method: upload.method,
headers: upload.headers,
body: bytes,
});
if (!put.ok) throw new Error(`Storage refused the upload with ${put.status}`);
// 3. Check the file by its upload id.
const check = await fetch(`${api}/audit`, {
method: "POST",
headers,
body: JSON.stringify({ image_upload_id: upload.id }),
});
console.log(await check.json());import os
import requests
API = "https://realpng.com/api/v1"
HEADERS = {"X-API-Key": os.environ["REALPNG_API_KEY"]}
with open("photo.png", "rb") as file:
image = file.read()
# 1. Ask for an upload URL for this exact size and type.
res = requests.post(
f"{API}/uploads",
headers=HEADERS,
json={"size": len(image), "content_type": "image/png"},
)
res.raise_for_status()
upload = res.json()
# 2. PUT the bytes to storage. A bytes body sends an exact Content-Length.
requests.put(upload["upload_url"], headers=upload["headers"], data=image).raise_for_status()
# 3. Check the file by its upload id.
check = requests.post(
f"{API}/audit",
headers=HEADERS,
json={"image_upload_id": upload["id"]},
)
print(check.json())Use each upload once
An upload is read once, by the first call that reaches the engine. A call refused before that leaves it usable. After that, even a failed call has used it, and a retry needs a new upload.
| The call answered | The upload |
|---|---|
400 for the body, the image field or callback_url | Still usable |
| 401, 402 or 429 | Still usable |
503 model_unavailable or optimizations_disabled | Still usable |
503 engine_unavailable | Usually usable. If the retry answers 404 upload_not_found, upload again |
| 200 from a check or 202 from an optimization | Used |
503 tracking_failed from an optimization | Used |
An error about the image itself, such as 413 too_many_pixels or 415 unsupported_media | Used |
404 upload_not_found | Used, expired or never uploaded |
To try again after the upload was used, call POST /uploads and PUT the file again. An optimization that fails later has used its upload too.
Any key on your account can send the id. Another account's id answers 404 upload_not_found.
Mind the expiry
upload_url works for 10 minutes after it is issued, until expires_at. After that, storage refuses the PUT and you need a new upload.
Storage also deletes uploads nobody used, so send the id soon after the PUT.
Upload from an agent
An agent connected to the MCP server follows the same steps:
- It calls
request_uploadwith the file'ssizeandcontent_type. The tool returns the same fields asPOST /uploads. - It PUTs the file to
upload_urlitself, usually withcurl -Tand every header fromheaders. - It passes
idasimage_upload_idtoaudit_imageorprocess_image.
The PUT needs a shell or an HTTP tool. An agent without one cannot upload, so give it a public image_url instead. The read once rule and the expiry apply as above, and MCP tools has the tool inputs.
Handle errors
POST /uploads refuses a bad request with a JSON error, listed in Errors.
The PUT answers from storage, not from realpng, so a refusal is not JSON. Check the status code and ignore the body.
A 403 usually means the length or type does not match what you declared, a header from headers is missing, or the URL expired. Fix the request, or ask for a new upload if expires_at has passed.
A 412 means a file was already PUT to that URL. Send the id you have, or ask for a new upload to send another file.
On the check or optimization, 404 upload_not_found means nothing was PUT to that id, it expired, or a call already used it. Start again from POST /uploads. A 400 invalid_image means image_upload_id is not a valid id.