# AI watermark checker API

Base URL: `https://symelo.com`

OpenAPI: `/api/v1/ai-watermarks/openapi.json`

`POST /api/v1/ai-watermarks/check` checks images for AI watermarks and provenance data, and text for hidden characters. It is the server version of the [AI Watermark Checker](/tools/ai-watermark-checker), which runs the same checks in the browser without uploading anything. No API key or account is needed.

## What is checked

Images (JPEG, PNG, WebP, GIF, AVIF, HEIC, TIFF):

- **Content Credentials (C2PA)**: every manifest in the file, with its claim generator, actions, IPTC digital source types, ingredients and signing certificate. The COSE signature of each claim is verified with the certificate the file carries (ES256/384/512, PS256/384/512, Ed25519), and the active manifest's data hash is compared with the image bytes (JPEG, PNG, WebP, GIF, TIFF). Certificates are not checked against the C2PA trust list: read `signer` and decide whether you trust it.
- **IPTC digital source type** in XMP (`trainedAlgorithmicMedia`, `compositeWithTrainedAlgorithmicMedia` and the rest of the vocabulary), and the IPTC AI properties (`AISystemUsed`, `AIPromptInformation`).
- **Generator settings**: prompts, models, seeds and samplers saved by AUTOMATIC1111 and Forge, ComfyUI, InvokeAI, NovelAI, Fooocus, SwarmUI and Midjourney, in PNG text chunks, EXIF and XMP.
- **Invisible watermark**: the DWT-DCT watermark that Stability AI's reference code writes into Stable Diffusion XL, 1.x and 2.x images (PNG, JPEG, WebP up to 16 megapixels). It does not survive resizing, cropping or strong compression.
- **AI tool names** in EXIF and XMP software, credit and description fields (a hint only).

Text (a `text` field, or a UTF-8 or UTF-16 file):

- Zero-width and invisible characters, Unicode tag characters and variation-selector sequences. Hidden messages in tags, variation selectors and two-character zero-width binary are decoded.
- Bidirectional controls, control characters, private-use characters, unusual spaces (such as U+202F), line separators, soft hyphens and styled letters.
- Words that mix Latin with Cyrillic or Greek look-alike letters.

Not checked, because no public detector exists: Google's SynthID (images and text), Meta's and other providers' invisible watermarks, and statistical LLM text watermarks, which need the provider's key. A result without a watermark does not mean a person made the file: screenshots, re-saving and most social networks remove metadata.

## Limits

