Skip to content
Introduction
API v1

API documentation

Check whether a photo was made by AI with one request. Everything you need to add Perspicax to your product: a quick start, every endpoint and field, a playground and a Postman collection.

POST /api/v1/scans
$ curl https://perspicax.io/api/v1/scans \    -H "Authorization: Bearer $PERSPICAX_API_KEY" \    -H "Prefer: wait=30" \    -F image=@harbour.jpg# 200 OK · Illustrative values{  "status": "completed", "mode": "live",  "result": {    "label": "ai_generated",    "coarse": "high", "score": 0.9731  },  "model": { "id": "trueeye-2.3" }}
Base URL
https://perspicax.io/api/v1
Authentication
Bearer key
Format
JSON over HTTPS
Model
TrueEye 2.3

Introduction

The Perspicax API tells you whether a photo was fully generated by AI. Send an image as a file or a link, and get back a verdict in three bands, a score from 0 to 1, the provenance found in the file and the model that answered. Every image is checked with proven computer vision and TrueEye 2.3, our detector.

It is built for fully AI-generated, photo-like still images. It does not check videos or documents, and it does not point out the edited part of a photo.

The API speaks JSON over HTTPS. Answers are UTF-8 JSON, times are ISO 8601 in UTC, and errors are RFC 9457 problem details with a stable code.

At a glance

Base URL
https://perspicax.io/api/v1
Authentication
Authorization: Bearer pcx_live_…
Images
JPEG, PNG, WebP, HEIC, HEIF, AVIF, GIF (still), up to 20 MB, as a file or a link
Answers
In the same call with Prefer: wait (up to 30 s), or a 202 to poll
Retries
Safe with an Idempotency-Key, kept 24 hours
Rate limit
60 requests a minute per key

Quick start

Your first request takes about a minute.

  1. 1

    Get a key

    Sign up free or sign in, open Developers › API keys and create a key. Development keys (pcx_test_) are free on every plan; production keys (pcx_live_) come with Pro and Enterprise.

  2. 2

    Pick an image

    With a development key, use an image you have already scanned on perspicax.io, the very same file: development mode answers with that scan’s stored result. With a production key, any image works.

  3. 3

    Set the key

    Keep the key in an environment variable, never in your code:

    bash
    export PERSPICAX_API_KEY=pcx_test_…
  4. 4

    Send it

    Run the request in the code panel. With Prefer: wait=30 the verdict comes back in the same call: read result.label.

Rather click than type? Try the playground, or import the Postman collection.

Your first request
curl https://perspicax.io/api/v1/scans \  -H "Authorization: Bearer $PERSPICAX_API_KEY" \  -H "Prefer: wait=30" \  -H "Idempotency-Key: $(uuidgen)" \  -F "image=@harbour.jpg"
What comes back · Illustrative values
200 OK · application/json
{  "id": "6f1c2d3e-4b5a-4c7d-8e9f-0a1b2c3d4e5f",  "status": "completed",  "source": "api",  "mode": "live",  "replayOf": null,  "model": {    "id": "trueeye-2.3",    "name": "TrueEye",    "version": "2.3"  },  "createdAt": "2026-10-28T09:12:47.210Z",  "completedAt": "2026-10-28T09:12:49.350Z",  "image": {    "sha256": "f750730fb99d9aaf619583559e175ec3069aceb2b10fc3d2565148ce28b6b596",    "width": 2400,    "height": 1018,    "mime": "image/jpeg",    "bytes": 299028,    "filename": "harbour.jpg",    "previewUrl": "https://perspicax.io/api/scans/6f1c2d3e-4b5a-4c7d-8e9f-0a1b2c3d4e5f/image?exp=1761643067&sig=Q2xlYXJseUlsbHVzdHJhdGl2ZQ"  },  "result": {    "label": "ai_generated",    "inconclusiveReason": null,    "labelSource": "detector",    "coarse": "high",    "score": 0.9731,    "thresholds": null,    "provenance": {      "c2pa": null,      "iptcDigitalSourceType": null    },    "metadata": {      "exifPresent": false,      "cameraMake": null,      "software": null    },    "detector": {      "model": "TrueEye",      "version": "2.3",      "cropScores": null,      "photoLike": true,      "mock": false    },    "autoCrop": {      "enabled": true,      "applied": false,      "box": null,      "imageWidth": 2400,      "imageHeight": 1018    }  },  "usage": {    "counted": true,    "cached": false  },  "latencyMs": 2140,  "share": null}

Authentication

Every request sends one of your workspace’s keys as a bearer token in the Authorization header. Keys work on /api/v1 only, and cookies are ignored there.

pcx_test_…

Mode
Development: the stored results of images you have already scanned
Plans
Every plan

pcx_live_…

Mode
Production: every image is checked
Plans
Pro and Enterprise
  • Keys belong to the workspace (your personal account or your Enterprise organisation). A key can do only what the member who created it may do there, checked on every request, and it stops working when they leave the organisation.
  • A new key is shown once. Keep it in a secret manager or an environment variable.
  • Never put a key in a web page or a mobile app, where anyone can read it. Call the API from your server.
  • Up to 20 active keys per workspace. Revoke a key in the app at any time; it stops working at once.

A missing, malformed, unknown or revoked key gets 401 with the code unauthorized and a WWW-Authenticate header.

GET /api/v1/models is the quickest check: it needs a valid key and nothing else.

Check your key
curl https://perspicax.io/api/v1/models \  -H "Authorization: Bearer $PERSPICAX_API_KEY"
A key that does not work
401 Unauthorized · application/problem+json
{  "type": "https://perspicax.io/problems/unauthorized",  "title": "Sign-in required",  "status": 401,  "detail": "A valid API key is required: Authorization: Bearer pcx_live_… or pcx_test_…",  "code": "unauthorized",  "instance": "/api/v1/models",  "requestId": "b3f1c2d4-5e6f-4a7b-8c9d-0e1f2a3b4c5d"}

Development and production

Every key works in one of two modes, set by its prefix. Build and test in development mode for free, then switch the key, not the code.

Development

Key
pcx_test_…
Plans
Every plan, Free included
Which images
Only images your workspace has already scanned, byte for byte
What you get
The stored result of that earlier scan, as a new scan with mode: test and replayOf
Answer
200 at once, every time
Quota
None used
Preview link
previewUrl is null
An image you have not scanned
422 unknown_test_image
Public page (share=true)
Accepted and ignored: share is null
Scans the key reads
The workspace’s development-mode answers
On the Free plan
Works
Made for
Building, tests and CI, demos

Production

Key
pcx_live_…
Plans
Pro and Enterprise
Which images
Any image
What you get
A new check by TrueEye 2.3, with mode: live
Answer
200 within Prefer: wait, or a 202 to poll
Quota
Uses your quota: Pro’s 35 scans a day, shared with the app, or your Enterprise pool
Preview link
A link valid for 5 minutes
An image you have not scanned
Checked like any other
Public page (share=true)
Made with the scan: share.url, also on a 202
Scans the key reads
Every other scan of the workspace
On the Free plan
402 upgrade_required
Made for
Your product

Development mode

Development mode answers without running the detector. We hash the bytes you send (SHA-256). When your workspace has an earlier completed scan of exactly those bytes, made in the app, before you signed up, in the playground or with a production key, you get that scan’s stored result: the same label, band, model and auto-crop, and the same score on Pro and Enterprise. On Free, score is null: the exact score comes with production access.

It comes back at once as a new scan with mode: "test" and replayOf set to the earlier scan’s id, and it uses no quota. Prefer: wait changes nothing, and share=true is accepted but makes no public page: these answers repeat an earlier result. Any other image gets 422 unknown_test_image, with its sha256 and a link back to this section.

Good to know

  • Scan your test images once in the app, then keep the files as they are. Re-saving, resizing, compressing or screenshotting a file changes its bytes, and development mode will not know it.
  • The answers never change, so development keys suit automated tests and CI: the same file always gets the same result.
  • When several earlier scans of the file match, the newest one made with the same model and autoCrop setting is repeated, otherwise the newest one.
  • Development mode has limits of its own: 30 requests a minute per key, and 60 a minute and 1000 a day per workspace.
  • Workspaces are separate: an image scanned only in your personal workspace is unknown to your organisation’s keys, and the other way round.
  • Development-mode answers stay out of the app’s history and exports and never get a public page. They show in your request logs.

