# 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 `unavailable` did not fail, so it is charged like any other. [Handle an unavailable verdict](/docs/guides/check-an-image#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](#when-credits-run-out) 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](/pricing).

Change your plan at [/account#billing](/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](/docs/guides/upload-a-local-file). `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](/docs/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](/docs/guides/check-a-voice-clip) covers it.

A video over a few megabytes is faster as an [upload](/docs/guides/upload-a-local-file) than by URL. `video_url` has to send the whole file within the request. [Check and optimize a video](/docs/guides/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](/docs/errors#quota_exceeded) describes.

Upgrade at [/account#billing](/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.

```javascript title="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](/docs/guides/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](/docs/guides/optimize-an-image#get-a-webhook-instead).

## Next steps

- [Authentication](/docs/authentication): Send your key, and see how many keys each plan holds.

- [Errors](/docs/errors): Every error code, what it means and how to fix it.

- [Upload a local file](/docs/guides/upload-a-local-file): Send an image from disk, including files too large for base64.

- [Optimize an image](/docs/guides/optimize-an-image): Start an optimization, then poll or get a signed webhook.
