# Check an image

Send an image by URL, as base64 or as an upload, and read the verdict, the score and the three lanes behind it.

## Send an image by URL

**Check an image by URL**

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

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

```python title="Python"
import os

import requests

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

Response `200 OK`

```json
{
  "verdict": "ai",
  "score": 91,
  "decided_by": "pixels",
  "lanes": {
    "pixels": {
      "verdict": "ai",
      "score": 91
    },
    "watermark": {
      "verdict": "none",
      "score": 3
    },
    "provenance": {
      "verdict": "nothing",
      "issuer": null
    }
  },
  "media": "image"
}
```

realpng fetches the URL itself, so it must be a public http or https address. Redirects are followed. A private address, a host that is slow to answer or a URL that answers with an error comes back as a 400, listed in [errors](/docs/errors).

Each call sends exactly one of `image_url`, `image_base64` or `image_upload_id`. It needs an [API key](/docs/authentication) and spends [credits](/docs/credits-and-limits#credits) from your plan.

## Send a small file as base64

**Check a small file as base64**

```bash title="cURL"
curl -X POST "https://realpng.com/api/v1/audit" \
  -H "X-API-Key: $REALPNG_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "image_base64": "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAIAAAACCAIAAAD91JpzAAAAF0lEQVR4nGM4UREQteASg0bUtGd3TgAALTYG/9S1V3UAAAAASUVORK5CYII="
  }'
```

```javascript title="JavaScript"
const res = await fetch("https://realpng.com/api/v1/audit", {
  method: "POST",
  headers: {
    "X-API-Key": process.env.REALPNG_API_KEY,
    "Content-Type": "application/json",
  },
  body: JSON.stringify({
    "image_base64": "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAIAAAACCAIAAAD91JpzAAAAF0lEQVR4nGM4UREQteASg0bUtGd3TgAALTYG/9S1V3UAAAAASUVORK5CYII="
  }),
});
const data = await res.json();
```

```python title="Python"
import os

import requests

res = requests.post(
    "https://realpng.com/api/v1/audit",
    headers={"X-API-Key": os.environ["REALPNG_API_KEY"]},
    json={
        "image_base64": "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAIAAAACCAIAAAD91JpzAAAAF0lEQVR4nGM4UREQteASg0bUtGd3TgAALTYG/9S1V3UAAAAASUVORK5CYII=",
    },
)
data = res.json()
```

Send the bytes as plain base64 or as a data URL. The `data:` prefix is removed before decoding.

Base64 travels inside the request, so its size limit is [lower](/docs/credits-and-limits#image-limits) than for a URL or an upload. Send anything bigger as an [upload](/docs/guides/upload-a-local-file).

## Send an uploaded file

**Check an uploaded file**

```bash title="cURL"
curl -X POST "https://realpng.com/api/v1/audit" \
  -H "X-API-Key: $REALPNG_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "image_upload_id": "5d1f7a3c-2b8e-4f6a-9c0d-8e3b1a7f4c62"
  }'
```

```javascript title="JavaScript"
const res = await fetch("https://realpng.com/api/v1/audit", {
  method: "POST",
  headers: {
    "X-API-Key": process.env.REALPNG_API_KEY,
    "Content-Type": "application/json",
  },
  body: JSON.stringify({
    "image_upload_id": "5d1f7a3c-2b8e-4f6a-9c0d-8e3b1a7f4c62"
  }),
});
const data = await res.json();
```

```python title="Python"
import os

import requests