Production mode

Production keys check every image with TrueEye 2.3 and use your workspace’s quota, through the same rules as scans in the app. They come with Pro and Enterprise automatically: there is nothing to request.

Every upload is a new scan and counts, also when it is the same file. Only a retry with the same Idempotency-Key gets the earlier answer without counting again.

On Free, creating or using a production key answers 402 upgrade_required with suggestedPlan: "pro", also for a key whose workspace went back to Free, until the plan comes back. Development keys keep working. Upgrade to Pro.

Development key, an image you scanned before · Illustrative values
200 OK · application/json
{  "id": "6f1c2d3e-4b5a-4c7d-8e9f-0a1b2c3d4e5f",  "status": "completed",  "source": "api",  "mode": "test",  "replayOf": "0b8e4f27-3c1d-4a6e-9f52-7e1a3d9c4b88",  "model": {    "id": "trueeye-2.3",    "name": "TrueEye",    "version": "2.3"  },  "createdAt": "2026-10-28T09:12:47.210Z",  "completedAt": "2026-10-28T09:12:47.222Z",  "image": {    "sha256": "f750730fb99d9aaf619583559e175ec3069aceb2b10fc3d2565148ce28b6b596",    "width": 2400,    "height": 1018,    "mime": "image/jpeg",    "bytes": 299028,    "filename": "harbour.jpg",    "previewUrl": null  },  "result": {    "label": "ai_generated",    "inconclusiveReason": null,    "labelSource": "detector",    "coarse": "high",    "score": 0.9731,    "thresholds": null,    "provenance": {      "c2pa": null,      "iptcDigitalSourceType": null    },    "metadata": {      "exifPresent": false,      "cameraMake": null,      "software": null    },    "detector": {      "model": "TrueEye",      "version": "2.3",      "cropScores": null,      "photoLike": true,      "mock": false    },    "autoCrop": {      "enabled": true,      "applied": false,      "box": null,      "imageWidth": 2400,      "imageHeight": 1018    }  },  "usage": {    "counted": false,    "cached": true  },  "latencyMs": 12,  "share": null}
Development key, an image you never scanned
422 Unprocessable Content · application/problem+json
{  "type": "https://perspicax.io/problems/unknown_test_image",  "title": "Unknown image in development mode",  "status": 422,  "detail": "Development mode answers for images you have already scanned on perspicax.io. Scan it in the app first, or use a production key (Pro).",  "code": "unknown_test_image",  "instance": "/api/v1/scans",  "requestId": "b3f1c2d4-5e6f-4a7b-8c9d-0e1f2a3b4c5d",  "docs": "https://perspicax.io/docs/api#development-mode",  "sha256": "f750730fb99d9aaf619583559e175ec3069aceb2b10fc3d2565148ce28b6b596"}
Production key on the Free plan
402 Payment Required · application/problem+json
{  "type": "https://perspicax.io/problems/upgrade_required",  "title": "Upgrade required",  "status": 402,  "detail": "Live keys use production mode, which comes with Pro and Enterprise. Upgrade to Pro, or use a test key (pcx_test_): development mode is free on every plan.",  "code": "upgrade_required",  "instance": "/api/v1/scans",  "requestId": "b3f1c2d4-5e6f-4a7b-8c9d-0e1f2a3b4c5d",  "suggestedPlan": "pro",  "docs": "https://perspicax.io/docs/api#production-mode"}

Verdicts and scores

result.label is the answer to build on. It has three values, each with a matching band in result.coarse:

  • Likely AI-generatedai_generatedcoarse: highStrong signs that the whole image was generated by AI.
  • Inconclusiveinconclusivecoarse: middleNeither clearly AI nor clearly real. Treat it as unknown: review it, or ask for another photo.
  • Likely realnot_ai_generatedcoarse: lowNo signs of AI generation. It does not prove that a photo is authentic or unedited.

The score

result.score runs from 0 to 1: higher means stronger signs of AI generation. It is a score, not a probability. The cut-offs between the bands are not published and can change with a new model version, so don’t hard-code thresholds: decide on label, and keep score for ranking or your own records. In development mode on Free, score is null.

Photos, and everything else

result.detector.photoLike is false when the image looks like a screenshot, poster, document or artwork rather than a photo. It still gets a verdict, but TrueEye 2.3 is built for photos: treat that verdict as a weaker signal.

Provenance

We also read the file before the image check. result.provenance holds its Content Credentials summary and IPTC digital source type when it has them, and result.metadata what its EXIF says. When a verified manifest from Google or OpenAI declares the image AI-generated, labelSource is content_credentials.

Suggested handling

  • ai_generated: hold, label or reject the image, as your policy says.
  • inconclusive: send it to human review, or ask for another photo.
  • not_ai_generated: carry on. It is not proof that a photo is authentic.

How well it works: measured accuracy and the TrueEye 2.3 model card.

result, abridged · Illustrative values
result
{  "result": {    "label": "ai_generated",    "inconclusiveReason": null,    "labelSource": "detector",    "coarse": "high",    "score": 0.9731,    "thresholds": null,    "provenance": {      "c2pa": null,      "iptcDigitalSourceType": null    },    "metadata": {      "exifPresent": false,      "cameraMake": null,      "software": null    },    "detector": {      "model": "TrueEye",      "version": "2.3",      "cropScores": null,      "photoLike": true,      "mock": false    },    "autoCrop": {      "enabled": true,      "applied": false,      "box": null,      "imageWidth": 2400,      "imageHeight": 1018    }  }}

Waiting and polling

A scan takes a few seconds. You choose how to wait for it:

  • Wait in the call. Send Prefer: wait=N (up to 30 seconds). If the scan finishes in time, you get 200 with the verdict and Preference-Applied: wait=N.
  • Poll. Without Prefer, or when the wait runs out, you get 202 Accepted with the scan so far, a Location header and Retry-After: 2. Read Location every 2 seconds until status is completed or failed.
Your serverPerspicax
  1. POST /api/v1/scansPrefer: wait=30
  2. 200 OK, the verdictDone in time
  3. Not done in time, or no wait?
  4. 202 AcceptedLocation, Retry-After: 2
  5. GET the LocationEvery 2 s
  6. 200 OK, completedOr failed: send again

Up to 16 requests of one workspace can wait at the same time; beyond that, the answer is the 202 at once. Polls count against the rate limit, so prefer a wait to a tight loop.

A scan that ends failed was not counted. Send the image again with a new Idempotency-Key.

