# Optimize an image

Start an optimization, follow the job by polling or with a signed webhook, and download the optimized image with its optimization score.

## How an optimization runs

An optimization is a job. `POST /optimize` answers at once, and the job moves through these statuses.

| Status      | Meaning                                                   | What moves it on                               |
| ----------- | --------------------------------------------------------- | ---------------------------------------------- |
| `queued`    | Accepted and waiting for a worker.                        | A worker picks it up.                          |
| `running`   | A worker is optimizing the image.                         | The optimization finishes, or something fails. |
| `succeeded` | The optimized image and its optimization score are ready. | Nothing. It is final.                          |
| `failed`    | The optimization stopped. `error` says why.               | Nothing. It is final.                          |

- An optimization usually takes a couple of minutes. A running job reports no progress beyond its status.
- A job only moves forward. One still `queued` or `running` past its deadline fails with `job_timeout`. A job cannot be canceled.
- A job is kept for 24 hours from when you started it. After that, `GET /optimize/{id}` and the download link answer 404.

## Start an optimization

**Start an optimization**

```bash title="cURL"
curl -X POST "https://realpng.com/api/v1/optimize" \
  -H "X-API-Key: $REALPNG_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "image_url": "https://images.example.com/portrait.jpg"
  }'
```

```javascript title="JavaScript"
const res = await fetch("https://realpng.com/api/v1/optimize", {
  method: "POST",
  headers: {
    "X-API-Key": process.env.REALPNG_API_KEY,
    "Content-Type": "application/json",
  },
  body: JSON.stringify({
    "image_url": "https://images.example.com/portrait.jpg"
  }),
});
const data = await res.json();
```

```python title="Python"
import os

import requests

res = requests.post(
    "https://realpng.com/api/v1/optimize",
    headers={"X-API-Key": os.environ["REALPNG_API_KEY"]},
    json={
        "image_url": "https://images.example.com/portrait.jpg",
    },
)
data = res.json()
```

Response `202 Accepted`

```json
{
  "id": "7c9e6679-7425-40de-944b-e07fc1f90ae7",
  "status": "queued",
  "media": "image"
}
```

`POST /optimize` answers 202 at once with the job's `id`, `status: "queued"` and `media: "image"`, and nothing else. It takes the image the same three ways as a [check](/docs/guides/check-an-image): `image_url`, `image_base64` or `image_upload_id`, the last for a [file on disk](/docs/guides/upload-a-local-file).