res = requests.post(
    "https://realpng.com/api/v1/audit",
    headers={"X-API-Key": os.environ["REALPNG_API_KEY"]},
    json={
        "image_upload_id": "5d1f7a3c-2b8e-4f6a-9c0d-8e3b1a7f4c62",
    },
)
data = res.json()
```

Any file on disk can go through `POST /uploads` first. You PUT the bytes to a one-time URL, then send its id as `image_upload_id`. [Upload a local file](/docs/guides/upload-a-local-file) covers the full flow, including what a retry needs.

## Read the verdict

A 200 carries these fields. Agents get the same body from the `audit_image` tool. See [MCP tools](/docs/mcp/tools).

| Field        | Type            | What it holds                                                                          |
| ------------ | --------------- | -------------------------------------------------------------------------------------- |
| `verdict`    | string          | `ai`, `real`, `uncertain` or `unavailable`. The field to act on                        |
| `score`      | integer or null | 0 to 100, higher reads more like AI. Null only when the verdict is `unavailable`       |
| `decided_by` | string          | Which evidence set the verdict                                                         |
| `lanes`      | object          | What each lane found on its own: `pixels`, `watermark` and `provenance`                |
| `media`      | string          | `image`, or `video` for a [video](/docs/guides/check-and-optimize-a-video)             |
| `detail`     | object          | Only with `detail=full`. See [get the full engine report](#get-the-full-engine-report) |

### Verdict

| Value         | Meaning                                                             | What to do                                                                 |
| ------------- | ------------------------------------------------------------------- | -------------------------------------------------------------------------- |
| `ai`          | The image reads as AI generated, or carries proof that it is        | Handle it as AI generated                                                  |
| `real`        | The image reads as a photograph, or carries a signed camera capture | Handle it as real, within [the limits](#know-what-a-check-cannot-tell-you) |
| `uncertain`   | The evidence sits in between, or proof points both ways             | Have a person look, or treat it as unknown                                 |
| `unavailable` | No lane found proof and the pixel lane could not read the image     | [Retry later](#handle-an-unavailable-verdict)                              |

### Score

The score orders images by how strongly they read as AI, from 0 to 100. Use it to sort or rank, such as reviewing the highest first.

Branch on `verdict`, not on the score. The cut points between `real`, `uncertain` and `ai` are internal and may change, so a threshold you pick today can drift. When proof decides, the score is pinned to a set value whatever the pixels said, as the next table shows.

### What decided it

| `decided_by` | When                                                                            | Verdict and score                                                             |
| ------------ | ------------------------------------------------------------------------------- | ----------------------------------------------------------------------------- |
| `watermark`  | A SynthID watermark was found                                                   | `ai`, score pinned near 100                                                   |
| `provenance` | A valid signed C2PA manifest was found                                          | `ai` near 100 for `signed_ai`, `real` near 0 for `signed_capture`             |
| `pixels`     | No proof and nothing to weigh. Also the value when the verdict is `unavailable` | The pixel lane's own verdict and score                                        |
| `weighed`    | No proof, but an unsigned AI label or a faint watermark raised the pixel score  | The raised score, which never reaches either end, and the verdict it falls in |
| `conflict`   | AI proof and camera proof at once, such as a watermark on a signed capture      | `uncertain`, score in the middle                                              |

A watermark decides even when the pixels are unsure:

```json
{
  "verdict": "ai",
  "score": 99,
  "decided_by": "watermark",
  "lanes": {
    "pixels": { "verdict": "uncertain", "score": 38 },
    "watermark": { "verdict": "present", "score": 96 },
    "provenance": { "verdict": "nothing", "issuer": null }
  }
}
```

## Read the lanes

Each lane keeps its own vocabulary, so the watermark lane reads `present` or `none`, never `ai` or `real`.

### Pixels

A panel of trained pixel detectors reads the image. The lane reads pixels only and knows nothing about who made the file.

It averages the detectors on several overlapping crops and takes the median crop, so a caption or sticker over one part does not decide the verdict. An image too small to crop is read whole.

| `verdict`     | Meaning                                                   |
| ------------- | --------------------------------------------------------- |
| `ai`          | The pixels read as AI generated                           |
| `real`        | The pixels read as a photograph                           |
| `uncertain`   | The pixels sit in between                                 |
| `unavailable` | Too few detectors returned a reading, and `score` is null |

`score` is 0 to 100, or null.

**What it cannot see.** Detection is measured on original files. An ordinary JPEG re-encode makes it markedly weaker, so screenshots, social media copies and recompressed files are caught less often. Generators the panel was not tuned on are caught less reliably.

### Watermark

The lane looks for SynthID, the invisible watermark Google embeds in images from its generators.

| `verdict`     | Meaning                                                        |
| ------------- | -------------------------------------------------------------- |
| `present`     | SynthID was found. This is proof of AI and decides the verdict |
| `faint`       | A weak trace. It raises the score but never decides on its own |
| `none`        | No SynthID was found. This does not mean the image is real     |
| `unavailable` | The watermark model did not run, and `score` is null           |

`score` is the watermark model's own reading, 0 to 100, or null.

**What it cannot see.** Any watermark other than SynthID. An image from a generator that does not embed SynthID reads `none`.

### Provenance

The lane reads the file's metadata, not its pixels. It uses no model and always answers, so it has no `unavailable` value.

| `verdict`        | Meaning                                                                                                                      | `issuer`                     |
| ---------------- | ---------------------------------------------------------------------------------------------------------------------------- | ---------------------------- |
| `signed_ai`      | A C2PA manifest that validates, from a known AI issuer or declaring an AI source. Proof of AI                                | The signer, such as `OpenAI` |
| `signed_capture` | A C2PA manifest that validates and records a digital capture, such as from a signing camera. Proof of a real capture         | The signer                   |
| `ai_label`       | An unsigned AI label: an AI digital source type in IPTC XMP, an unverified C2PA AI action, or generator settings in PNG text | null                         |
| `nothing`        | None of the above                                                                                                            | null                         |

An `ai_label` raises the score but never decides on its own. Anyone can add or remove unsigned metadata, so only a signature counts as proof. Stray bytes that happen to spell a generator's name are ignored.

**What it cannot see.** Most re-encodes, screenshots and social platforms strip metadata, so `nothing` is common and says nothing either way. A missing signature is not evidence of AI.

## Handle an unavailable verdict

The verdict is `unavailable` when no lane found proof and the pixel lane could not read the image. With a watermark `present` or a signed manifest, the verdict is still decided even if the pixel lane is unavailable.

```json
{
  "verdict": "unavailable",
  "score": null,
  "decided_by": "pixels",
  "lanes": {
    "pixels": { "verdict": "unavailable", "score": null },
    "watermark": { "verdict": "none", "score": 4 },
    "provenance": { "verdict": "nothing", "issuer": null }
  }
}
```

It is a normal 200, not an error, and it is [charged as a completed check](/docs/credits-and-limits#when-credits-come-back). Retry the same image later. The [full engine report](#get-the-full-engine-report) names the detector that dropped out.

The watermark lane can also answer `unavailable` on its own. The verdict then comes from the other lanes as usual.

An engine that is still warming up is different. It answers 503 before any lane runs, as described in [errors](/docs/errors).

## Get the full engine report

**Check with the full engine report**

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

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

```python title="Python"
import os

import requests

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

Add `detail=full` to the query to get `detail` beside the usual fields. It holds the raw engine report: each detector's own reading, the evidence per lane, the decoded image's size and format, and how long the engine took (`engine_ms`).

> [!WARNING]
> The shape of `detail` can change without notice. Read `verdict`, `score`, `decided_by` and `lanes`
> in code, and keep `detail` for debugging and support.

## Know what a check cannot tell you

- Only `watermark`, `provenance` and `conflict` in `decided_by` rest on proof. A `pixels` or `weighed` verdict is a statistical reading and can be wrong.
- A `real` verdict from the pixels means the image does not read as AI. It does not prove a camera took it.
- Each lane has blind spots of its own. Recompressed copies weaken the pixels, the watermark lane sees SynthID only, and most platforms strip metadata. [Read the lanes](#read-the-lanes) has the detail.

## Handle errors

Every error comes back as JSON with a stable `code`. [Errors](/docs/errors) lists each one with its status and fix.

## Next steps

- [Optimize an image](/docs/guides/optimize-an-image): Optimize an image for posting and read its optimization score.

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

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

- [API reference](/docs/api-reference/audit-image): Every field of POST /audit.
