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
queuedorrunningpast its deadline fails withjob_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
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"
}'{
"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: image_url, image_base64 or image_upload_id, the last for a file on disk.
An optimization accepts fewer pixels 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.
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 has the fix for each.
Poll for the result
curl "https://realpng.com/api/v1/optimize/7c9e6679-7425-40de-944b-e07fc1f90ae7" \
-H "X-API-Key: $REALPNG_API_KEY"{
"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, 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 }:
{ "id": "7c9e6679-7425-40de-944b-e07fc1f90ae7", "status": "running", "media": "image" }A failed job adds error, with no download link or expires_at:
{
"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. |
image_url | The download link. It needs no API key. See 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. |
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.
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);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 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 first, since the first upload was already read. What a failed optimization costs is on Credits and limits.
Get a webhook instead
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"
}'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
POST /webhooks/realpng HTTP/1.1
Content-Type: application/json
RealPNG-Signature: sha256=dd4b1949467981f71cf71afea6162b065ad85e59b62c4563edfb9984f695a540The body is the job as GET /optimize/{id} answers once it is succeeded or failed. 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 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.
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.
// 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 });
}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 "", 204To test your code, sign the succeeded and the failed body in 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.
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 describes.
Download the optimized image
curl -L -o optimized.png "https://realpng.com/api/v1/optimize/7c9e6679-7425-40de-944b-e07fc1f90ae7/image"HTTP/1.1 200 OKimage_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. 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 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.