Credits and limits
What a check and an optimization cost, when credits come back and reset, each plan's rate limits and jobs at once, and the limits on images and videos.
Credits
Every check and optimization spends credits from your plan. The cost is the same over the API, the MCP server and the web app.
| Call | An image | A video, for every started 15 seconds |
|---|---|---|
Check, POST /audit | 5 | 5 |
Optimize, POST /optimize | 20 | 20 |
A video's credits go by its length at any frame rate, so a video that runs one second past a slice costs one more slice.
The web app gives 3 checks a month without an account, a video counting as one, and 50 credits with one. Optimizing needs an account.
Credits are reserved when a call starts, so checks and optimizations in flight already count against what you have left. Uploads, polls and downloads spend no credits.
When credits come back
- A call refused before it starts costs nothing. That covers a bad body or URL, a file over the size limit, and a refusal for your key, plan, credits or rate limits.
- A check that fails gives its credits back at once. So does an optimization the engine refuses on submit, such as one over the pixel limit.
- A check that answers with the verdict
unavailabledid not fail, so it is charged like any other. Handle an unavailable verdict says when that happens. - An optimization that fails while it runs gives its credits back once realpng sees it failed. A poll sees it, and so does the next call that finds your jobs at once full or your credits used up.
- An optimization that answers 503
tracking_failedwas never charged. - A video's credits are held for the
secondsyou send, or for the longest video without it. Once its job finishes, it is charged for its real length and the rest comes back.
When credits reset
Credits run in monthly periods. Unused credits do not carry over to the next one.
- Free resets at the start of each calendar month, in UTC.
- A paid plan resets on your billing day each month. A yearly plan also grants its credits a month at a time.
The resets_at field of a 402 gives the exact time.
Plans
| Plan | Credits a month | API requests a minute | API keys | Jobs at once |
|---|---|---|---|---|
| Free | 50 | No API access | None | 4 |
| Starter | 1,500 | 60 | 2 | 4 |
| Pro | 6,000 | 300 | 10 | 8 |
| Max | 25,000 | 600 | 25 | 16 |
Prices are on the pricing page.
Change your plan at /account#billing.
Rate limits
Your plan sets two limits: requests a minute and jobs at once. Going over either answers 429 with a Retry-After header.
Requests a minute
The limit is per account. Every API key on the account and the MCP server share one count.
Every request that carries a valid key counts. That means POST /audit, POST /optimize, each poll of GET /optimize/{id}, POST /uploads and every MCP message, listing the tools included. Downloading an optimized image needs no key and does not count. Neither does the PUT to an upload URL, or a request refused for its key or plan.
Upload URLs also have a limit of their own: 60 a minute per account, across the web app, the API and MCP. A POST /uploads or request_upload call counts toward both, and going over either answers 429 rate_limited.
An MCP tool that waits for an optimization, process_image or get_processing_result, counts one more request for each time it checks on the job while it waits. Over the limit it stops waiting and hands back the job ID to resume with.
The count runs per clock minute. Over the limit, a call answers 429 rate_limited, and Retry-After gives the seconds until the next minute starts. The web app has its own limit and does not use this count.
Jobs at once
This caps the checks and optimizations your account runs at the same time, across the web app, the API and the MCP server.
A check holds its slot until it answers. An optimization holds its slot until realpng sees it finish, by a poll or the download link. If you pass callback_url and never poll, the next call that finds your slots full frees the finished optimizations first.
Over the cap, a new check or optimization answers 429 too_many_jobs. Retry-After says how long to wait.
Image limits
| Limit | Value |
|---|---|
Size by image_url or upload | 25 MB |
Size as image_base64 | 4 MB, decoded |
| Pixels | 25 megapixels |
| Types | PNG, JPEG, WebP, single frame only |
image_base64 travels inside the request body, so its limit is lower. Send a larger file as an upload. POST /uploads returns the exact byte limit as max_bytes.
Pixels means width times height. An optimization over the pixel limit is refused on submit, never as a failed optimization. An optimization over 2048 px on its long edge comes back at 2048 px.
realpng reads the type from the image bytes, not from the URL or the file name. An animated image is refused.
Past a limit, a call answers 413 image_too_large, 413 too_many_pixels or 415 unsupported_media. Errors says what each code means and how to fix it.
Video limits
| Limit | Value |
|---|---|
| Length | 3 minutes and 5,400 frames |
Size by video_url or upload | 1 GB |
| Resolution | Read and returned at up to 1920 px on the long edge |
| Types | MP4, MOV, WebM, with a video stream |
| Voice clip types | MP3, WAV, M4A |
The frame limit stops a 60 fps video at 90 seconds. A video over a limit, or one that runs past the seconds you sent, fails with video_too_long, and its credits come back.
Send seconds when you know the video's length. Credits are held for it, and a video that runs longer is refused. Without it, credits for 3 minutes are held, so your plan needs that many free at the start. The job is then charged for its real length.
A voice clip takes the same length and size limits, and costs what a video check costs. Only as much of a clip as seconds covers is read, never refused. Check a voice clip covers it.
A video over a few megabytes is faster as an upload than by URL. video_url has to send the whole file within the request. Check and optimize a video covers the rest.
When credits run out
A check or optimization your remaining credits cannot cover answers 402 quota_exceeded. detail holds required, available and resets_at, as Errors describes.
Upgrade at /account#billing for more credits, or wait until resets_at.
Run many images
This script checks every image in a folder. It runs up to CONCURRENCY checks at once, and waits as long as Retry-After says on any 429 or 503 that sends it.
import { readdir, readFile } from "node:fs/promises";
import { extname, join } from "node:path";
const API = "https://realpng.com/api/v1";
const KEY = process.env.REALPNG_API_KEY;
// Your plan's jobs at once, Starter's here. More workers only wait on 429 too_many_jobs.
const CONCURRENCY = 4;
const TYPES = {
".png": "image/png",
".jpg": "image/jpeg",
".jpeg": "image/jpeg",
".webp": "image/webp",
};
const sleep = (seconds) => new Promise((wake) => setTimeout(wake, seconds * 1000));
// POSTs JSON, and waits and retries while the answer sends Retry-After (429 and some 503s).
async function post(path, body) {
for (;;) {
const res = await fetch(API + path, {
method: "POST",
headers: { "X-API-Key": KEY, "Content-Type": "application/json" },
body: JSON.stringify(body),
});
const wait = res.headers.get("Retry-After");
if (!wait) return res;
await res.body?.cancel();
await sleep(Number(wait));
}
}
// Uploads one file and checks it. Returns the verdict, or the error body the API sent.
async function check(file) {
const bytes = await readFile(file);
const type = TYPES[extname(file).toLowerCase()];
const res = await post("/uploads", { size: bytes.length, content_type: type });
const upload = await res.json();
if (!res.ok) return upload;
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);
// A 429 comes before the engine reads the upload, so a retry reuses the same id.
return (await post("/audit", { image_upload_id: upload.id })).json();
}
const dir = process.argv[2] ?? ".";
const files = (await readdir(dir)).filter((name) => TYPES[extname(name).toLowerCase()]);
let next = 0;
async function worker() {
while (next < files.length) {
const name = files[next++];
try {
const result = await check(join(dir, name));
if (result.verdict) console.log(name, result.verdict, result.score);
else console.log(name, result.code, result.message);
// Out of credits: every later check fails the same way, so start no new files.
if (result.code === "quota_exceeded") next = files.length;
} catch (error) {
console.log(name, error.message);
}
}
}
await Promise.all(Array.from({ length: CONCURRENCY }, worker));Run it with node check-folder.mjs ./images on Node.js 18 or later.
- Each image takes two requests, one for the upload URL and one for the check. Upload a local file explains both.
- Set
CONCURRENCYto your plan's jobs at once. It starts at Starter's. A higher number only waits on 429too_many_jobs. - A 429, or a 503 while the engine warms up, comes before the engine reads the upload. The retry reuses the same upload id.
- On
quota_exceededit starts no new files, since every later check would fail the same way.
For optimizations in bulk, pass callback_url instead of polling, since every poll counts as a request. See Get a webhook instead.