# 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

### 1. Ask for an upload URL

Send the file's exact size in bytes and its content type. A file over the [base64 limit](/docs/credits-and-limits#image-limits) works too, and so does a video up to the [video limit](/docs/credits-and-limits#video-limits), whose id you send as `video_upload_id`.

**Create an upload**

```bash title="cURL"
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"
  }'
```

```javascript title="JavaScript"
const res = await fetch("https://realpng.com/api/v1/uploads", {
  method: "POST",
  headers: {
    "X-API-Key": process.env.REALPNG_API_KEY,
    "Content-Type": "application/json",
  },
  body: JSON.stringify({
    "size": 8804226,
    "content_type": "image/png"
  }),
});
const data = await res.json();
```

```python title="Python"
import os

import requests

res = requests.post(
    "https://realpng.com/api/v1/uploads",
    headers={"X-API-Key": os.environ["REALPNG_API_KEY"]},
    json={
        "size": 8804226,
        "content_type": "image/png",
    },
)
data = res.json()
```

Response `201 Created`

```json
{
  "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](/docs/authentication) and counts toward your [requests a minute](/docs/credits-and-limits#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.

### 2. 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`.

```bash title="cURL"
curl -T photo.png -H "Content-Type: image/png" -H "If-None-Match: *" "$UPLOAD_URL"
```

```javascript title="JavaScript"
import { readFile } from "node:fs/promises";

const bytes = await readFile("photo.png");
await fetch(data.upload_url, {
  method: data.method,
  headers: data.headers,
  body: bytes,
});
```

```python title="Python"
import requests

with open("photo.png", "rb") as file:
    requests.put(data["upload_url"], headers=data["headers"], data=file.read())
```

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.

### 3. Send the id

Pass `id` as `image_upload_id`, in place of `image_url` or `image_base64`.

**Check the uploaded file**

```bash title="cURL"
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"
  }'
```

```javascript title="JavaScript"
const res = await fetch("https://realpng.com/api/v1/audit", {
  method: "POST",
  headers: {
    "X-API-Key": process.env.REALPNG_API_KEY,
    "Content-Type": "application/json",
  },
  body: JSON.stringify({
    "image_upload_id": "9b2e4c71-6d3a-4e8f-a1b5-3c7d0e9f2a48"
  }),
});
const data = await res.json();
```

```python title="Python"
import os

import requests

res = requests.post(
    "https://realpng.com/api/v1/audit",
    headers={"X-API-Key": os.environ["REALPNG_API_KEY"]},
    json={
        "image_upload_id": "9b2e4c71-6d3a-4e8f-a1b5-3c7d0e9f2a48",
    },
)
data = res.json()
```

Response `200 OK`

```json
{
  "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](/docs/guides/optimize-an-image).

## Upload and check in one program

```javascript title="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());
```

```python title="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

> [!WARNING]
> 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](/docs/mcp) 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](/docs/mcp/tools) has the tool inputs.

## Handle errors

`POST /uploads` refuses a bad request with a JSON error, listed in [Errors](/docs/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

- [Check an image](/docs/guides/check-an-image): Read the verdict, the score and the three lanes.

- [Optimize an image](/docs/guides/optimize-an-image): Start an optimization and follow the job to the optimized image.

- [Credits and limits](/docs/credits-and-limits): Size limits, image types and requests a minute.

- [API reference](/docs/api-reference/create-upload): Every field of POST /uploads.
