realpng
Docs

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.

Create an upload
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"
  }'
201 Created
{
  "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:

FieldWhat it holds
idThe upload id. You send it as image_upload_id in the last step
upload_urlWhere to PUT the file. Treat it as opaque. Its host and query can change
methodAlways PUT
headersThe headers to send with the PUT, exactly as given
max_bytesThe largest file an upload takes
expires_atWhen 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-Type must equal the content_type you declared.
  • Send a Content-Length equal to size. 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-Match header in headers makes 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.

Check the uploaded file
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"
  }'
200 OK
{
  "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

Upload and check in JavaScript
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());
Upload and check in Python
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 answeredThe upload
400 for the body, the image field or callback_urlStill usable
401, 402 or 429Still usable
503 model_unavailable or optimizations_disabledStill usable
503 engine_unavailableUsually usable. If the retry answers 404 upload_not_found, upload again
200 from a check or 202 from an optimizationUsed
503 tracking_failed from an optimizationUsed
An error about the image itself, such as 413 too_many_pixels or 415 unsupported_mediaUsed
404 upload_not_foundUsed, 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:

  1. It calls request_upload with the file's size and content_type. The tool returns the same fields as POST /uploads.
  2. It PUTs the file to upload_url itself, usually with curl -T and every header from headers.
  3. It passes id as image_upload_id to audit_image or process_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.

Next steps