An optimization accepts [fewer pixels](/docs/credits-and-limits#image-limits) than a check. A larger image is refused at once with 413 `too_many_pixels`, never as a failed job. What an optimization costs is on [Credits and limits](/docs/credits-and-limits#credits).

`POST /optimize` refuses a bad call with the same errors as a check. It adds 400 `invalid_url` or `invalid_callback` for a bad `callback_url`, 503 `optimizations_disabled` while optimizing is paused, and 503 `tracking_failed` when the optimization could not be recorded. [Errors](/docs/errors) has the fix for each.

## Poll for the result

**Get an optimization**

```bash title="cURL"
curl "https://realpng.com/api/v1/optimize/7c9e6679-7425-40de-944b-e07fc1f90ae7" \
  -H "X-API-Key: $REALPNG_API_KEY"
```

```javascript title="JavaScript"
const res = await fetch("https://realpng.com/api/v1/optimize/7c9e6679-7425-40de-944b-e07fc1f90ae7", {
  headers: {
    "X-API-Key": process.env.REALPNG_API_KEY,
  },
});
const data = await res.json();
```

```python title="Python"
import os

import requests

res = requests.get(
    "https://realpng.com/api/v1/optimize/7c9e6679-7425-40de-944b-e07fc1f90ae7",
    headers={"X-API-Key": os.environ["REALPNG_API_KEY"]},
)
data = res.json()
```

Response `200 OK`

```json
{
  "id": "7c9e6679-7425-40de-944b-e07fc1f90ae7",
  "status": "succeeded",
  "media": "image",
  "image_url": "https://realpng.com/api/v1/optimize/7c9e6679-7425-40de-944b-e07fc1f90ae7/image",
  "file_url": "https://realpng.com/api/v1/optimize/7c9e6679-7425-40de-944b-e07fc1f90ae7/file",
  "expires_at": "2026-09-26T10:15:03+00:00",
  "optimization_score": {
    "score": 92,
    "zone": "high"
  }
}
```

`GET /optimize/{id}` returns the job as it is now. Poll it every few seconds until `status` is `succeeded` or `failed`.

Each poll counts toward your plan's [requests a minute](/docs/credits-and-limits#requests-a-minute), so polling faster only reaches a 429 sooner. On 429 `rate_limited`, wait the seconds in `Retry-After`, then poll again.

What the body holds depends on the status. A `queued` or `running` job has only `{ id, status, media }`:

```json
{ "id": "7c9e6679-7425-40de-944b-e07fc1f90ae7", "status": "running", "media": "image" }
```

A `failed` job adds `error`, with no download link or `expires_at`:

```json
{
  "id": "7c9e6679-7425-40de-944b-e07fc1f90ae7",
  "status": "failed",
  "media": "image",
  "error": { "code": "job_timeout", "message": "This took too long. Try again." }
}
```

A `succeeded` job has every field:

| Field                | Description                                                                                                       |
| -------------------- | ----------------------------------------------------------------------------------------------------------------- |
| `id`                 | The job ID.                                                                                                       |
| `status`             | `succeeded`.                                                                                                      |
| `media`              | `image`, or `video` for a [video](/docs/guides/check-and-optimize-a-video).                                       |
| `image_url`          | The download link. It needs no API key. See [Download the optimized image](#download-the-optimized-image).        |
| `file_url`           | The same file at the link that serves images and videos alike.                                                    |
| `expires_at`         | When the job and its download link expire. An ISO 8601 date and time with a `+00:00` offset.                      |
| `optimization_score` | 100 minus our detectors' AI reading of the file. See [Read the optimization score](#read-the-optimization-score). |

An unknown ID or an ID from another account answers 404 `not_found`, and an expired job answers 404 `not_found` or `unknown_job`. Treat any 404 as gone.

### Poll in a loop

Both programs start an optimization, wait between polls, and honor `Retry-After` on a 429.

```javascript title="Poll an optimization in JavaScript"
const api = "https://realpng.com/api/v1";
const headers = { "X-API-Key": process.env.REALPNG_API_KEY };
const sleep = (seconds) => new Promise((wake) => setTimeout(wake, seconds * 1000));

const start = await fetch(`${api}/optimize`, {
  method: "POST",
  headers: { ...headers, "Content-Type": "application/json" },
  body: JSON.stringify({ image_url: "https://images.example.com/portrait.jpg" }),
});
let job = await start.json();
if (!start.ok) throw new Error(`${job.code}: ${job.message}`);

while (job.status === "queued" || job.status === "running") {
  await sleep(5);
  const res = await fetch(`${api}/optimize/${job.id}`, { headers });
  if (res.status === 429) {
    // Over the plan's requests a minute, so wait as long as Retry-After says.
    await sleep(Number(res.headers.get("Retry-After")) || 5);
    continue;
  }
  job = await res.json();
  if (!res.ok) throw new Error(`${job.code}: ${job.message}`);
}

if (job.status === "succeeded") console.log(job.image_url, job.optimization_score.zone);
else console.error(job.error.code, job.error.message);
```

```python title="Poll an optimization in Python"
import os
import time

import requests

API = "https://realpng.com/api/v1"
HEADERS = {"X-API-Key": os.environ["REALPNG_API_KEY"]}

res = requests.post(
    f"{API}/optimize",
    headers=HEADERS,
    json={"image_url": "https://images.example.com/portrait.jpg"},
)
res.raise_for_status()
job = res.json()

while job["status"] in ("queued", "running"):
    time.sleep(5)
    res = requests.get(f"{API}/optimize/{job['id']}", headers=HEADERS)
    if res.status_code == 429:
        # Over the plan's requests a minute, so wait as long as Retry-After says.
        time.sleep(int(res.headers.get("Retry-After", 5)))
        continue
    res.raise_for_status()
    job = res.json()

if job["status"] == "succeeded":
    print(job["image_url"], job["optimization_score"]["zone"])
else:
    print(job["error"]["code"], job["error"]["message"])
```

### When an optimization fails

A failed job still answers `GET /optimize/{id}` with 200. Its `error` has a `code` and a `message`, and [Errors](/docs/errors#errors-in-a-failed-optimization) lists every code it can carry. Branch on `code`, because messages can change.

To try again, start a new optimization. If you sent `image_upload_id`, [upload the file again](/docs/guides/upload-a-local-file#use-each-upload-once) first, since the first upload was already read. What a failed optimization costs is on [Credits and limits](/docs/credits-and-limits#when-credits-come-back).

## Get a webhook instead

**Start an optimization with a webhook**

```bash title="cURL"
curl -X POST "https://realpng.com/api/v1/optimize" \
  -H "X-API-Key: $REALPNG_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "image_url": "https://images.example.com/portrait.jpg",
    "callback_url": "https://example.com/webhooks/realpng"
  }'
```

```javascript title="JavaScript"
const res = await fetch("https://realpng.com/api/v1/optimize", {
  method: "POST",
  headers: {
    "X-API-Key": process.env.REALPNG_API_KEY,
    "Content-Type": "application/json",
  },
  body: JSON.stringify({
    "image_url": "https://images.example.com/portrait.jpg",
    "callback_url": "https://example.com/webhooks/realpng"
  }),
});
const data = await res.json();
```

```python title="Python"
import os

import requests

res = requests.post(
    "https://realpng.com/api/v1/optimize",
    headers={"X-API-Key": os.environ["REALPNG_API_KEY"]},
    json={
        "image_url": "https://images.example.com/portrait.jpg",
        "callback_url": "https://example.com/webhooks/realpng",
    },
)
data = res.json()
```

Pass `callback_url` and realpng POSTs the finished job to it when the job ends as `succeeded` or `failed`. `POST /optimize` still answers 202 with the job, so you can poll as well.

`callback_url` must be a public https URL on the default port. An http URL, another port, a user name or password in the URL, or a host that resolves to a private address answers 400. `localhost` is refused too, so in development expose your receiver through a public tunnel.

The URL is checked before the image is read, so a refused `callback_url` spends no credits and no upload.

### What the webhook sends

```http
POST /webhooks/realpng HTTP/1.1
Content-Type: application/json
RealPNG-Signature: sha256=dd4b1949467981f71cf71afea6162b065ad85e59b62c4563edfb9984f695a540
```

The body is the job as `GET /optimize/{id}` answers once it is `succeeded` or `failed`. [Poll for the result](#poll-for-the-result) shows both.

The docs show bodies formatted for reading. The real body arrives on one line with no space between JSON items, and the signature covers those exact bytes.

### Verify the signature

`RealPNG-Signature` is `sha256=` followed by the lowercase hex HMAC SHA-256 of the raw request body. The HMAC key is the signing secret of the API key that started the optimization.

Use the whole signing secret as UTF-8 bytes, `whsec_` prefix included. Do not base64 decode the part after the prefix, as you would a Svix secret.

Each API key has its own signing secret, and [Authentication](/docs/authentication#signing-secret) says where to find it. If your optimizations come from several keys, give each key its own `callback_url` so you know which secret to verify with.

> [!WARNING]
> Compute the HMAC over the raw request body before any JSON parsing. A body parsed and serialized
> again has different bytes and never matches.

Compare the signatures in constant time, as both programs do.

```javascript title="Verify a webhook in JavaScript"
// A Next.js route handler. Any runtime with web Request and Response works the same way.
import { createHmac, timingSafeEqual } from "node:crypto";

// The signing secret of the API key that started the optimization, used as is.
const secret = process.env.REALPNG_SIGNING_SECRET;

export async function POST(request) {
  // Sign the raw bytes. JSON parsed and serialized again would not match.
  const body = Buffer.from(await request.arrayBuffer());
  const digest = createHmac("sha256", secret).update(body).digest("hex");
  const expected = Buffer.from(`sha256=${digest}`);
  const given = Buffer.from(request.headers.get("RealPNG-Signature") ?? "");
  if (given.length !== expected.length || !timingSafeEqual(given, expected)) {
    return new Response("Invalid signature", { status: 401 });
  }
  const job = JSON.parse(body.toString());
  // Handle each job.id once. A second request for the same id changes nothing.
  console.log(job.id, job.status);
  return new Response(null, { status: 204 });
}
```

```python title="Verify a webhook in Python"
import hashlib
import hmac
import os

from flask import Flask, abort, request

app = Flask(__name__)
# The signing secret of the API key that started the optimization, used as is.
SECRET = os.environ["REALPNG_SIGNING_SECRET"].encode()


@app.post("/webhooks/realpng")
def realpng_webhook():
    # Sign the raw bytes. JSON parsed and serialized again would not match.
    body = request.get_data()
    expected = "sha256=" + hmac.new(SECRET, body, hashlib.sha256).hexdigest()
    given = request.headers.get("RealPNG-Signature", "")
    if not hmac.compare_digest(expected.encode(), given.encode()):
        abort(401)
    job = request.get_json()
    # Handle each job["id"] once. A second request for the same id changes nothing.
    print(job["id"], job["status"])
    return "", 204
```

To test your code, sign the succeeded and the failed body in [Poll for the result](#poll-for-the-result) with this sample secret, which is not a real one. Write each body on one line with its fields in the order shown and no space between JSON items, as realpng sends it.

| Input                    | Value                                                                     |
| ------------------------ | ------------------------------------------------------------------------- |
| Sample signing secret    | `whsec_UmVhbHBuZ1NpZ25pbmdTZWNyZXREZW1v`                                  |
| Succeeded body signature | `sha256=dd4b1949467981f71cf71afea6162b065ad85e59b62c4563edfb9984f695a540` |
| Failed body signature    | `sha256=d1c25ef6efe7c7108a7982588483b9a0275b72a85443612bcd64ff244583d7c9` |

The signature covers no timestamp, so a captured request can be sent again. Handle each job `id` once and ignore a second webhook for it.

### Delivery

- Each webhook is sent once and never retried.
- realpng waits only a few seconds for your answer. Answer quickly and do slow work afterward.
- Your response status is ignored.
- Redirects are not followed. A receiver that redirects, from http to https or to add a trailing slash, never gets the webhook.
- No webhook is sent for a job that fails by running past its deadline, that could not be handed to a worker, or whose worker stops.
- No webhook is sent once the key that started the job is revoked or its account is deleted.

> [!NOTE]
> A webhook is not guaranteed to arrive. If none has come, poll `GET /optimize/{id}`. Polling after
> a webhook is fine and costs nothing extra.

Over MCP there is no webhook. `process_image` waits for the optimization instead, as [MCP tools](/docs/mcp/tools) describes.

## Download the optimized image

**Download the optimized image**

```bash title="cURL"
curl -L -o optimized.png "https://realpng.com/api/v1/optimize/7c9e6679-7425-40de-944b-e07fc1f90ae7/image"
```

```javascript title="JavaScript"
const res = await fetch("https://realpng.com/api/v1/optimize/7c9e6679-7425-40de-944b-e07fc1f90ae7/image");
const image = await res.blob();
```

```python title="Python"
import requests

res = requests.get("https://realpng.com/api/v1/optimize/7c9e6679-7425-40de-944b-e07fc1f90ae7/image")
with open("optimized.png", "wb") as file:
    file.write(res.content)
```

```http
HTTP/1.1 200 OK
```

`image_url` needs no API key. The job ID in it is the only credential, so you can pass the link on. It answers 200 with the PNG as an attachment. `file_url` answers the same.

The link works until `expires_at`, 24 hours after the optimization started, not after it finished. Before the optimization succeeds and after it expires, it answers 404.

The optimized file is always a PNG with the same width and height as the original as it displays, unless it was [scaled down to fit](/docs/credits-and-limits#image-limits). Any EXIF rotation is applied, so a rotated photo comes back upright. It is a new file that does not keep the original's metadata, and an image with transparency comes back without it.

## Read the optimization score

`optimization_score` is 100 minus our detectors' AI reading of the optimized file, from 0 to 100. Its `zone` is the [check's band](/docs/guides/check-an-image) of that reading, turned around.

| `zone` | The detectors read the optimized file as | Score    |
| ------ | ---------------------------------------- | -------- |
| `high` | Real, an AI reading below 30             | Above 70 |
| `good` | Unsure, an AI reading from 30 to 43      | 57 to 70 |
| `low`  | AI, an AI reading above 43               | Below 57 |

Branch on `zone`. It is our detectors' reading today, not a promise about how any platform or tool will treat the image.

## Next steps

- [Check an image](/docs/guides/check-an-image): Read an image for AI, a separate call with its own verdict.

- [Upload a local file](/docs/guides/upload-a-local-file): Optimize a file from your disk by its upload id.

- [Errors](/docs/errors): Every error code, including those of a failed optimization.

- [API reference](/docs/api-reference/start-optimization): Every field of the optimization endpoints.