Send without waiting, then poll
scan=$(curl -s https://perspicax.io/api/v1/scans \  -H "Authorization: Bearer $PERSPICAX_API_KEY" \  -H "Idempotency-Key: $(uuidgen)" \  -F "image=@harbour.jpg")while echo "$scan" | jq -e '.status == "queued" or .status == "running"' > /dev/null; do  sleep 2  scan=$(curl -s https://perspicax.io/api/v1/scans/$(echo "$scan" | jq -r .id) \    -H "Authorization: Bearer $PERSPICAX_API_KEY")doneecho "$scan" | jq '{status, label: .result.label, score: .result.score}'

The Scan object

Every scan endpoint answers with a Scan. Every field is always present: those marked “or null” are null when they don’t apply. Click a field with fields inside to open it.

  • idstring (UUID)

    The scan’s id. Read it again with GET /api/v1/scans/{id}.

  • statusstring, enum

    queued and running while the check runs, then completed (with result) or failed (not counted: send the image again with a new Idempotency-Key).

    Valuesqueuedrunningcompletedfailed

  • sourcestring, enum

    Where the scan was made: landing (the website), app, or api (API keys and the Developers playground).

    Valueslandingappapi

  • modestring, enum

    test: a development-mode answer (an earlier result repeated). live: a real check (the app, the website, production keys).

    Valuestestlive

  • replayOfstring (UUID) or null

    Development mode: the id of the earlier scan whose stored result this answer repeats. null in production mode.

  • modelobject

    The detector that answered.

    3 fields inside
    • idstring

      The model id, what you send as model: trueeye-2.3 by default.

    • namestring

      Always TrueEye.

    • versionstring

      The model version, such as 2.3.

  • createdAtstring (date-time)

    When the scan was created (ISO 8601, UTC).

  • completedAtstring (date-time) or null

    When the scan finished; null until then.

  • imageobject

    The file we received.

    7 fields inside
    • sha256string

      SHA-256 of the exact bytes received, in hex. Development mode recognises images by this hash.

    • widthinteger or null

      Width as displayed (EXIF orientation applied), in pixels; null when it could not be read.

    • heightinteger or null

      Height as displayed, in pixels; null when it could not be read.

    • mimestring, enum

      The format, from the file’s bytes (not its name).

      Valuesimage/jpegimage/pngimage/webpimage/heicimage/heifimage/avifimage/gif

    • bytesinteger

      File size in bytes.

    • filenamestring or null

      The file name you sent (cleaned), or the last part of the URL.

    • previewUrlstring (URL) or null

      A link, valid for 5 minutes, to a display copy: WebP, at most 2048 px on the longest side, every piece of metadata removed. Never the original. null in development mode and when no copy can be made.

  • resultobject or null

    The verdict. null until the scan is completed.

    10 fields inside
    • labelstring, enum

      The verdict: ai_generated, inconclusive or not_ai_generated. Build your logic on this field.

      Valuesai_generatedinconclusivenot_ai_generated

    • inconclusiveReasonstring, enum or null

      between_thresholds when the label is inconclusive because the result sits between a clear AI and a clear real answer; null otherwise. (outside_scope appears only on scans made before 28 October 2026.)

      Valuesbetween_thresholdsoutside_scope

    • labelSourcestring, enum

      detector when the image check decided the label; content_credentials when a verified manifest from Google or OpenAI declares the image AI-generated. score is filled in either way.

      Valuesdetectorcontent_credentials

    • coarsestring, enum or null

      The band that goes with the label: low (likely real), middle (inconclusive) or high (likely AI). null only on old outside-scope scans.

      Valueslowmiddlehigh

    • scorenumber or null

      From 0 to 1, rounded to 4 decimals: higher means stronger signs of AI generation. It is a score, not a probability, and the cut-offs between the bands are not published (they can change with the model), so base decisions on label. The exact score comes with production access: in development mode on Free it is null (the label and coarse band are there).

    • thresholdsnull

      Reserved. Always null in API answers.

    • provenanceobject

      Provenance found in the file, read before the image check.

      2 fields inside
      • c2paobject or null

        The Content Credentials manifest, or null when the file has none.

        5 fields inside
        • presentboolean

          Always true when the object is there.

        • claimGeneratorstring or null

          The software that wrote the manifest.

        • issuerstring or null

          Who signed the manifest.

        • actionsarray of strings

          The actions the manifest records, such as c2pa.created.

        • digitalSourceTypestring or null

          The IPTC digital source type the manifest declares.

      • iptcDigitalSourceTypestring or null

        The IPTC digital source type from the file’s metadata, or null.

    • metadataobject

      Camera metadata found in the file.

      3 fields inside
      • exifPresentboolean

        true when the file has EXIF data.

      • cameraMakestring or null

        The camera maker from EXIF, or null.

      • softwarestring or null

        The software from EXIF, or null.

    • detectorobject

      The model that answered, by its public name.

      5 fields inside
      • modelstring

        Always TrueEye.

      • versionstring

        The model version, such as 2.3.

      • cropScoresnull

        Reserved. Always null in API answers.

      • photoLikeboolean

        false when the image looks like a screenshot, poster, document or artwork rather than a photo. It still gets a verdict; treat it as a weaker signal, because the model is built for photos.

      • mockboolean

        Always false on perspicax.io. true only on test servers that use a stand-in detector.

    • autoCropobject or null

      What auto-crop did; null on scans made before auto-crop existed.

      5 fields inside
      • enabledboolean

        What you asked for: autoCrop, true unless you sent false.

      • appliedboolean

        true when borders or app UI were found and left out.

      • boxobject or null

        The region that was kept and checked, in pixels of the image as displayed (EXIF orientation applied); null when nothing was left out.

        4 fields inside
        • xinteger

          Left edge of the checked region, in pixels.

        • yinteger

          Top edge of the checked region, in pixels.

        • widthinteger

          Width of the checked region, in pixels.

        • heightinteger

          Height of the checked region, in pixels.

      • imageWidthinteger

        Width of the whole image as displayed, in pixels.

      • imageHeightinteger

        Height of the whole image as displayed, in pixels.

  • usageobject

    What this scan cost.

    2 fields inside
    • countedboolean

      true when the scan used your quota: a completed production scan. false in development mode, for failed scans and for Idempotency-Key retries.

    • cachedboolean

      true when nothing was checked for this answer: a development-mode answer, or a retry with the same Idempotency-Key.

  • latencyMsinteger or null

    Time from upload to verdict, in milliseconds; null until the scan finishes.

  • shareobject or null

    The scan’s public result page, when it is shared (share=true, or POST /api/v1/scans/{id}/share); null otherwise. Always null on a development-mode answer.

    1 field inside
    • urlstring (URL)

      The page, https://perspicax.io/r/<slug>. Its result card is <url>/card.png and its printable report <url>/report.pdf.

A completed production scan · Illustrative values
Scan · application/json
{  "id": "6f1c2d3e-4b5a-4c7d-8e9f-0a1b2c3d4e5f",  "status": "completed",  "source": "api",  "mode": "live",  "replayOf": null,  "model": {    "id": "trueeye-2.3",    "name": "TrueEye",    "version": "2.3"  },  "createdAt": "2026-10-28T09:12:47.210Z",  "completedAt": "2026-10-28T09:12:49.350Z",  "image": {    "sha256": "f750730fb99d9aaf619583559e175ec3069aceb2b10fc3d2565148ce28b6b596",    "width": 2400,    "height": 1018,    "mime": "image/jpeg",    "bytes": 299028,    "filename": "harbour.jpg",    "previewUrl": "https://perspicax.io/api/scans/6f1c2d3e-4b5a-4c7d-8e9f-0a1b2c3d4e5f/image?exp=1761643067&sig=Q2xlYXJseUlsbHVzdHJhdGl2ZQ"  },  "result": {    "label": "ai_generated",    "inconclusiveReason": null,    "labelSource": "detector",    "coarse": "high",    "score": 0.9731,    "thresholds": null,    "provenance": {      "c2pa": null,      "iptcDigitalSourceType": null    },    "metadata": {      "exifPresent": false,      "cameraMake": null,      "software": null    },    "detector": {      "model": "TrueEye",      "version": "2.3",      "cropScores": null,      "photoLike": true,      "mock": false    },    "autoCrop": {      "enabled": true,      "applied": false,      "box": null,      "imageWidth": 2400,      "imageHeight": 1018    }  },  "usage": {    "counted": true,    "cached": false  },  "latencyMs": 2140,  "share": {    "url": "https://perspicax.io/r/Xq7Lm2Pz9Rt4Vb6N"  }}

Create a scan

POST/api/v1/scans

Send one image, as a file (multipart/form-data, field image) or as a link (JSON { "url": … }). A production key checks it with TrueEye and uses your quota; a development key answers at once with the stored result of an image your workspace already scanned, and uses none. In production, add Prefer: wait=N to get the verdict in the same call, and share=true for a public result page. An Idempotency-Key makes retries safe in both modes.

With a development key, the image must be one your workspace already scanned, and the answer is always a 200 at once (see Development mode). In production, the verdict comes in the same call with Prefer: wait; otherwise poll the Location of the 202 (see Waiting and polling).

Headers

  • PreferstringOptional

    Production mode: wait=N keeps the request open up to N seconds (at most 30) while the scan runs: a scan done in time is answered 200 with Preference-Applied. Without it, or when the time runs out, the answer is 202 with Location to poll. Development mode ignores it: its answer is always an immediate 200.

  • Idempotency-KeystringOptional

    Makes retries safe: the same key with the same request returns the same scan, without a second charge, for 24 hours. 1 to 255 visible ASCII characters; a UUID is ideal.

  • X-Request-IdstringOptional

    Your own id for this request (8 to 64 letters, digits, dots, colons, dashes or underscores). We use it as the request id; otherwise we make one. Never send a key here: an id that looks like one (pcx_…) is replaced, and never stored.

Body: a file

multipart/form-data, the image in the field image.

  • imagefileRequired

    The image file, at most 20 MB: JPEG, PNG, WebP, HEIC, HEIF, AVIF, GIF (still).

  • modelstringOptionalDefault trueeye-2.3

    A model id from GET /api/v1/models; trueeye-2.3 when absent. An unknown id is 422 validation_failed naming the allowed ids.

  • autoCropbooleanOptionalDefault true

    Borders and app UI around the photo (for example a social post screenshot) are left out, and only the photo is checked; result.autoCrop.box says what was kept. false checks the whole image as sent. auto_crop is accepted too. In a form, send true or false.

  • sharebooleanOptionalDefault false

    Production mode: true also makes the scan’s public result page, also on a 202; its link comes back as share.url. Development mode accepts it and makes no page: its answers repeat an earlier result.

Body: a link

application/json.

  • urlstring (URL)Required

    A public https:// link to the image file. We follow up to 3 redirects and give the download 15 seconds; private and local addresses are refused.

  • modelstringOptionalDefault trueeye-2.3

    A model id from GET /api/v1/models; trueeye-2.3 when absent. An unknown id is 422 validation_failed naming the allowed ids.

  • autoCropbooleanOptionalDefault true

    Borders and app UI around the photo (for example a social post screenshot) are left out, and only the photo is checked; result.autoCrop.box says what was kept. false checks the whole image as sent. auto_crop is accepted too.

  • sharebooleanOptionalDefault false

    Production mode: true also makes the scan’s public result page, also on a 202; its link comes back as share.url. Development mode accepts it and makes no page: its answers repeat an earlier result.

Send one or the other: a request with both, or with neither, gets 400 validation_failed.

Returns

  • 200The scan is done: in production within your Prefer: wait, and always in development mode (a stored result).
  • 202Accepted and still running: poll Location (every Retry-After seconds) until status is completed or failed.
  • 400The request is not valid.validation_failed
  • 401The key is missing, malformed, unknown or revoked, or the member who created it has left the workspace.unauthorized
  • 402Production mode is not on this plan, or the quota is used up.upgrade_requiredquota_exceeded
  • 403The API is switched off for this workspace, or the call bypassed https://perspicax.io. Or the member who created the key may not scan, or (development mode) may not see the workspace’s scans.forbidden
  • 409The first request with this Idempotency-Key is still creating its scan.validation_failed
  • 413The image is over the size limit.file_too_large
  • 415Not a supported still image.unsupported_media_type
  • 422Development mode does not know the image, the image can’t be read, a field is invalid, or the Idempotency-Key was reused.unknown_test_imageunprocessable_imagevalidation_failedidempotency_key_reused
  • 429Too many requests with this key, or over a development-mode limit.rate_limiteddevelopment
  • 500Something went wrong on our side; nothing was counted.internal
  • 503The detector is briefly unavailable; nothing was counted.detector_unavailable
From a file
curl https://perspicax.io/api/v1/scans \  -H "Authorization: Bearer $PERSPICAX_API_KEY" \  -H "Prefer: wait=30" \  -H "Idempotency-Key: $(uuidgen)" \  -F "image=@harbour.jpg"
From a link
curl https://perspicax.io/api/v1/scans \  -H "Authorization: Bearer $PERSPICAX_API_KEY" \  -H "Content-Type: application/json" \  -H "Prefer: wait=30" \  -H "Idempotency-Key: $(uuidgen)" \  -d '{"url":"https://perspicax.io/images/harbour.jpg"}'
Production key: a completed scan
200 OK · application/json
{  "id": "6f1c2d3e-4b5a-4c7d-8e9f-0a1b2c3d4e5f",  "status": "completed",  "source": "api",  "mode": "live",  "replayOf": null,  "model": {    "id": "trueeye-2.3",    "name": "TrueEye",    "version": "2.3"  },  "createdAt": "2026-10-28T09:12:47.210Z",  "completedAt": "2026-10-28T09:12:49.350Z",  "image": {    "sha256": "f750730fb99d9aaf619583559e175ec3069aceb2b10fc3d2565148ce28b6b596",    "width": 2400,    "height": 1018,    "mime": "image/jpeg",    "bytes": 299028,    "filename": "harbour.jpg",    "previewUrl": "https://perspicax.io/api/scans/6f1c2d3e-4b5a-4c7d-8e9f-0a1b2c3d4e5f/image?exp=1761643067&sig=Q2xlYXJseUlsbHVzdHJhdGl2ZQ"  },  "result": {    "label": "ai_generated",    "inconclusiveReason": null,    "labelSource": "detector",    "coarse": "high",    "score": 0.9731,    "thresholds": null,    "provenance": {      "c2pa": null,      "iptcDigitalSourceType": null    },    "metadata": {      "exifPresent": false,      "cameraMake": null,      "software": null    },    "detector": {      "model": "TrueEye",      "version": "2.3",      "cropScores": null,      "photoLike": true,      "mock": false    },    "autoCrop": {      "enabled": true,      "applied": false,      "box": null,      "imageWidth": 2400,      "imageHeight": 1018    }  },  "usage": {    "counted": true,    "cached": false  },  "latencyMs": 2140,  "share": null}

Retrieve a scan

GET/api/v1/scans/{id}

One scan of your workspace, in any state: check status. This is the URL a 202 points to in Location. A failed scan has result: null and was not counted. A development key reads development-mode answers only.

Path

  • idstring (UUID)Required

    The scan’s id.

Returns

  • 200The scan.
  • 400The id is not a scan id (a UUID).validation_failed
  • 404No such scan in your workspace, or not one this key reads: another mode’s, or one the key’s creator may not see.not_found
  • 401The key is missing, malformed, unknown or revoked, or the member who created it has left the workspace.unauthorized
  • 402A production key of a workspace without production mode (Free).upgrade_required
  • 403The API is switched off for this workspace, or the call bypassed https://perspicax.io.forbidden
  • 429Too many requests with this key.rate_limited
GET /api/v1/scans/{id}
curl https://perspicax.io/api/v1/scans/6f1c2d3e-4b5a-4c7d-8e9f-0a1b2c3d4e5f \  -H "Authorization: Bearer $PERSPICAX_API_KEY"
A completed scan
200 OK · application/json
{  "id": "6f1c2d3e-4b5a-4c7d-8e9f-0a1b2c3d4e5f",  "status": "completed",  "source": "api",  "mode": "live",  "replayOf": null,  "model": {    "id": "trueeye-2.3",    "name": "TrueEye",    "version": "2.3"  },  "createdAt": "2026-10-28T09:12:47.210Z",  "completedAt": "2026-10-28T09:12:49.350Z",  "image": {    "sha256": "f750730fb99d9aaf619583559e175ec3069aceb2b10fc3d2565148ce28b6b596",    "width": 2400,    "height": 1018,    "mime": "image/jpeg",    "bytes": 299028,    "filename": "harbour.jpg",    "previewUrl": "https://perspicax.io/api/scans/6f1c2d3e-4b5a-4c7d-8e9f-0a1b2c3d4e5f/image?exp=1761643067&sig=Q2xlYXJseUlsbHVzdHJhdGl2ZQ"  },  "result": {    "label": "ai_generated",    "inconclusiveReason": null,    "labelSource": "detector",    "coarse": "high",    "score": 0.9731,    "thresholds": null,    "provenance": {      "c2pa": null,      "iptcDigitalSourceType": null    },    "metadata": {      "exifPresent": false,      "cameraMake": null,      "software": null    },    "detector": {      "model": "TrueEye",      "version": "2.3",      "cropScores": null,      "photoLike": true,      "mock": false    },    "autoCrop": {      "enabled": true,      "applied": false,      "box": null,      "imageWidth": 2400,      "imageHeight": 1018    }  },  "usage": {    "counted": true,    "cached": false  },  "latencyMs": 2140,  "share": null}

List scans

GET/api/v1/scans

The workspace’s scans, newest first, one page at a time: follow nextCursor until it is null. A development key lists development-mode answers only; a production key lists every other scan of the workspace (the app, the website and production keys).

Filter with status, label, source, from and to, and page with limit and cursor (see Pagination).

Query

  • limitintegerOptional

    How many scans per page: 1 to 100, 20 by default.

  • cursorstringOptional

    The nextCursor of the previous page. Omit it for the first page.

  • statusstring, enumOptional

    Only scans in this state.

  • labelstring, enumOptional

    Only scans with this verdict.

  • sourcestring, enumOptional

    Only scans made on the website (landing), in the app or with the api.

  • fromstring (date-time)Optional

    Only scans created at or after this time (ISO 8601).

  • tostring (date-time)Optional

    Only scans created before this time (ISO 8601).

Returns

  • 200A page of scans.
  • 400A query parameter or the cursor is not valid.validation_failed
  • 401The key is missing, malformed, unknown or revoked, or the member who created it has left the workspace.unauthorized
  • 402A production key of a workspace without production mode (Free).upgrade_required
  • 403The API is switched off for this workspace, or the call bypassed https://perspicax.io. Or the member who created the key may not see the workspace’s scans.forbidden
  • 429Too many requests with this key.rate_limited
GET /api/v1/scans
curl "https://perspicax.io/api/v1/scans?limit=20" \  -H "Authorization: Bearer $PERSPICAX_API_KEY"
A page of scans
200 OK · application/json
{  "items": [    {      "id": "6f1c2d3e-4b5a-4c7d-8e9f-0a1b2c3d4e5f",      "status": "completed",      "source": "api",      "mode": "live",      "replayOf": null,      "model": {        "id": "trueeye-2.3",        "name": "TrueEye",        "version": "2.3"      },      "createdAt": "2026-10-28T09:12:47.210Z",      "completedAt": "2026-10-28T09:12:49.350Z",      "image": {        "sha256": "f750730fb99d9aaf619583559e175ec3069aceb2b10fc3d2565148ce28b6b596",        "width": 2400,        "height": 1018,        "mime": "image/jpeg",        "bytes": 299028,        "filename": "harbour.jpg",        "previewUrl": "https://perspicax.io/api/scans/6f1c2d3e-4b5a-4c7d-8e9f-0a1b2c3d4e5f/image?exp=1761643067&sig=Q2xlYXJseUlsbHVzdHJhdGl2ZQ"      },      "result": {        "label": "ai_generated",        "inconclusiveReason": null,        "labelSource": "detector",        "coarse": "high",        "score": 0.9731,        "thresholds": null,        "provenance": {          "c2pa": null,          "iptcDigitalSourceType": null        },        "metadata": {          "exifPresent": false,          "cameraMake": null,          "software": null        },        "detector": {          "model": "TrueEye",          "version": "2.3",          "cropScores": null,          "photoLike": true,          "mock": false        },        "autoCrop": {          "enabled": true,          "applied": false,          "box": null,          "imageWidth": 2400,          "imageHeight": 1018        }      },      "usage": {        "counted": true,        "cached": false      },      "latencyMs": 2140,      "share": null    },    {      "id": "0b8e4f27-3c1d-4a6e-9f52-7e1a3d9c4b88",      "status": "completed",      "source": "api",      "mode": "live",      "replayOf": null,      "model": {        "id": "trueeye-2.3",        "name": "TrueEye",        "version": "2.3"      },      "createdAt": "2026-10-28T09:12:47.210Z",      "completedAt": "2026-10-28T09:12:49.080Z",      "image": {        "sha256": "a828aec688f64ca7fb9fb51418eccfd658d4592ef8ed8370d519086b2eb68762",        "width": 928,        "height": 1152,        "mime": "image/jpeg",        "bytes": 201286,        "filename": "kebab.jpg",        "previewUrl": "https://perspicax.io/api/scans/0b8e4f27-3c1d-4a6e-9f52-7e1a3d9c4b88/image?exp=1761643067&sig=Q2xlYXJseUlsbHVzdHJhdGl2ZQ"      },      "result": {        "label": "not_ai_generated",        "inconclusiveReason": null,        "labelSource": "detector",        "coarse": "low",        "score": 0.0318,        "thresholds": null,        "provenance": {          "c2pa": null,          "iptcDigitalSourceType": null        },        "metadata": {          "exifPresent": false,          "cameraMake": null,          "software": null        },        "detector": {          "model": "TrueEye",          "version": "2.3",          "cropScores": null,          "photoLike": true,          "mock": false        },        "autoCrop": {          "enabled": true,          "applied": false,          "box": null,          "imageWidth": 928,          "imageHeight": 1152        }      },      "usage": {        "counted": true,        "cached": false      },      "latencyMs": 1870,      "share": null    }  ],  "nextCursor": "MjAyNi0xMC0yOFQwODo1NTowMy4xMThafDBiOGU0ZjI3LTNjMWQtNGE2ZS05ZjUyLTdlMWEzZDljNGI4OA"}

Share a scan

POST/api/v1/scans/{id}/share

Makes the scan’s public result page, or returns the one it already has: each scan has one link. Anyone with the link sees the photo and the verdict, never the score, the file name or who shared it. A scan that failed and a development-mode answer cannot be shared.

See Public result pages for what the page shows.

Path

  • idstring (UUID)Required

    The scan’s id.

Returns

  • 201The page was made now.
  • 200The scan was already shared: the same link.
  • 400The scan failed, or it is a development-mode answer: neither has a public page. Or the id is not a scan id (a UUID).faileddevelopmentmalformed
  • 404No such scan in your workspace, or not one this key reads: another mode’s, or one the key’s creator may not see.not_found
  • 401The key is missing, malformed, unknown or revoked, or the member who created it has left the workspace.unauthorized
  • 402A production key of a workspace without production mode (Free).upgrade_required
  • 403The API is switched off for this workspace, or the call bypassed https://perspicax.io.forbidden
  • 429Too many requests with this key.rate_limited

Stop sharing a scan

DELETE/api/v1/scans/{id}/share

The public link answers 404 from now on. Also fine when the scan was not shared.

Path

  • idstring (UUID)Required

    The scan’s id.

Returns

  • 200Not shared any more.
  • 400The id is not a scan id (a UUID).validation_failed
  • 404No such scan in your workspace, or not one this key reads: another mode’s, or one the key’s creator may not see.not_found
  • 401The key is missing, malformed, unknown or revoked, or the member who created it has left the workspace.unauthorized
  • 402A production key of a workspace without production mode (Free).upgrade_required
  • 403The API is switched off for this workspace, or the call bypassed https://perspicax.io.forbidden
  • 429Too many requests with this key.rate_limited
POST /api/v1/scans/{id}/share
curl -X POST https://perspicax.io/api/v1/scans/6f1c2d3e-4b5a-4c7d-8e9f-0a1b2c3d4e5f/share \  -H "Authorization: Bearer $PERSPICAX_API_KEY"
The public page
201 Created · application/json
{  "url": "https://perspicax.io/r/Xq7Lm2Pz9Rt4Vb6N"}
DELETE /api/v1/scans/{id}/share
curl -X DELETE https://perspicax.io/api/v1/scans/6f1c2d3e-4b5a-4c7d-8e9f-0a1b2c3d4e5f/share \  -H "Authorization: Bearer $PERSPICAX_API_KEY"
Done
200 OK · application/json
{  "ok": true}

Get usage

GET/api/v1/usage

The workspace’s quota, the numbers the app shows: Free’s and Pro’s scans of the UTC day (Pro’s are shared with the app), or the Enterprise pool of the contract period. Any key can read it, in either mode, with the X-Quota-* headers. Development mode never uses this quota, and reading it uses none.

Reading it counts toward the key’s rate limit only. How quotas work: Quotas and plans.

Returns

  • 200The workspace’s quota.
  • 401The key is missing, malformed, unknown or revoked, or the member who created it has left the workspace.unauthorized
  • 402A production key of a workspace without production mode (Free).upgrade_required
  • 403The API is switched off for this workspace, or the call bypassed https://perspicax.io.forbidden
  • 429Too many requests with this key.rate_limited
  • planstring, enum

    The workspace’s plan.

    Valuesfreeproenterprise

  • limitinteger

    The scans in the period: Free and Pro, the UTC day’s; Enterprise, the pool of the contract period.

  • usedinteger

    The scans used in the period.

  • remaininginteger

    The scans left in the period.

  • windowstring, enum

    The period: day on Free and Pro (a UTC day); on Enterprise, the contract’s day, week, month or year.

    Valuesdayweekmonthyear

  • resetsAtstring (date-time) or null

    When the period restarts (ISO 8601, UTC): the next 00:00 UTC, or the end of the contract period.

  • needsSignupboolean

    Always false for a workspace (it is for signed-out visitors of the website).

  • needsUpgradeboolean

    true on Free when the day’s scans are used up: Pro would allow more.

  • overageobject or null

    Enterprise only: scans past the pool, as set in the organisation’s contract; null otherwise.

    5 fields inside
    • enabledboolean

      true when the organisation’s contract allows scans past the pool.

    • usedinteger

      Scans past the pool in this period (a count).

    • capinteger or null

      The most scans past the pool the contract allows in a period; null for no cap.

    • pricePerScannumber or null

      Always null in API answers: the price is in the organisation’s contract, and members who manage billing see it in the app.

    • amountDuenumber or null

      Always null in API answers: what the overage comes to is shown in the app to members who manage billing.

  • blockedstring, enum or null

    Enterprise only: why no scan can run whatever the numbers say (the organisation is being set up, no contract, not started yet, paused or ended); null when scans may run.

    Valuessetting_upno_contractnot_startedpausedended

GET /api/v1/usage
curl https://perspicax.io/api/v1/usage \  -H "Authorization: Bearer $PERSPICAX_API_KEY"
A Pro workspace
200 OK · application/json
{  "plan": "pro",  "limit": 35,  "used": 12,  "remaining": 23,  "window": "day",  "resetsAt": "2026-10-29T00:00:00.000Z",  "needsSignup": false,  "needsUpgrade": false,  "overage": null,  "blocked": null}

List models

GET/api/v1/models

The detector models a scan can ask for with model. Without model you get the default, trueeye-2.3. A good first call to check your key.

Ids of earlier releases are still accepted and run the current release.

Returns

  • 200The models.
  • 401The key is missing, malformed, unknown or revoked, or the member who created it has left the workspace.unauthorized
  • 402A production key of a workspace without production mode (Free).upgrade_required
  • 403The API is switched off for this workspace, or the call bypassed https://perspicax.io.forbidden
  • 429Too many requests with this key.rate_limited
  • idstring, enum

    What you send as model; scans return it as model.id.

    Valuestrueeye-2.3

  • namestring

    The display name.

  • descriptionstring

    What the model is for, in one line.

  • defaultboolean

    true for the model used when a request names none.

GET /api/v1/models
curl https://perspicax.io/api/v1/models \  -H "Authorization: Bearer $PERSPICAX_API_KEY"
Models
200 OK · application/json
{  "items": [    {      "id": "trueeye-2.3",      "name": "TrueEye 2.3",      "description": "Detects fully AI-generated, photo-like still images.",      "default": true    }  ]}

Auto-crop

Screenshots carry more than the photo. With autoCrop on, as it is by default, borders and app UI around the photo (for example a social post screenshot: the status bar, header, caption, buttons and the next post peeking in) are left out, and only the photo is checked.

result.autoCrop says what happened: applied is true when something was left out, and box is the region that was kept, in pixels of the image as displayed. Use it to show your users what was checked.

It does not catch every screenshot: a dark photo in a dark app, for example, may stay uncropped. Send autoCrop=false to check the whole image exactly as sent.

Checked: result.autoCrop.boxLeft out
result.autoCrop on a social post screenshot · Illustrative values
result.autoCrop
{  "autoCrop": {    "enabled": true,    "applied": true,    "box": {      "x": 0,      "y": 486,      "width": 1170,      "height": 1462    },    "imageWidth": 1170,    "imageHeight": 2532  }}

Public result pages

A scan can have a public page that anyone with the link can open: the photo, the verdict and the model, never the score, the file name or who shared it. Search engines don’t index these pages.

Send share=true with a production key when you create the scan, as a form field or a JSON key, or call POST /api/v1/scans/{id}/share later. The link comes back as share.url, also on a 202. Each scan has one link. Development mode accepts share and makes no page, because its answers repeat an earlier result: share the original scan instead.

Next to the page are its result card, card.png, and a printable report, report.pdf. To stop sharing, send DELETE to the same path: the link answers 404 from then on.

POST /api/v1/scans · share=true
curl https://perspicax.io/api/v1/scans \  -H "Authorization: Bearer $PERSPICAX_API_KEY" \  -H "Prefer: wait=30" \  -H "Idempotency-Key: $(uuidgen)" \  -F "image=@harbour.jpg" \  -F "share=true"
Scan.share
share
{  "share": {    "url": "https://perspicax.io/r/Xq7Lm2Pz9Rt4Vb6N"  }}

Headers

Request headers

Request headers
AuthorizationBearer and your key. Required on every request.
Content-Typemultipart/form-data for a file, application/json for a link. Your HTTP client usually sets it.
PreferProduction mode: wait=N keeps the request open up to N seconds (at most 30) while the scan runs: a scan done in time is answered 200 with Preference-Applied. Without it, or when the time runs out, the answer is 202 with Location to poll. Development mode ignores it: its answer is always an immediate 200.
Idempotency-KeyMakes retries safe: the same key with the same request returns the same scan, without a second charge, for 24 hours. 1 to 255 visible ASCII characters; a UUID is ideal.
X-Request-IdYour own id for this request (8 to 64 letters, digits, dots, colons, dashes or underscores). We use it as the request id; otherwise we make one. Never send a key here: an id that looks like one (pcx_…) is replaced, and never stored.

Response headers

Response headers
X-Request-IdThis request’s id. Every answer has one; problems repeat it as requestId.
RateLimit-LimitRequests allowed per window for this key.
RateLimit-RemainingRequests left in the current window.
RateLimit-ResetSeconds until the window starts again.
RateLimit-PolicyThe policy, such as 60;w=60: 60 requests per 60 seconds.
Retry-AfterSeconds to wait before polling (202) or retrying (409, 429, 503).
LocationThe scan to poll: /api/v1/scans/{id}.
Preference-Appliedwait=N when your Prefer: wait was honoured.
WWW-AuthenticateWith 401: Bearer realm="perspicax", error="invalid_token".
X-Quota-LimitProduction answers (errors included) and GET /api/v1/usage, always: the scans in the period (Free and Pro: the UTC day’s; Enterprise: the pool).
X-Quota-RemainingThe same answers: the scans left in the period.
X-Quota-ResetThe same answers: when the period restarts (ISO 8601, UTC): 00:00 UTC, or the end of the Enterprise contract period.
X-Quota-Overage-UsedThe same answers, when the organisation’s contract has overage on: the overage scans used in the period (a count).

Header names are case-insensitive. Every answer also carries the usual Content-Type: application/json, or application/problem+json for errors.

Errors

Errors are RFC 9457 problem details, sent as application/problem+json. Branch on code: it never changes. detail is a sentence for people and can change.

Every problem has type (a link to its documentation), title, status, code, detail, instance (the path you called) and requestId. Some add more: errors lists the fields at fault, retryAfter says how long to wait, quota says where you stand, suggestedPlan names the plan that allows the request, docs links to the part of these docs that explains it, and sha256 is the hash of an image development mode does not know.

When to retry

  • 429 and 503: wait Retry-After seconds, then retry.
  • 409: wait Retry-After, then retry the same request with the same Idempotency-Key.
  • 500: retry once with the same key; if it fails again, use a new key.
  • Any other 4xx: fix the request first. Sent again unchanged, it gets the same answer.

Every error code

  • validation_failed400422409Invalid request
    When
    400: no image and no URL, or both; a URL that is not a public https:// address; an empty file; a body that can’t be parsed; an Idempotency-Key that is not 1 to 255 visible ASCII characters; an invalid cursor or list filter; sharing a scan that failed, or a development-mode answer (it has no public page). 422: an unknown model, or autoCrop or share that is not true or false (errors names the field). 409: a retry while the first request with the same Idempotency-Key is still creating its scan.
    What to do
    Fix the request: detail and errors say what is wrong. On a 409, wait Retry-After (1 s) and send the same request with the same key, or poll Location.
  • unauthorized401Sign-in required
    When
    The Authorization header is missing or malformed, or the key is unknown or revoked, or the member who created it has left the workspace (or was removed or suspended). The answer carries WWW-Authenticate.
    What to do
    Send Authorization: Bearer pcx_test_… or Bearer pcx_live_…. If the key was revoked or its creator left, create a new one in the app.
  • upgrade_required402Upgrade required
    When
    Any request with a production key (pcx_live_) of a workspace without production mode: Free, for example after a downgrade. Creating one in the app on Free is refused the same way. The answer carries suggestedPlan: "pro" and a docs link.
    What to do
    Upgrade to Pro, or use a development key (pcx_test_) with images you have already scanned. The key works again when the plan has production mode.
  • quota_exceeded402Scan limit reached
    When
    Production mode: the workspace’s quota for the period is used up. Pro: the 35 scans of the UTC day, shared with the app. Enterprise: the organisation’s pool for the contract period, and any overage set in its contract; or the organisation is still being set up, or its contract is paused, ended or not started yet (quota.blocked says which). The answer carries quota.
    What to do
    Wait until quota.resetsAt (Pro: 00:00 UTC). On Enterprise, the pool and any overage are set in the organisation’s contract: write to us to change them. Development mode keeps working.
  • forbidden403Forbidden
    When
    We switched the API off for this workspace (detail says so), or the request did not come through https://perspicax.io. In an organisation, a key acts with the permissions of the member who created it: it is also refused when they may not scan (creating scans), or may not see the workspace’s scans (listing them, and development mode).
    What to do
    Call https://perspicax.io. If the API was switched off, write to us to switch it back on. If the key’s creator lacks a permission, ask the organisation’s owner for it, or use a key of a member who has it.
  • not_found404Not found
    When
    No scan with this id in your workspace (or it was deleted), or the path does not exist. A development key reads development-mode scans only, and a key reads only the scans its creator may see (in an organisation: everyone’s, or only their own).
    What to do
    Check the id, the key and the path.
  • file_too_large413File too large
    When
    The file, or the image behind the URL, is larger than 20 MB.
    What to do
    Send a smaller file.
  • unsupported_media_type415Unsupported media type
    When
    Not a JPEG, PNG, WebP, HEIC, HEIF, AVIF, GIF (still) image (we check the bytes, not the name), an animated image, or a URL that does not serve an image.
    What to do
    Send a still image in a supported format.
  • unknown_test_image422Unknown image in development mode
    When
    A development key (pcx_test_) sent an image this workspace has never scanned: development mode recognises an image by the SHA-256 of its exact bytes. The answer carries that sha256 and a docs link.
    What to do
    Scan the image on perspicax.io first (in the app, or with a production key), then send the very same file. Re-saving, resizing or screenshotting a file changes its bytes. Or use a production key (Pro).
  • unprocessable_image422The image could not be read
    When
    The image can’t be decoded; it is smaller than 16 px on a side, larger than 50 megapixels or more than 32 times longer than wide; or the URL could not be downloaded (unknown host, an HTTP error, more than 3 redirects, over 15 seconds). Nothing is counted.
    What to do
    detail says which. Check the file or the URL.
  • idempotency_key_reused422Idempotency key reused with a different request
    When
    This Idempotency-Key was used in the last 24 hours with a different image, URL or options.
    What to do
    Use a new key for every new request. Reuse a key only to retry the very same request.
  • rate_limited429Too many requests
    When
    More than 60 requests a minute with this key (polls included), or more than 30 a minute from one IP address without a valid key. Development mode also allows 30 scan requests a minute per key, and 60 a minute and 1000 in 24 hours per workspace; its detail names the limit, with a docs link.
    What to do
    Wait Retry-After seconds, then retry. Prefer Prefer: wait=30 to a tight polling loop.
  • internal500Internal error
    When
    Something went wrong on our side. Nothing is counted.
    What to do
    Retry once with the same Idempotency-Key. If it fails again, the scan failed: retry with a new key, and write to us with the requestId if it keeps happening.
  • detector_unavailable503The detector is unavailable
    When
    The detector is briefly unavailable. Nothing is counted.
    What to do
    Wait Retry-After (30 s), then retry with a new Idempotency-Key (a failed scan keeps its key for 24 hours).

Every answer carries X-Request-Id, and every problem repeats it as requestId. Quote it when you write to contact@perspicax.io.

A problem
422 Unprocessable Content · application/problem+json
{  "type": "https://perspicax.io/problems/validation_failed",  "title": "Invalid request",  "status": 422,  "detail": "Unknown model \"trueeye-9\". Use one of: trueeye-2.3.",  "code": "validation_failed",  "instance": "/api/v1/scans",  "requestId": "b3f1c2d4-5e6f-4a7b-8c9d-0e1f2a3b4c5d",  "errors": [    {      "path": "model",      "message": "Allowed: trueeye-2.3"    }  ]}
quota_exceeded
402 Payment Required · application/problem+json
{  "type": "https://perspicax.io/problems/quota_exceeded",  "title": "Scan limit reached",  "status": 402,  "detail": "You've used today's 35 scans. They reset at 00:00 UTC.",  "code": "quota_exceeded",  "instance": "/api/v1/scans",  "requestId": "b3f1c2d4-5e6f-4a7b-8c9d-0e1f2a3b4c5d",  "quota": {    "plan": "pro",    "limit": 35,    "used": 35,    "remaining": 0,    "window": "day",    "resetsAt": "2026-10-29T00:00:00.000Z",    "needsSignup": false,    "needsUpgrade": false,    "overage": null,    "blocked": null  }}

Playground

Build a request and send it. The code follows every change, in every language.

Image
Send the image as
Key
Your plan
Options
30 s
POST /api/v1/scans
curl https://perspicax.io/api/v1/scans \  -H "Authorization: Bearer $PERSPICAX_API_KEY" \  -H "Prefer: wait=30" \  -H "Idempotency-Key: $(uuidgen)" \  -F "image=@harbour.jpg"

Send the request to see the status line, the headers and the JSON body.

Postman and OpenAPI

Everything in these docs is also a file. Import it into Postman, or into any tool that reads OpenAPI 3.1.

Set up Postman in five steps

  1. 1Download the two filesThe collection holds every request with example answers; the environment holds the base URL and your key.
  2. 2Import themIn Postman, choose Import and drop both files. The “Perspicax API v1” collection and the “Perspicax” environment appear.
  3. 3Add your keySelect the Perspicax environment and paste your key into apiKey, as its current value. It is a secret variable, so Postman masks it. Don’t share it.
  4. 4Send your first requestOpen Models › List models and send it: a 200 means your key works. Then open Scans › Create a scan from a file, choose your image in Body › form-data › image, and send.
  5. 5Read the answerThe verdict is in result.label. The collection keeps the scan’s id in scanId, so Retrieve a scan works straight away.
  6. Not using Postman?Using something else? Insomnia, Bruno, Hoppscotch and most code generators import the OpenAPI file directly.OpenAPI 3.1

Postman is a trademark of Postman, Inc. Perspicax is not affiliated with Postman.

Code samples

Every request in these docs comes in curl, JavaScript, Python, PHP and Go. Pick a language once: every sample on the page follows, and this browser remembers your choice.

The samples read your key from the environment variable PERSPICAX_API_KEY. JavaScript runs as an ES module (save it as a .mjs file) on Node.js 18 or later with the built-in fetch; Python needs the requests package, PHP 8 its curl extension, and Go only its standard library.

Working in another language? Most can generate a client from the OpenAPI file.

Pick a language: every sample follows
curl https://perspicax.io/api/v1/scans \  -H "Authorization: Bearer $PERSPICAX_API_KEY" \  -H "Prefer: wait=30" \  -H "Idempotency-Key: $(uuidgen)" \  -F "image=@harbour.jpg"

Rate limits

Each key can make 60 requests a minute, polls and reads included. Requests without a valid key are limited to 30 a minute per IP address.

Every answer tells you where you stand, in the RateLimit headers below. Over the limit you get 429 rate_limited with Retry-After: wait that long, then retry.

Rate limit headers
RateLimit-LimitRequests allowed per window for this key.
RateLimit-RemainingRequests left in the current window.
RateLimit-ResetSeconds until the window starts again.
RateLimit-PolicyThe policy, such as 60;w=60: 60 requests per 60 seconds.
Retry-AfterSeconds to wait before polling (202) or retrying (409, 429, 503).

Development mode adds limits of its own on POST /api/v1/scans: 30 requests a minute per key, and 60 a minute and 1000 in a rolling 24 hours per workspace. Its 429 names the limit that was hit, with retryAfter and a docs link. Spread bulk jobs out, and prefer Prefer: wait to tight polling.

Rate limit headers on an answer
HTTP/1.1 200Content-Type: application/json; charset=utf-8RateLimit-Limit: 60RateLimit-Remaining: 57RateLimit-Reset: 41RateLimit-Policy: 60;w=60X-Quota-Limit: 35X-Quota-Remaining: 23X-Quota-Reset: 2026-10-29T00:00:00.000ZX-Request-Id: b3f1c2d4-5e6f-4a7b-8c9d-0e1f2a3b4c5d
Over a development-mode limit
429 Too Many Requests · application/problem+json
{  "type": "https://perspicax.io/problems/rate_limited",  "title": "Too many requests",  "status": 429,  "detail": "Development mode allows 30 requests a minute per key. Try again in 17 s.",  "code": "rate_limited",  "instance": "/api/v1/scans",  "requestId": "b3f1c2d4-5e6f-4a7b-8c9d-0e1f2a3b4c5d",  "retryAfter": 17,  "docs": "https://perspicax.io/docs/api#development-mode"}

Quotas and plans

Development mode never uses quota. Production scans count against your workspace’s plan:

Free

API
Development mode
Scans
5 scans a day in the app. No production keys.

Pro

API
Development and production
Scans
35 scans a day (UTC), shared by the app and your production keys.

Enterprise

API
Development and production
Scans
A pool shared by the whole organisation for each contract period: a day, a week, a month or a year. Any overage is set in the organisation’s contract.

What counts

  • Every completed production scan counts, the same file sent again included.
  • Failed scans, development-mode answers and Idempotency-Key retries don’t.
  • Free’s and Pro’s day starts at 00:00 UTC; an Enterprise pool refills at the end of its contract period.

Production answers, and Get usage with any key, carry X-Quota-Limit, X-Quota-Remaining and X-Quota-Reset; when an organisation’s contract has overage on, X-Quota-Overage-Used counts the overage scans used. When the quota is used up, production scans get 402 quota_exceeded with a quota object, and development mode keeps working. A paused or ended Enterprise contract answers the same way, with quota.blocked set.

Plans and prices are on Pricing. For an Enterprise pool, contact us.

The quota is used up
402 Payment Required · application/problem+json
{  "type": "https://perspicax.io/problems/quota_exceeded",  "title": "Scan limit reached",  "status": 402,  "detail": "You've used today's 35 scans. They reset at 00:00 UTC.",  "code": "quota_exceeded",  "instance": "/api/v1/scans",  "requestId": "b3f1c2d4-5e6f-4a7b-8c9d-0e1f2a3b4c5d",  "quota": {    "plan": "pro",    "limit": 35,    "used": 35,    "remaining": 0,    "window": "day",    "resetsAt": "2026-10-29T00:00:00.000Z",    "needsSignup": false,    "needsUpgrade": false,    "overage": null,    "blocked": null  }}

Idempotency

Networks drop requests. Send an Idempotency-Key with every scan, and the same key again when you retry that request: a retry never creates or counts a second scan.

A key is 1 to 255 visible ASCII characters; a UUID is ideal. Keys are kept 24 hours and shared by all of your workspace’s API keys, so never reuse one for a different image.

What a request with a known key gets
The same key and the same requestThe same scan, with usage.cached: true. Nothing is counted again.
The same key and a different image, link or options422 idempotency_key_reused. Nothing is created.
The same key while the first request is still creating its scan409 validation_failed with Retry-After: 1 and Location. Retry with the same key, or poll.
The same key after that scan failedThe same error again. A failed scan keeps its key for 24 hours, so retry it with a new key.
A retry with the same key
# A retry with the same keyPOST /api/v1/scansIdempotency-Key: 3f2b7c1e-8a4d-4f6b-9c2e-5d1a7b3e9f40HTTP/1.1 200"usage": { "counted": true, "cached": false }POST /api/v1/scansIdempotency-Key: 3f2b7c1e-8a4d-4f6b-9c2e-5d1a7b3e9f40HTTP/1.1 200"usage": { "counted": false, "cached": true }

Pagination

Lists are newest first and cursor-paginated. Ask for up to 100 items with limit (20 by default). When nextCursor is not null, send it as cursor to get the next page, with the same limit.

Cursors are opaque: store them as they are and don’t build them yourself. Pages don’t shift when new scans arrive while you page through.

GET /api/v1/scans
curl "https://perspicax.io/api/v1/scans?limit=20" \  -H "Authorization: Bearer $PERSPICAX_API_KEY"

Versioning

The API version is in the path: /api/v1. Within v1 we only add things: new fields, new optional parameters, new error codes and new enum values. Write clients that ignore fields they don’t know, and handle an unknown code by its HTTP status.

A change that could break a v1 client would come as a new version, announced in the changelog ahead of time.

The detector model has its own versions. Every scan records model.id and model.version; send model to choose one from List models. Ids of earlier releases are still accepted and run the current release.

Changelog

7 November 2026

  • TrueEye 2.3 is the default model (trueeye-2.3); requests for trueeye-2.2 and trueeye-2.1 run it. Scores and bands come from 2.3; the answer format is unchanged.

28 October 2026

  • Development mode: development keys answer with the stored result of images your workspace already scanned, free on every plan.
  • Production keys on Pro and Enterprise, with nothing to request.
  • GET /api/v1/scans lists your scans, with filters and cursor pagination.
  • GET /api/v1/usage answers the workspace’s quota, the numbers the app shows; production answers carry X-Quota-* headers.
  • Public result pages: share when you create a scan, or share and stop sharing later.
  • A new default model; requests for trueeye-2.1 run it.
  • Auto-crop also leaves out app UI around the photo in screenshots.

Changes to v1 are listed here as they ship.

Questions

Can I try the API for free?

Yes. Development keys are free on every plan: they answer with the stored result of images you have already scanned on perspicax.io, and use no quota. The playground on this page shows example answers without signing in.

Why does my development key answer unknown_test_image?

Development mode recognises an image by the SHA-256 of its exact bytes, so it only knows files your workspace has scanned before. A re-saved, resized or screenshotted copy is a different file. Scan the image in the app first, then send the same file, or use a production key.

How many requests can I make?

Each key can make 60 requests a minute. Production scans also count against your plan: Pro’s daily scans, shared with the app, or your Enterprise pool. Development mode uses no quota.

Can I call the API from a browser or a mobile app?

No. Anyone can read a key shipped in a web page or an app. Call the API from your server and pass the verdict on to your users.

Do you keep the images I send?

Images are kept as you sent them, in private storage, and are not used to train our models unless you opt in. Retention options are available for Enterprise.

Does it detect edited photos or videos?

No. It is built for still images that were fully generated by AI. It does not point out an edited part of a photo, and it does not check videos.

Which image formats does it accept?

JPEG, PNG, WebP, HEIC, HEIF, AVIF, GIF (still), up to 20 MB, as a file or a link.

What should I do with an inconclusive result?

Treat it as unknown. Send it to human review, or ask for another photo. Don’t treat it as a pass or a fail.

Is there an SDK?

Not yet. The API is plain HTTP and JSON, and the samples here run as they are. Most languages can generate a client from the OpenAPI file.

Where can I get help?

Write to contact@perspicax.io. Quote the X-Request-Id of the request in question, and we can find it.

Support

Write to contact@perspicax.io. Quote the X-Request-Id of the request in question: every answer has one, and problems repeat it as requestId, so we can find it.

For an Enterprise pool, higher limits or anything else about your plan, contact us.

Start building, for free

Development keys are free on every plan. Move to Pro when you go live.