- Up to 5 items (files and texts together) per request, 10 MB each.
- At most 300 images an hour across all callers (texts and other files don't count). A request that would pass the limit is refused as a whole with `429` and `"code": "image_limit"`; its message says how many images are left.
- Multipart bodies up to 51 MB; JSON bodies up to 68 MB (base64 is a third larger).
- A request has 25 seconds to check its items.
- Service-wide request and data budgets: on `429` or `503`, wait for `Retry-After` seconds.
- Files are never fetched from URLs: send the bytes.

Each request is checked in a separate short-lived process without secrets. Files are kept in memory only for the check: they are never written to disk or logged, and every copy (the upload, the decoded pixels) is overwritten and released as soon as its check ends.

## Requests

### Multipart

```bash
curl -sS https://symelo.com/api/v1/ai-watermarks/check \
  -F "file=@image.png" \
  -F "file=@photo.jpg" \
  -F "text=Text to check"
```

Any field name works for files. Add `-F include_cleaned_text=true` to get each text back without its hidden characters.

### JSON

```bash
curl -sS https://symelo.com/api/v1/ai-watermarks/check \
  -H "Content-Type: application/json" \
  -d '{"text": ["First text", "Second text"], "include_cleaned_text": true}'
```

Files in JSON are base64, with an optional name (a `data:` URL prefix is accepted):

```json
{ "files": [{ "name": "image.png", "data": "iVBORw0KGgo…" }] }
```

### JavaScript

```javascript
// Node.js 20+ or Bun.
import { readFile } from "node:fs/promises";

const form = new FormData();
form.append("file", new Blob([await readFile("image.png")]), "image.png");

const response = await fetch("https://symelo.com/api/v1/ai-watermarks/check", {
  method: "POST",
  body: form,
  signal: AbortSignal.timeout(60_000),
});
const data = await response.json();
if (!response.ok) throw new Error(data.error?.message ?? "HTTP " + response.status);
for (const result of data.results) console.log(result.name, result.kind, result.verdict, result.headline);
```

## Response

`200` returns one result per file, then per text, in the order sent:

```json
{
  "schema_version": "1.0",
  "results": [
    {
      "index": 0,
      "name": "image.png",
      "bytes": 362296,
      "kind": "image",
      "format": "png",
      "verdict": "ai_generated",
      "headline": "AI-generated",
      "signals": [
        { "check": "c2pa", "strength": "strong", "verdict": "ai_generated", "summary": "Content Credentials signed by OpenAI say it was created with generative AI (GPT-4o)." }
      ],
      "c2pa": { "found": true, "content_hash": "match", "manifests": [{ "active": true, "claim_generator": "ChatGPT", "signature": "valid", "ai_source_types": ["http://cv.iptc.org/newscodes/digitalsourcetype/trainedAlgorithmicMedia"] }] },
      "iptc": { "found": false, "digital_source_types": [] },
      "generator": { "found": false },
      "invisible_watermark": { "status": "not_detected", "candidates": [{ "id": "sdxl", "matched_bits": 23, "total_bits": 48, "detected": false }] },
      "software": [],
      "errors": []
    },
    {
      "index": 1,
      "name": null,
      "kind": "text",
      "verdict": "hidden_characters",
      "headline": "Hidden characters found",
      "categories": [{ "id": "tags", "severity": "high", "count": 12, "characters": [] }],
      "hidden_messages": [{ "method": "unicode_tags", "text": "hidden note", "offset": 42 }]
    }
  ],
  "notes": ["No watermark found does not mean a person made the file: …"]
}
```

Image `verdict`, from strongest:

| Verdict | Meaning |
| --- | --- |
| `ai_generated` | Content Credentials or IPTC metadata declare generative AI, generator settings are in the file, or the Stable Diffusion pixel watermark was decoded. |
| `ai_edited` | Content Credentials or IPTC metadata say generative AI edited or was combined into the image. |
| `ai_hints` | AI tool names in the metadata, or algorithmic media; easy to write by hand. |
| `credentials` | Content Credentials without an AI declaration (for example a camera or an editor). |
| `none` | Nothing found. Not proof of human origin. |

Each entry of `signals` names the check, its `strength` (`strong` for valid signed credentials, generator settings and the decoded watermark; `medium` for unsigned labels or broken signatures; `weak` for names) and a one-line summary. A broken signature (`signature: "invalid"`) or `content_hash: "mismatch"` means the credentials or the image were changed after signing.

Text `verdict`: `hidden_characters` (invisible characters, tags or variation-selector data), `unusual_characters` (controls, look-alike letters, unusual spaces and similar), or `clean`.

Files that are neither a supported image nor text return `"kind": "unsupported"`; the rest of the request still succeeds.

## Errors

Errors return JSON `{ "error": { "code": "…", "message": "…" } }`:

| Status | Code | When |
| --- | --- | --- |
| 400 | `no_input`, `too_many_items`, `invalid_multipart`, `invalid_json` | Nothing to check, more than 5 items, or an unreadable body |
| 405 | `method_not_allowed` | Not POST |
| 408 | `timeout` | The upload took too long |
| 413 | `too_large`, `file_too_large` | The body or one item is too large |
| 415 | `unsupported_media_type` | Not multipart/form-data or application/json |
| 422 | `invalid_request`, `invalid_base64`, `unreadable` | Invalid JSON fields, bad base64, or files that could not be checked |
| 429 | `rate_limited`, `image_limit` | Request or data budgets used up, or the 300 images an hour; wait for `Retry-After` |
| 503 | `busy`, `unavailable` | Busy or switched off; wait for `Retry-After` |
| 504 | `timeout` | The check took longer than 25 seconds |
