realpng
Docs

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.

CallAn imageA video, for every started 15 seconds
Check, POST /audit55
Optimize, POST /optimize2020

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 unavailable did 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_failed was never charged.
  • A video's credits are held for the seconds you 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

PlanCredits a monthAPI requests a minuteAPI keysJobs at once
Free50No API accessNone4
Starter1,5006024
Pro6,000300108
Max25,0006002516

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

LimitValue
Size by image_url or upload25 MB
Size as image_base644 MB, decoded
Pixels25 megapixels
TypesPNG, 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

LimitValue
Length3 minutes and 5,400 frames
Size by video_url or upload1 GB
ResolutionRead and returned at up to 1920 px on the long edge
TypesMP4, MOV, WebM, with a video stream
Voice clip typesMP3, 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.

check-folder.mjs
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 CONCURRENCY to your plan's jobs at once. It starts at Starter's. A higher number only waits on 429 too_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_exceeded it 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.

Next steps