> ## Knowledge Base Index
> Fetch the complete knowledge base index at: https://help.undetectable.ai/sitemap.xml
> Use this file to discover available pages before exploring further.
> Pure-Markdown content can be obtained by appending a '.md' suffix to the content URLs listed in the sitemap (without the trailing slash).

# Image Detector API


# Try it Out

You can test out the API without code by going to the FastAPI link with your web browser: `https://ai-image-detect.undetectable.ai/docs`

# Authentication

Undetectable.AI uses API keys to allow access to the API. You can get your API key at the top of the page in our [developer portal](https://undetectable.ai/develop).

UD expects for the API key to be included in all API requests to the server in a request body that looks like the following:

`key: YOUR API KEY GOES HERE`

|| You must replace `YOUR API KEY GOES HERE` with your personal API key.


# Rate Limits

The Image Detector API enforces a per-minute request budget for each API key. Heavier write endpoints cost more than lighter read endpoints. The default limit is **60 requests per minute** per API key — contact us if you need a higher limit.

#### Endpoint weights

Each call deducts its weight from your per-minute budget:

| Endpoint | Type | Weight |
| ---- |
| `POST /detect` | Write | 1 |
| `POST /bulk-upload` | Write | 1 |
| `GET /get-presigned-url` | Read | 0.2 |
| `GET /check-user-credits` | Read | 0.2 |
| `POST /heatmap/{id}` | Read | 0.2 |
| `POST /preview/{id}` | Read | 0.2 |
| `POST /query` | Read | Not rate-limited (no API key required) |
| `GET /health` | Read | Not rate-limited |

**Example:** with a 60/minute budget, you can send up to 60 write calls, or up to ~300 read calls, or any mix where the weighted total stays ≤ 60 per minute.

#### Throttled response (HTTP 429)

When you exceed your per-minute budget, the API returns:

```
HTTP/1.1 429 Too Many Requests
X-RateLimit-Limit: 60
X-RateLimit-Remaining: 0
X-RateLimit-Reset: 3
X-RateLimit-Retry-After: 3
Content-Type: application/json

{
  "error": "Too many requests"
}
```

#### Rate-limit response headers

* **`X-RateLimit-Limit`** — your per-minute capacity.
* **`X-RateLimit-Remaining`** — calls remaining for the current minute (after this request).
* **`X-RateLimit-Reset`** — *(only on 429)* seconds until you can send another write request.
* **`X-RateLimit-Retry-After`** — *(only on 429)* same value as `X-RateLimit-Reset`, returned in `Retry-After` style.

#### Recommended client behavior

* Watch **`X-RateLimit-Remaining`** on successful responses to pace your requests.
* On a **`429`**, wait at least **`X-RateLimit-Retry-After`** seconds before retrying — use exponential backoff with jitter.
* For batches, prefer **`POST /bulk-upload`** (one call processes up to 50 images) over many parallel **`/detect`** calls.

---

# Speed & Performance Optimization

Different use cases demand different trade-offs between **speed** and **detail**. Every optional feature beyond the core AI score adds asynchronous work — disable the features you do not need to reduce overall latency and polling time.

#### Fastest Response (Core AI Score Only)

Set **`generate_preview`**, **`generate_analysis_details`**, and **`generate_heatmap`** to `false`. This returns only the core AI detection score with the shortest possible polling window.

```
curl -X 'POST' \
  'https://ai-image-detect.undetectable.ai/detect' \
  -H 'accept: application/json' \
  -H 'Content-Type: application/json' \
  -d '{
  "key": "YOUR-API-KEY-GOES-HERE",
  "url": "https://ai-image-detector-prod.nyc3.digitaloceanspaces.com/<FILE_PATH>",
  "generate_preview": false,
  "generate_analysis_details": false,
  "generate_heatmap": false
}'
```

Poll **`/query`** until **`status`** is **`done`** — no need to wait for heatmap or analysis fields.

#### Most Detailed Response (Score + Heatmap + Analysis + Preview)

Keep the defaults (or explicitly enable all features):

```
curl -X 'POST' \
  'https://ai-image-detect.undetectable.ai/detect' \
  -H 'accept: application/json' \
  -H 'Content-Type: application/json' \
  -d '{
  "key": "YOUR-API-KEY-GOES-HERE",
  "url": "https://ai-image-detector-prod.nyc3.digitaloceanspaces.com/<FILE_PATH>",
  "generate_preview": true,
  "generate_analysis_details": true
}'
```

Poll **`/query`** until:
* **`status == done`** — core AI score is ready
* **`result_details.heatmap_status == ready`** — heatmap image is available (AI-generated images only)
* **`result_details.analysis_results_status == ready`** — detailed LLM analysis is available

#### Feature Impact on Latency

| Feature | Default (`/detect`) | Default (`/bulk-upload`) | Latency Impact |
| ---- |
| Core AI score | Always on | Always on | Baseline |
| `generate_analysis_details` | `true` | `false` | **Highest** — runs an LLM as a secondary async job |
| Heatmap generation | Auto (AI images only) | Auto (AI images only) | Moderate — async; only for AI/edited predictions |
| `generate_preview` | `true` | `false` | Low — async preview upload |

**Tip:** For the absolute fastest integration, set **`generate_preview=false`** and **`generate_analysis_details=false`**, and poll only for **`status == done`**. See `GET /help` for a compact JSON performance guide.

---

# AI Image Detector 

#### Detect (3-Step Process)

The AI Image Detection workflow consists of the following steps:

* Obtain a Pre-signed Upload URL
* Upload the Image
* Submit the Image for Detection

##### 1. Obtain a Pre-signed Upload URL

Begin by requesting a pre-signed URL from the API. This URL allows you to securely upload your image file to the storage server.

**Supported File Formats:** JPG, JPEG, PNG, WebP, JFIF, HEIC, HEIF, AVIF, BMP, TIFF, TIF , GIF , SVG, PDF

**Note:** It is necessary to remove spaces from the image filename when requesting a pre-signed URL.
**PDF note:** For PDF files, only the first image will be detected.

| **${color}[#0093ee](GET) ** `https://ai-image-detect.undetectable.ai/get-presigned-url?file_name=example.jpg`

**Query parameters:**

* `file_name` (required) — Original filename; the server may normalize it (spaces and unsafe characters are adjusted). Use a `.zip` extension for bulk upload.
* `expiration` (optional) — Presigned URL lifetime in seconds (default: `3600`).
* `proxy_host` (optional) — Allow-listed host to return the upload URL on (currently `mcp-cdn.truthscan.com`), for callers whose network policy only permits `*.truthscan.com`. Any other value is ignored and the default host is used.

#####  Example Request

```
curl -X GET 'https://ai-image-detect.undetectable.ai/get-presigned-url?file_name=example.jpg' \
--header 'apikey: YOUR API KEY GOES HERE'
```

#####  Example Response

```
{
  "status": "success",
  "presigned_url": "https://nyc3.digitaloceanspaces.com/ai-image-detector-dev/uploads/581d47c7-3ef4-42af-88d9-6dab6bf69389_20250611-121955_example.jpg...",
  "file_path": "uploads/example.jpg",
  "document_id": "a1b2c3d4-e5f6-7890-abcd-ef1234567890"
}
```

The **`document_id`** is a new UUID generated for this upload request (for correlation/logging). The **`id`** you use for **`/detect`** or **`/bulk-upload`** is assigned when you submit those endpoints unless you pass an optional **`id`** on **`/detect`** (see below).

**HTTP responses (`GET /get-presigned-url`):**

| Status | Body | When |
| ---- |
| **200** | JSON (`status`, `presigned_url`, `file_path`, `document_id`) | Presigned URL issued successfully. |
| **400** | JSON `{"error": "..."}` | Unsupported file type or invalid parameters. |
| **403** | JSON `{"error": "..."}` | Invalid API key. |
| **404** | JSON `{"error": "..."}` | TruthScan API key could not be validated. |
| **500** | JSON `{"error": "..."}` | Server error while generating the URL. |

##### 2. Upload the Image

Use the provided `presigned_url` to upload your image via a `PUT` request. Ensure the correct content type is set according to your image format.

#####  Example Request

```
curl -X PUT 'https://nyc3.digitaloceanspaces.com/ai-image-detector-dev/uploads/581d47c7-3ef4-42af-88d9-6dab6bf69389_20250611-121955_example.jpg...' \
  --header 'Content-Type: image/jpeg' \
  --header 'x-amz-acl: private' \
  --data-binary '@example.jpg' # Attachment
```

Set the `Content-Type` header to match your file extension exactly:
* image/jpeg: jpg, jpeg, jfif
* image/png: png
* image/webp: webp
* image/heic: heic
* image/heif: heif
* image/avif: avif
* image/bmp: bmp
* image/tiff: tiff, tif
* image/gif: gif
* image/svg+xml: svg
* application/pdf: pdf
```
# PNG example
curl -X PUT '<PRESIGNED_URL_FOR_example.png>' \
  --header 'Content-Type: image/png' \
  --header 'x-amz-acl: private' \
  --data-binary '@example.png'

# PDF example
curl -X PUT '<PRESIGNED_URL_FOR_example.pdf>' \
  --header 'Content-Type: application/pdf' \
  --header 'x-amz-acl: private' \
  --data-binary '@example.pdf'

# SVG example
curl -X PUT '<PRESIGNED_URL_FOR_example.svg>' \
  --header 'Content-Type: image/svg+xml' \
  --header 'x-amz-acl: private' \
  --data-binary '@example.svg'
```
Common mistakes to avoid:
* Do not use `image/jpg` (incorrect). Use `image/jpeg`.
* Do not mismatch file and header (e.g., `.png` file with `image/jpeg`).
* Do not change the extension without updating the header (or vice versa).
* Do not include spaces in filenames when requesting/uploading.
**Note:** It is necessary to remove spaces from the image filename when uploading the image.

Ensure that the file format remains consistent during the upload process. A successful upload returns HTTP **`200`** with an **empty response body** by design — this `PUT` goes directly to object storage (AWS S3 / DigitalOcean Spaces), not to our API. Do not parse a JSON body from this step; success means HTTP 200 and no body.

**File Size Limits:**
* Minimum file size: 1KB
* Maximum file size: 10MB

##### 3. Submit Image for AI Detection

After uploading, submit the image for AI detection by referencing the `file_path` from the previous step.
For PDF uploads, only the first image will be analyzed/detected.

| **${color}[#0093ee](POST) ** `https://ai-image-detect.undetectable.ai/detect`

#### Example Request

```
curl -X 'POST' \
  'https://ai-image-detect.undetectable.ai/detect' \
  -H 'accept: application/json' \
  -H 'Content-Type: application/json' \
  -d '{
  "key": "YOUR-API-KEY-GOES-HERE",
  "url": "https://ai-image-detector-prod.nyc3.digitaloceanspaces.com/<FILE_PATH>",
  "generate_preview": true
}'
```
The `FILE_PATH` refers to the path obtained from the response in the initial step, "Obtain a Pre-signed Upload URL".

**Optional Parameters:**
* `id`: Optional UUID string. If omitted, the server generates a new document id. If provided, it must not already exist; otherwise the API returns an error.
* `generate_preview`: Set to `true` to generate a preview URL for the image (default: `true`). Set to `false` to skip preview generation.
* `document_type`: Type of document (default: `Image`)
* `email`: Email address for processing
* `generate_analysis_details`: Set to `false` to skip generating detailed analysis (default: `true`)
* `generate_heatmap`: Set to `false` to skip heatmap generation (default: `true`). Heatmaps are produced only when the image is classified as AI-generated and this flag is `true`; real images never receive a heatmap. When `false`, do not set `generate_heatmap_overlayed` or `generate_heatmap_normalized` to `true` — the API returns a validation error.
* `generate_heatmap_overlayed`: Controls how the heatmap image is produced when a heatmap is generated (default: `true`). When `true`, the heatmap is blended onto the original image (standard overlay). When `false`, the service returns a **transparent** heatmap: an RGBA image with the JET-colored activation map and alpha from the model, with a transparent background so you can composite it over the original or another UI in your app. Only applies when `generate_heatmap` is `true` and the image is classified as AI-generated.
* `generate_heatmap_normalized`: When `false`, heatmap generation skips the normalization step used for the activation map (default: `true`). Use together with `generate_heatmap_overlayed` to control heatmap appearance. Only applies when `generate_heatmap` is `true` and the image is classified as AI-generated.
* `model`: Model or routing hint (default: `generic`). Supported examples include `generic`, or **`instance_id/model`** (e.g. `my-instance-id/generic`) to send the job to a dedicated queue for that instance. Invalid `instance_id` values are rejected with `400`.
* `user_agent`: Optional string stored with the document for analytics/support.

#### Example Response

```javascript
{
    "id": "77565038-9e3d-4e6a-8c80-e20785be5ee9",
    "status": "pending"
}
```

The response includes a unique image ID for tracking the detection status.

**HTTP responses (`POST /detect`):**

| Status | Body | When |
| ---- |
| **200** | JSON (`id`, `status`) | Job accepted and queued for processing (`status` is `pending`). **This is what our API returns on success.** |
| **400** | JSON `{"error": "..."}` | Invalid input (missing URL, file not uploaded, size/type limits, duplicate `id`, invalid `model`, etc.). |
| **403** | JSON `{"error": "..."}` | Invalid API key or insufficient credits. |
| **500** | JSON `{"error": "..."}` | Server error while validating or enqueueing the job. |

Treat any **2xx** response that includes the expected JSON (`id` and `status`) as success. Some HTTP clients, proxies, or frameworks surface **201** or **202** for async “submit for processing” calls; our service returns **200**, but you should key off the body shape, not assume only **200** if an intermediary normalizes status codes.

---

#### Bulk Upload (ZIP)

Submit multiple images for AI detection in a single request by uploading a ZIP file. The workflow mirrors the single-image flow:

* Obtain a Pre-signed Upload URL (for the ZIP file)
* Upload the ZIP
* Submit the ZIP for Bulk Detection
* Check the status and results with `/query`

##### 1. Obtain a Pre-signed Upload URL (ZIP)

Request a pre-signed URL for your ZIP file. Use the same `get-presigned-url` endpoint with a `.zip` filename.

| **${color}[#0093ee](GET) ** `https://ai-image-detect.undetectable.ai/get-presigned-url?file_name=images.zip`

##### Example Request

```
curl -X GET 'https://ai-image-detect.undetectable.ai/get-presigned-url?file_name=images.zip' \
--header 'apikey: YOUR API KEY GOES HERE'
```

##### 2. Upload the ZIP

Use the provided `presigned_url` to upload your ZIP via a `PUT` request.

##### Example Request

```
curl -X PUT '<PRESIGNED_URL_FOR_images.zip>' \
  --header 'Content-Type: application/zip' \
  --header 'x-amz-acl: private' \
  --data-binary '@images.zip'
```

A successful ZIP upload returns HTTP **`200`** with an **empty response body** (same as single-image upload — direct `PUT` to object storage, not our API).

**ZIP Limits:**
* Maximum ZIP size: 100MB
* Maximum images per bulk: 50
* Per-image limits: minimum 1KB, maximum 10MB

**Supported formats inside ZIP:** JPG, JPEG, PNG, WebP, JFIF, HEIC, HEIF, AVIF, BMP, TIFF, TIF, GIF, SVG

**Note:** PDF files inside the ZIP are not supported and will be skipped. SVG files are converted to PNG before detection.

##### 3. Submit ZIP for Bulk Detection

| **${color}[#0093ee](POST) ** `https://ai-image-detect.undetectable.ai/bulk-upload`

##### Example Request

```
curl -X 'POST' \
  'https://ai-image-detect.undetectable.ai/bulk-upload' \
  -H 'accept: application/json' \
  -H 'Content-Type: application/json' \
  -d '{
  "key": "YOUR-API-KEY-GOES-HERE",
  "url": "https://ai-image-detector-prod.nyc3.digitaloceanspaces.com/<FILE_PATH>",
  "generate_preview": false,
  "generate_analysis_details": false,
  "model": "generic"
}'
```

The `FILE_PATH` refers to the path from the presigned URL response (e.g., `uploads/images.zip`).

**Optional Parameters:**
* `generate_preview`: Set to `true` to generate preview URLs for images (default: `false`)
* `generate_analysis_details`: Set to `true` to generate detailed analysis results (default: `false`)
* `generate_heatmap`: Same behavior as for **`/detect`** (default: `true`). Heatmaps are produced only for AI-generated images when this flag is `true`.
* `generate_heatmap_overlayed`: Same behavior as for **`/detect`**: `true` (default) = heatmap blended on the original image; `false` = transparent RGBA heatmap only for custom compositing. Only applies when `generate_heatmap` is `true` and the image is classified as AI-generated.
* `generate_heatmap_normalized`: Same behavior as for **`/detect`** (default: `true`). Only applies when `generate_heatmap` is `true` and the image is classified as AI-generated.
* `model`: Model domain - `generic`, or `instance_id/model` format

##### Example Response

```
{
    "id": "77565038-9e3d-4e6a-8c80-e20785be5ee9",
    "status": "pending",
    "expected_count": 12
}
```

The response includes a unique ID for tracking the bulk request, an initial **`status`** of **`pending`**, and **`expected_count`** (how many images from the ZIP will be analyzed).

**HTTP responses (`POST /bulk-upload`):**

| Status | Body | When |
| ---- |
| **200** | JSON (`id`, `status`, `expected_count`) | Bulk job accepted and queued (`status` is `pending`). **This is what our API returns on success.** |
| **400** | JSON `{"error": "..."}` | Invalid ZIP (not uploaded, wrong type, size/count limits, no valid images, etc.). May include a `skipped` array when no valid images were found. |
| **403** | JSON `{"error": "..."}` | Invalid API key or insufficient credits. |
| **500** | JSON `{"error": "..."}` | Server error while validating or enqueueing the bulk job. |

As with **`/detect`**, treat any **2xx** response with the expected JSON as success; our API returns **200**, but **201** or **202** can appear from other stacks for the same “accepted for async processing” pattern.

##### How bulk ZIP results update

When you check **`/query`** while a ZIP is still processing, you can see progress for each image as it is analyzed.

* After you submit the ZIP, **`status`** is **`pending`** until processing begins, then it becomes **`analyzing`**.
* The **`results`** array lists each image. Entries that are not finished yet show **`status`**: **`pending`** with **`result`** and **`result_details`** set to **`null`**. As each image finishes, that entry updates with its score and details. Files that could not be analyzed appear under **`skipped`** with a **`failed`** status and an explanation in **`result_details`**.
* Optional heatmaps and analysis details for a given image may still show as **`pending`** inside **`result_details`** until they are ready-the same behavior as for single-image detection (see **Query Detection Status and Results** below).
* When every image in **`results`** has finished, the overall **`status`** becomes **`done`**. If the bulk request cannot be completed, **`status`** may be **`failed`**.

You can call **`/query`** again on the same ID to refresh the response; you do not need to wait until the whole ZIP is finished before you see individual image results.

##### 4. Query Bulk Upload Results

To check the status and retrieve results for a bulk ZIP, use the **`/query`** endpoint with the **`id`** returned from **`/bulk-upload`**. This is the same endpoint as for single-image detection; the JSON you get back depends on whether the **`id`** belongs to a single image or a ZIP batch.

Keep checking until **`status`** is **`done`** or **`failed`**, or use the partial **`results`** while **`status`** is still **`pending`** or **`analyzing`** if you want to show live progress.

| **${color}[#0093ee](POST) ** `https://ai-image-detect.undetectable.ai/query`

##### Example Request

```
curl -X 'POST' \
  'https://ai-image-detect.undetectable.ai/query' \
  -H 'accept: application/json' \
  -H 'Content-Type: application/json' \
  -d '{
  "id": "77565038-9e3d-4e6a-8c80-e20785be5ee9"
}'
```

**HTTP responses (`POST /query`):**

| Status | Body | When |
| ---- |
| **200** | JSON (single-image or bulk shape) | Document found; current status and results (or partial progress for bulk). |
| **404** | JSON / error detail | No document with that `id`. |
| **500** | JSON / error detail | Server error while loading the document. |

##### Example Response (still processing)

While **`status`** is **`analyzing`**, some images in **`results`** may already be **`done`** while others are still **`pending`**:

```javascript
{
  "id": "3b81fb24-dd23-40e7-ae95-82f823f44098",
  "status": "analyzing",
  "results": [
    {
      "id": "4600c7e5-00ec-469d-9117-ff20e0f1c1fa",
      "status": "done",
      "result": 44.0006,
      "result_details": { },
      "filename": "photo1.jpg",
      "preview_url": null
    },
    {
      "id": "2f770c89-352a-4d53-b6a3-a2147ca2c0ae",
      "status": "pending",
      "result": null,
      "result_details": null,
      "filename": "photo2.jpg",
      "preview_url": null
    }
  ],
  "skipped": []
}
```

**Tip:** To estimate how far along the batch is, compare the number of images whose **`status`** is **`done`** or **`failed`** to the total number of entries in **`results`**.

##### Example Response (complete)

When **`status`** is **`done`**, each image in **`results`** has a final **`status`** (**`done`** or **`failed`**). The fields inside **`result_details`** for each image follow the same structure as in the single-image **`/query`** response (for example **`final_result`**, **`confidence`**, **`final_label_confidence`**, **`metadata`**, **`ocr`**, **`ml_model`**, **`warnings`**, heatmap fields, and optional analysis fields where enabled).

```
{
    "id": "77565038-9e3d-4e6a-8c80-e20785be5ee9",
    "status": "done",
    "results": [
        {
            "id": "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
            "status": "done",
            "result": 90.23,
            "filename": "image1.jpg",
            "preview_url": "https://ai-image-detector-prod.nyc3.digitaloceanspaces.com/previews/...",
            "result_details": {
                "is_valid": true,
                "detection_step": 3,
                "final_result": "AI Generated",
                "confidence": 90.23,
                "final_label_confidence": 90.23,
                "metadata": ["..."],
                "ocr": ["OCR did not detect AI", 0.0],
                "ml_model": ["AI Generated", 90.23],
                "heatmap_status": "ready",
                "heatmap_url": "https://...",
                "analysis_results_status": "ready",
                "analysis_results": null,
                "warnings": []
            }
        }
    ],
    "skipped": [
        {
            "id": "b2c3d4e5-f6a7-8901-bcde-f12345678901",
            "status": "failed",
            "result": null,
            "filename": "document.pdf",
            "result_details": {
                "is_valid": false,
                "error_message": "PDF not supported in bulk"
            }
        }
    ]
}
```

**Response fields (bulk ZIP):**

* **id** - The ID for this bulk ZIP request.
* **status** - Overall progress: **`pending`**, then **`analyzing`**, then **`done`** when all images are finished, or **`failed`** if the request could not be completed.
* **results** - One entry per image from the ZIP that is being analyzed. Each entry includes **`id`**, **`status`**, **`result`**, **`result_details`**, and when available **`filename`** and **`preview_url`**. Until that image has finished, **`status`** is **`pending`** and **`result`** / **`result_details`** are **`null`**.
* **skipped** - Files that were not analyzed (for example unsupported type, invalid path, or size limits). Each entry uses the same shape; **`status`** is usually **`failed`** with the reason in **`result_details`**.

**Billing:** Credits are used only for images that are successfully analyzed. Failed SVG conversions and skipped files are not billed.

---

#### Query Detection Status and Results

To check the status and retrieve the results, use the `/query` endpoint with the image ID.

**Authentication:** The request body only includes `id`; the API does not send an API key on this call. Anyone who knows the UUID can poll results—treat document IDs as sensitive if you need to restrict who can see scores.

| **${color}[#0093ee](POST) ** `https://ai-image-detect.undetectable.ai/query`

#### Example Request

```
curl -X 'POST' \
  'https://ai-image-detect.undetectable.ai/query' \
  -H 'accept: application/json' \
  -H 'Content-Type: application/json' \
  -d '{
  "id": "IMAGE-ID-GOES-HERE"
}'
```

**HTTP responses:** Same as the bulk **`/query`** table above — **200** with result JSON on success, **404** if the `id` is unknown, **500** on server error.

#### Example Response

```
{
    "id": "00fee5ff-a55b-42fb-b7c7-d14f05ae0769",
    "status": "done",
    "result": 90.2371538185235,
    "result_details": {
        "is_valid": true,
        "detection_step": 3,
        "final_result": "AI Generated",
        "metadata": [
            "No Information Detected for Real/AI",
            "Could not find anything from ExifTool and Pillow metadata"
        ],
        "metadata_basic_source": "null",
        "ocr": [
            "OCR did not detect AI",
            0.0
        ],
        "ml_model": [
            "AI Generated",
            90.2371538185235
        ],
        "confidence": 90.2371538185235,
        "final_label_confidence": 90.2371538185235,
        "heatmap_status": "ready",
        "heatmap_url": "https://ai-image-detector-prod.nyc3.digitaloceanspaces.com/uploads/....",
        "analysis_results_status": "pending",
        "analysis_results": null,
        "warnings": [
            {
                "type": "blur_dark",
                "label": "Blurred"
            },
            {
                "type": "watermark",
                "label": "Gemini",
                "confidence": 0.95
            },
            {
                "type": "screen_recapture",
                "label": "screen",
                "metrics": { "is_screen": false },
                "confidence": 99.99
            }
        ]
    },
    "preview_url": "https://ai-image-detector-prod.nyc3.digitaloceanspaces.com/previews/..."
}
```

`warnings` is omitted or empty when there is nothing to report. Not every run includes every `type`; entries appear only when the corresponding checks apply. A **`watermark`** object is added only when a watermark label is detected (for example `"Gemini"`); when the pipeline is unsure, the outcome remains in **`ocr`** only (see **Result Details** below).

#### Example Response (`/query`, secure URLs enabled on the org)

```
{
    "id": "00fee5ff-a55b-42fb-b7c7-d14f05ae0769",
    "status": "done",
    "result": 90.2371538185235,
    "result_details": {
        "is_valid": true,
        "detection_step": 3,
        "final_result": "AI Generated",
        "confidence": 90.2371538185235,
        "final_label_confidence": 90.2371538185235,
        "heatmap_status": "ready",
        "heatmap_url": "https://ai-image-detect.undetectable.ai/heatmap/00fee5ff-a55b-42fb-b7c7-d14f05ae0769",
        "analysis_results_status": "ready",
        "analysis_results": null
    },
    "preview_url": "https://ai-image-detect.undetectable.ai/preview/00fee5ff-a55b-42fb-b7c7-d14f05ae0769"
}
```

**Notes:**

1. Heatmap generation is asynchronous and only runs when the image is classified as AI-generated and **`generate_heatmap`** is `true` (default). Real images do not receive a heatmap. Initially, `heatmap_status` will be `pending` and `heatmap_url` may be absent or null. When ready, `heatmap_status` becomes `ready` and `heatmap_url` is populated. The file at `heatmap_url` reflects **`generate_heatmap_overlayed`** and **`generate_heatmap_normalized`** from your **`/detect`** (or **`/bulk-upload`**) request: with the default overlay (`true`) it is a normal image with the heatmap overlaid on the photo; with `generate_heatmap_overlayed` `false` it is typically a **PNG with transparency** showing only the colored activation map so you can layer it yourself.

2. Detailed analysis (unless turned off with `generate_analysis_details=false`) is asynchronous. Initially, `analysis_results_status` will be `pending` and `analysis_results` may be absent or null. When ready, `analysis_results_status` becomes `ready` (or `skipped` / `failed` in edge cases) and `analysis_results` is populated.

3. **Secure URLs:** When enabled, **`heatmap_url`** and **`preview_url`** in **`/query`** may use this API’s host (see **Example Response (`/query`, secure URLs enabled on the org)** above). Fetch with **`POST /heatmap/{id}`** and **`POST /preview/{id}`** and your **`key`** — see **Secure heatmap and preview assets** below.

*Tip: Poll&#32;`/query`&#32;again after the main score is ready to pick up heatmap and&#32;`analysis_results`&#32;when they finish.*

#### Example Response when analysis results are ready

```
{
    "id": "00fee5ff-a55b-42fb-b7c7-d14f05ae0769",
    "status": "done",
    "result": 90.2371538185235,
    "result_details": {
        "is_valid": true,
        "detection_step": 3,
        "final_result": "AI Generated",
        "confidence": 90.2371538185235,
        "final_label_confidence": 90.2371538185235,
        "heatmap_status": "ready",
        "heatmap_url": "https://ai-image-detector-prod.nyc3.digitaloceanspaces.com/uploads/....",
        "analysis_results_status": "ready",
        "analysis_results": {
            "imageTags": [
                "person",
                "portrait",
                "outdoor",
                "vineyard",
                "smiling"
            ],
            "agreement": "strong",
            "confidence": 92,
            "keyIndicators": [
                "Unnaturally smooth skin texture",
                "Consistent lighting anomalies"
            ],
            "detailedReasoning": "The image shows clear signs of AI generation with unnaturally smooth textures and consistent lighting patterns not typical of real photography.",
            "visualPatterns": [
                "Uniform noise pattern typical of diffusion models"
            ],
            "recommendations": [
                "Cross-reference with original source if available",
                "Check for metadata inconsistencies",
                "Compare with known AI generation patterns"
            ]
        }
    },
    "preview_url": "https://ai-image-detector-prod.nyc3.digitaloceanspaces.com/previews/..."
}
```

#### Result Details

* **is_valid**: Indicates if the image file is valid (true/false)
* **detection_step**: Indicates the stage at which detection was completed.
*      **1**: Only `metadata` is returned.
*      **2**: Returns `metadata` and `ocr` results.
*      **3**: Returns `metadata`, `ocr`, and `ml_model` results.
* **final_result**: The overall determination (e.g., "AI Generated", "Real", "Digitally Edited, "AI Edited").
* **confidence**: The confidence score of the detection.
* **final_label_confidence**: How sure the model is of **`final_result`**, from 0 to 100. Read it as “this image is *N*% [label]”. For example, **`final_result`**: `"AI Generated"` with **`final_label_confidence`**: `90` means the model is 90% confident the image is AI generated; **`final_result`**: `"Real"` with **`final_label_confidence`**: `80` means the model is 80% confident the image is real.
* **metadata**: Information extracted from image metadata using ExifTool and Pillow.
* **metadata_basic_source**: This information may indicate whether the image was captured using a specific mobile camera model, generated by an AI tool, or modified using photo-editing software.
* **ocr**: Watermark-detection result, stored under the historical field name **`ocr`**. It is a two-element array: **`[label, score]`** where `label` is a detected watermark class (for example `"Gemini"`) or the string **`"OCR did not detect AI"`** when no watermark is inferred, and `score` is a model score on a **0-100** scale (or `0` when unsure). This is the same watermark pipeline summarized in **`warnings`** when a label is present (see **`warnings`** below).
* **ml_model**: Results from the machine learning model.
* **warnings**: Optional array of heterogeneous warning objects. Each item includes a **`type`** string. Common shapes:
* **`blur_dark`** — Image softness or darkness heuristics: **`type`** and **`label`** (`Dark` or `Blurred`) only; no **`metrics`** field.
* **`watermark`** — Watermark detected with high confidence: **`type`**, **`label`** (for example `Gemini`), **`confidence`** in **0-1** (overall score for watermark presence). Omitted when the watermark step does not produce a positive label (unsure outcomes stay only under **`ocr`**).
* **`screen_recapture`** — Screen-recapture heuristic: **`type`**, **`label`**, **`metrics`** (for example **`is_screen`**: boolean), and optionally **`confidence`**.
  The list may be empty or absent; order is not guaranteed across versions.
* **preview_url**: URL to the preview image (if `generate_preview` was set to true). May be a direct object-storage URL or, when secure URLs are enabled, an API path such as **`https://<api-host>/preview/<document_id>`**.
* **heatmap_status**: Status of heatmap generation (`pending`, `ready`, or `failed`). Omitted when the image is not AI-generated or when **`generate_heatmap`** was `false` on submit.
* **heatmap_url**: URL to the heatmap image, available when `heatmap_status` is `ready` and the image was classified as AI-generated with **`generate_heatmap`** enabled. Appearance depends on **`generate_heatmap_overlayed`** at submit time: overlaid on the original (default), or transparent heatmap-only when set to `false`. May be a direct object-storage URL or, when secure URLs are enabled, an API path such as **`https://<api-host>/heatmap/<document_id>`** (use **`POST /heatmap/{id}`** — see below).
* **analysis_results_status**: Status of detailed analysis (`pending`, `ready`, `skipped`, `failed`, or `analyzing` while work is in progress). Omitted or `null` when `generate_analysis_details` was not requested or was `false` on **`/detect`** / **`/bulk-upload`**.
* **analysis_results**: Detailed narrative analysis when enabled; structure below.

### Analysis Result Explanation
The **`analysis_results_status`** field may include:
* `pending` — Queued or not started
* `analyzing` — In progress
* `ready` — **`analysis_results`** is populated
* `skipped` / `failed` — Analysis was not produced (e.g., disabled, error, or unsupported case)

When not ready, **`analysis_results`** is `null`. When ready, it typically includes:

1. **`agreement`**: string — one of `strong` | `moderate` | `weak` | `disagreement`
2. **`imageTags`**: string[] — up to five short tags describing the image
3. **`confidence`**: number — 0-100
4. **`keyIndicators`**: string[] — specific visual or technical cues that support the assessment
5. **`detailedReasoning`**: string — short explanation tied to the detector outcome
6. **`visualPatterns`**: string[] — broader compositional or artifact patterns
7. **`recommendations`**: string[] — practical next steps for verification

---

#### Secure heatmap and preview assets

When **`heatmap_url`** or **`preview_url`** in **`/query`** point to this API host (not directly to object storage), download the image with a **`POST`** and the same API key you use for detection.

| **${color}[#0093ee](POST) ** `https://ai-image-detect.undetectable.ai/heatmap/{id}`

**Request body (JSON):**

```json
{ "key": "YOUR-API-KEY-GOES-HERE" }
```

**Responses (check the HTTP status code and&#32;`Content-Type`):**

| Status | Body | When |
| ---- |
| **200** | **Binary** (`image/png`, etc.) | Heatmap file is available. Header **`X-Heatmap-Status`** is set when present on the document (e.g. `ready`). |
| **202** | **JSON** | Heatmap is still **generating** (`heatmap_status` is `pending` and there is no stored `heatmap_url` yet). Example: `{"heatmap_status":"pending","message":"Heatmap is still being generated. Try again shortly."}` — call **`/query`** again, then retry this endpoint. |
| **200** | **JSON** | No heatmap to serve (optional feature, no URL after processing, or `heatmap_status` **`failed`**). Includes `heatmap_status` and `message` — this is **not** a server error. |
| **500** | **JSON** | A stored `heatmap_url` exists but the file **could not be downloaded** from storage — retry later. |
| **404** | **JSON** | Document id not found. |
| **403** | **JSON** | API key does not own this document (`Access denied`). |

The server **only downloads from storage when a&#32;`heatmap_url`&#32;is present** on the document. Pending or absent heatmaps return **202** or **200** JSON — not **500**.

| **${color}[#0093ee](POST) ** `https://ai-image-detect.undetectable.ai/preview/{id}`

**Request body (JSON):**

```json
{ "key": "YOUR-API-KEY-GOES-HERE" }
```

**Response:** Raw preview image bytes. Returns **`404`** with a JSON error if no preview was generated (`generate_preview` was false).

##### Examples (heatmap and preview)

Use the same document **`id`** from **`/query`** in both URLs. The key must match the document owner; otherwise the API returns **`403`**.

**Heatmap** — `POST /heatmap/{id}`

```
curl -X POST 'https://ai-image-detect.undetectable.ai/heatmap/00fee5ff-a55b-42fb-b7c7-d14f05ae0769' \
  -H 'Content-Type: application/json' \
  -d '{"key":"YOUR-API-KEY-GOES-HERE"}' \
  --output heatmap.png
```

**Preview** — `POST /preview/{id}`

```
curl -X POST 'https://ai-image-detect.undetectable.ai/preview/00fee5ff-a55b-42fb-b7c7-d14f05ae0769' \
  -H 'Content-Type: application/json' \
  -d '{"key":"YOUR-API-KEY-GOES-HERE"}' \
  --output preview.png
```

---

#### Check User Credits

This endpoint accepts the users apikey via the header. And returns users credit details.

| **${color}[#0093ee](GET) ** `https://ai-image-detect.undetectable.ai/check-user-credits`

#### Example Request

```
curl -X 'GET' \
  'https://ai-image-detect.undetectable.ai/check-user-credits' \
  -H 'apikey: YOUR API KEY GOES HERE' \
  -H 'accept: application/json' \
  -H 'Content-Type: application/json' \
```

#### Example Response

```
{
    "baseCredits": 10000,
    "boostCredits": 1000,
    "credits": 11000
}
```

**Note:** For external integrations, only the `credits` field will be populated.

---

#### Health Check

Check the health status of the API server.

| **${color}[#0093ee](GET) ** `https://ai-image-detect.undetectable.ai/health`

#### Example Request

```
curl -X 'GET' \
  'https://ai-image-detect.undetectable.ai/health' \
  -H 'accept: application/json'
```

#### Example Response

```
{
    "status": "healthy"
}
```

---

#### Internal: organization usage sync to Stripe (job secret)

This endpoint is for **trusted backend jobs**, not general API customers. It requires header **`x-job-secret`** matching the server **`JOB_SECRET`**. It syncs metered TruthScan organization usage to Stripe for a date range.

| **${color}[#0093ee](POST) ** `https://ai-image-detect.undetectable.ai/organizations/{org_id}/usage/sync-to-stripe`

**Headers:** `x-job-secret: <JOB_SECRET>`  
**Body (JSON):**

```json
{
  "start_date": "2026-04-01",
  "end_date": "2026-04-07",
  "idempotency_id": "optional-custom-stripe-meter-idempotency-key"
}
```

Aggregates completed AI Image Detector documents in the range for that organization and sends a Stripe billing meter event for orgs on **metered** billing with a linked metered product. **Response** includes `success`, `organization_id`, `total_documents`, `value_sent_to_stripe`, `report_date`, and `message`. Returns **`400`** if the organization is not on metered billing or lacks `stripe_customer_id` when sending usage; **`401`** if the job secret is invalid; **`404`** if the organization is not found; **`500`** if Stripe or job configuration is missing.

---

# Errors

|| Most errors will be from incorrect parameters being sent to the API. Double check the parameters of each API call to make sure it's properly formatted, and try running the provided example code.

The generic error codes we use conform to the REST standard:

| Error Code | Meaning |
| ---- |
| 400 | Bad Request -- Your request is invalid. |
| 401 | Unauthorized -- Invalid job secret (internal usage endpoints) or similar auth failure. |
| 403 | Forbidden -- The API key is invalid, access denied, or there aren't sufficient credits for the operation. |
| 404 | Not Found -- The specified resource doesn't exist. |
| 405 | Method Not Allowed -- You tried to access a resource with an invalid method. |
| 406 | Not Acceptable -- You requested a format that isn't JSON. |
| 410 | Gone -- The resource at this endpoint has been removed. |
| 422 | Invalid Request Body -- Your request body is formatted incorrectly or invalid or has missing parameters. |
| 429 | Too Many Requests -- You exceeded your per-minute rate limit. The body is `{"error":"Too many requests"}`; `X-RateLimit-Retry-After` tells you how many seconds to wait before retrying. |
| 500 | Internal Server Error -- We had a problem with our server. Try again later. |
| 503 | Service Unavailable -- We're temporarily offline for maintenance. Please try again later. |

# Common Issues and Solutions

## Authentication Issues

### "User verification failed" (403)
* **Cause**: Invalid or expired API key
* **Solution**:

1. Verify your API key is correct
2. Check if your API key is active in your account
3. Try regenerating your API key

### "Not enough credits" (403)
* **Cause**: Insufficient credits for image processing
* **Solution**:

1. Check your remaining credits using `/check-user-credits`
2. Purchase additional credits if needed

## Rate Limit Issues

### "Too many requests" (429)
* **Cause**: You exceeded your API key's per-minute rate limit (default 60/minute). `/detect` and `/bulk-upload` count as 1 (write); `/get-presigned-url`, `/check-user-credits`, `/heatmap/{id}` and `/preview/{id}` count as 0.2 each (read).
* **Solution**:

1. Read `X-RateLimit-Retry-After` on the 429 response and wait that many seconds before retrying.
2. Use the `X-RateLimit-Remaining` header on successful responses to pace your traffic.
3. Add exponential backoff with jitter for retries; avoid retrying immediately in a tight loop.
4. For batches, prefer `POST /bulk-upload` (up to 50 images per request) over many parallel `/detect` calls.
5. If you consistently hit the limit, contact us to request a higher per-minute limit for your account.

## Input Validation Issues

### "Input URL cannot be empty" (400)
* **Cause**: Empty or invalid URL submitted
* **Solution**:

1. Ensure your url input is not empty
2. Remove any leading/trailing whitespace in image names
3. Check if url encoding is correct

### "Input email is empty" (400)
* **Cause**: Missing email for URL processing
* **Solution**:

1. Provide a valid email address when submitting URLs
2. Check email format is correct

### "Unsupported image type" (400)
* **Cause**: File format not supported
* **Solution**:

1. Convert image to supported format (JPG, PNG, WebP, HEIC, HEIF, AVIF, BMP, TIFF, GIF, SVG, PDF)
2. Check file extension is correct

### "File size is too small" (400)
* **Cause**: Image file is below minimum size requirement
* **Solution**:

1. Use a larger image file (minimum 1KB)
2. Check if image was corrupted during upload

### "File size exceeds limit" (400)
* **Cause**: Image file is too large
* **Solution**:

1. Compress or resize the image; the maximum size is set per deployment (the API error message states the limit in MB).
2. Use a different image format

### "Invalid file type" (400)
* **Cause**: File type validation failed
* **Solution**:

1. Ensure file is a valid image format
2. Check file is not corrupted
3. Verify MIME type matches file extension

## Processing Issues

### Image Status "failed"
* **Cause**: Processing failed for various reasons
* **Solution**:

1. Verify URL is in a supported format
2. Check image file is valid and not corrupted
3. Ensure image meets size requirements
4. Contact support if issue persists

### "User not found"
* **Cause**: Invalid user ID
* **Solution**:

1. Verify user ID is correct
2. Ensure user account is active
3. Re-authenticate if needed

### "File metadata could not be fetched" (500)
* **Cause**: Unable to access uploaded file
* **Solution**:

1. Verify file was uploaded successfully
2. Check file URL is accessible
3. Try re-uploading the file

## Bulk Upload Issues

### "URL must point to a ZIP file" (400)
* **Cause**: The URL provided to `/bulk-upload` does not point to a ZIP file
* **Solution**:

1. Use `get-presigned-url?file_name=images.zip` (or another `.zip` filename)
2. Upload a valid ZIP file to the presigned URL
3. Ensure the URL in the bulk-upload request points to the uploaded ZIP

### "ZIP file too large" (400)
* **Cause**: ZIP exceeds maximum size (100MB)
* **Solution**:

1. Reduce the number of images or compress them
2. Split into multiple bulk uploads

### "Too many files" (400)
* **Cause**: ZIP contains more than 50 valid images
* **Solution**:

1. Reduce to 50 or fewer images per ZIP
2. Split into multiple bulk uploads

### "No valid images found in ZIP" (400)
* **Cause**: All files in the ZIP were skipped (unsupported format, too small, invalid path, etc.)
* **Solution**:

1. Ensure images use supported formats (JPG, PNG, WebP, HEIC, HEIF, AVIF, BMP, TIFF, TIF, GIF, SVG)
2. PDF is not supported in bulk
3. Each image must be at least 1KB and at most 10MB
4. Avoid hidden files (names starting with `.`) and path traversal (`..`)

### "Document is not a bulk upload (image-zip) result" (400)
* **Cause**: The **`id`** in your request does not go with that upload type (single image vs. ZIP batch).
* **Solution**:

1. Use the **`id`** from **`/detect`** when you submitted a single image.
2. Use the **`id`** from **`/bulk-upload`** when you submitted a ZIP file.

---
