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
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
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
Set the key
Keep the key in an environment variable, never in your code:
bashexport PERSPICAX_API_KEY=pcx_test_… - 4
Send it
Run the request in the code panel. With
Prefer: wait=30the verdict comes back in the same call: readresult.label.
Rather click than type? Try the playground, or import the Postman collection.
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"{ "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.
curl https://perspicax.io/api/v1/models \ -H "Authorization: Bearer $PERSPICAX_API_KEY"{ "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: testandreplayOf - Answer
200at once, every time- Quota
- None used
- Preview link
previewUrlis null- An image you have not scanned
422 unknown_test_image- Public page (share=true)
- Accepted and ignored:
shareisnull - 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
200withinPrefer: wait, or a202to 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 a202 - 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
modelandautoCropsetting 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.
{ "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}{ "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"}{ "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-generated
ai_generatedcoarse: highStrong signs that the whole image was generated by AI. - Inconclusive
inconclusivecoarse: middleNeither clearly AI nor clearly real. Treat it as unknown: review it, or ask for another photo. - Likely real
not_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": { "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 get200with the verdict andPreference-Applied: wait=N. - Poll. Without
Prefer, or when the wait runs out, you get202 Acceptedwith the scan so far, aLocationheader andRetry-After: 2. ReadLocationevery 2 seconds untilstatusiscompletedorfailed.
- POST /api/v1/scansPrefer: wait=30
- 200 OK, the verdictDone in time
- Not done in time, or no wait?
- 202 AcceptedLocation, Retry-After: 2
- GET the LocationEvery 2 s
- 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.
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, enumqueuedandrunningwhile the check runs, thencompleted(withresult) orfailed(not counted: send the image again with a new Idempotency-Key).Values
queuedrunningcompletedfailedsourcestring, enumWhere the scan was made:
landing(the website),app, orapi(API keys and the Developers playground).Values
landingappapimodestring, enumtest: a development-mode answer (an earlier result repeated).live: a real check (the app, the website, production keys).Values
testlivereplayOfstring (UUID) or nullDevelopment mode: the id of the earlier scan whose stored result this answer repeats.
nullin production mode.modelobjectThe detector that answered.
3 fields inside
idstringThe model id, what you send as
model:trueeye-2.3by default.namestringAlways
TrueEye.versionstringThe model version, such as
2.3.
createdAtstring (date-time)When the scan was created (ISO 8601, UTC).
completedAtstring (date-time) or nullWhen the scan finished;
nulluntil then.imageobjectThe file we received.
7 fields inside
sha256stringSHA-256 of the exact bytes received, in hex. Development mode recognises images by this hash.
widthinteger or nullWidth as displayed (EXIF orientation applied), in pixels;
nullwhen it could not be read.heightinteger or nullHeight as displayed, in pixels;
nullwhen it could not be read.mimestring, enumThe format, from the file’s bytes (not its name).
Values
image/jpegimage/pngimage/webpimage/heicimage/heifimage/avifimage/gifbytesintegerFile size in bytes.
filenamestring or nullThe file name you sent (cleaned), or the last part of the URL.
previewUrlstring (URL) or nullA 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.
nullin development mode and when no copy can be made.
resultobject or nullThe verdict.
nulluntil the scan iscompleted.10 fields inside
labelstring, enumThe verdict:
ai_generated,inconclusiveornot_ai_generated. Build your logic on this field.Values
ai_generatedinconclusivenot_ai_generatedinconclusiveReasonstring, enum or nullbetween_thresholdswhen the label isinconclusivebecause the result sits between a clear AI and a clear real answer;nullotherwise. (outside_scopeappears only on scans made before 28 October 2026.)Values
between_thresholdsoutside_scopelabelSourcestring, enumdetectorwhen the image check decided the label;content_credentialswhen a verified manifest from Google or OpenAI declares the image AI-generated.scoreis filled in either way.Values
detectorcontent_credentialscoarsestring, enum or nullThe band that goes with the label:
low(likely real),middle(inconclusive) orhigh(likely AI).nullonly on old outside-scope scans.Values
lowmiddlehighscorenumber or nullFrom 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 isnull(the label andcoarseband are there).thresholdsnullReserved. Always
nullin API answers.provenanceobjectProvenance found in the file, read before the image check.
2 fields inside
c2paobject or nullThe Content Credentials manifest, or
nullwhen the file has none.5 fields inside
presentbooleanAlways
truewhen the object is there.claimGeneratorstring or nullThe software that wrote the manifest.
issuerstring or nullWho signed the manifest.
actionsarray of stringsThe actions the manifest records, such as
c2pa.created.digitalSourceTypestring or nullThe IPTC digital source type the manifest declares.
iptcDigitalSourceTypestring or nullThe IPTC digital source type from the file’s metadata, or
null.
metadataobjectCamera metadata found in the file.
3 fields inside
exifPresentbooleantruewhen the file has EXIF data.cameraMakestring or nullThe camera maker from EXIF, or
null.softwarestring or nullThe software from EXIF, or
null.
detectorobjectThe model that answered, by its public name.
5 fields inside
modelstringAlways
TrueEye.versionstringThe model version, such as
2.3.cropScoresnullReserved. Always
nullin API answers.photoLikebooleanfalsewhen 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.mockbooleanAlways
falseon perspicax.io.trueonly on test servers that use a stand-in detector.
autoCropobject or nullWhat auto-crop did;
nullon scans made before auto-crop existed.5 fields inside
enabledbooleanWhat you asked for:
autoCrop, true unless you sent false.appliedbooleantruewhen borders or app UI were found and left out.boxobject or nullThe region that was kept and checked, in pixels of the image as displayed (EXIF orientation applied);
nullwhen nothing was left out.4 fields inside
xintegerLeft edge of the checked region, in pixels.
yintegerTop edge of the checked region, in pixels.
widthintegerWidth of the checked region, in pixels.
heightintegerHeight of the checked region, in pixels.
imageWidthintegerWidth of the whole image as displayed, in pixels.
imageHeightintegerHeight of the whole image as displayed, in pixels.
usageobjectWhat this scan cost.
2 fields inside
countedbooleantruewhen the scan used your quota: a completed production scan.falsein development mode, for failed scans and for Idempotency-Key retries.cachedbooleantruewhen nothing was checked for this answer: a development-mode answer, or a retry with the same Idempotency-Key.
latencyMsinteger or nullTime from upload to verdict, in milliseconds;
nulluntil the scan finishes.shareobject or nullThe scan’s public result page, when it is shared (
share=true, orPOST /api/v1/scans/{id}/share);nullotherwise. Alwaysnullon a development-mode answer.1 field inside
urlstring (URL)The page,
https://perspicax.io/r/<slug>. Its result card is<url>/card.pngand its printable report<url>/report.pdf.
{ "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
PreferstringOptionalProduction mode:
wait=Nkeeps the request open up to N seconds (at most 30) while the scan runs: a scan done in time is answered200withPreference-Applied. Without it, or when the time runs out, the answer is202withLocationto poll. Development mode ignores it: its answer is always an immediate200.Idempotency-KeystringOptionalMakes 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-IdstringOptionalYour 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.
imagefileRequiredThe image file, at most 20 MB: JPEG, PNG, WebP, HEIC, HEIF, AVIF, GIF (still).
modelstringOptionalDefault trueeye-2.3A model id from
GET /api/v1/models;trueeye-2.3when absent. An unknown id is422 validation_failednaming the allowed ids.autoCropbooleanOptionalDefault trueBorders and app UI around the photo (for example a social post screenshot) are left out, and only the photo is checked;
result.autoCrop.boxsays what was kept.falsechecks the whole image as sent.auto_cropis accepted too. In a form, sendtrueorfalse.sharebooleanOptionalDefault falseProduction mode:
truealso makes the scan’s public result page, also on a202; its link comes back asshare.url. Development mode accepts it and makes no page: its answers repeat an earlier result.
Body: a link
application/json.
urlstring (URL)RequiredA 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.3A model id from
GET /api/v1/models;trueeye-2.3when absent. An unknown id is422 validation_failednaming the allowed ids.autoCropbooleanOptionalDefault trueBorders and app UI around the photo (for example a social post screenshot) are left out, and only the photo is checked;
result.autoCrop.boxsays what was kept.falsechecks the whole image as sent.auto_cropis accepted too.sharebooleanOptionalDefault falseProduction mode:
truealso makes the scan’s public result page, also on a202; its link comes back asshare.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(everyRetry-Afterseconds) untilstatusiscompletedorfailed. - 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
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"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"}'{ "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)RequiredThe 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
curl https://perspicax.io/api/v1/scans/6f1c2d3e-4b5a-4c7d-8e9f-0a1b2c3d4e5f \ -H "Authorization: Bearer $PERSPICAX_API_KEY"{ "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
limitintegerOptionalHow many scans per page: 1 to 100, 20 by default.
cursorstringOptionalThe
nextCursorof the previous page. Omit it for the first page.statusstring, enumOptionalOnly scans in this state.
labelstring, enumOptionalOnly scans with this verdict.
sourcestring, enumOptionalOnly scans made on the website (
landing), in theappor with theapi.fromstring (date-time)OptionalOnly scans created at or after this time (ISO 8601).
tostring (date-time)OptionalOnly 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
curl "https://perspicax.io/api/v1/scans?limit=20" \ -H "Authorization: Bearer $PERSPICAX_API_KEY"{ "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"}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, enumThe workspace’s plan.
Values
freeproenterpriselimitintegerThe scans in the period: Free and Pro, the UTC day’s; Enterprise, the pool of the contract period.
usedintegerThe scans used in the period.
remainingintegerThe scans left in the period.
windowstring, enumThe period:
dayon Free and Pro (a UTC day); on Enterprise, the contract’sday,week,monthoryear.Values
dayweekmonthyearresetsAtstring (date-time) or nullWhen the period restarts (ISO 8601, UTC): the next 00:00 UTC, or the end of the contract period.
needsSignupbooleanAlways
falsefor a workspace (it is for signed-out visitors of the website).needsUpgradebooleantrueon Free when the day’s scans are used up: Pro would allow more.overageobject or nullEnterprise only: scans past the pool, as set in the organisation’s contract;
nullotherwise.5 fields inside
enabledbooleantruewhen the organisation’s contract allows scans past the pool.usedintegerScans past the pool in this period (a count).
capinteger or nullThe most scans past the pool the contract allows in a period;
nullfor no cap.pricePerScannumber or nullAlways
nullin API answers: the price is in the organisation’s contract, and members who manage billing see it in the app.amountDuenumber or nullAlways
nullin API answers: what the overage comes to is shown in the app to members who manage billing.
blockedstring, enum or nullEnterprise only: why no scan can run whatever the numbers say (the organisation is being set up, no contract, not started yet, paused or ended);
nullwhen scans may run.Values
setting_upno_contractnot_startedpausedended
curl https://perspicax.io/api/v1/usage \ -H "Authorization: Bearer $PERSPICAX_API_KEY"{ "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, enumWhat you send as
model; scans return it asmodel.id.Values
trueeye-2.3namestringThe display name.
descriptionstringWhat the model is for, in one line.
defaultbooleantruefor the model used when a request names none.
curl https://perspicax.io/api/v1/models \ -H "Authorization: Bearer $PERSPICAX_API_KEY"{ "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.box{ "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.
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"{ "share": { "url": "https://perspicax.io/r/Xq7Lm2Pz9Rt4Vb6N" }}Headers
Request headers
| Authorization | Bearer and your key. Required on every request. |
|---|---|
| Content-Type | multipart/form-data for a file, application/json for a link. Your HTTP client usually sets it. |
| Prefer | 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-Key | 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-Id | 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. |
Response headers
| X-Request-Id | This request’s id. Every answer has one; problems repeat it as requestId. |
|---|---|
| RateLimit-Limit | Requests allowed per window for this key. |
| RateLimit-Remaining | Requests left in the current window. |
| RateLimit-Reset | Seconds until the window starts again. |
| RateLimit-Policy | The policy, such as 60;w=60: 60 requests per 60 seconds. |
| Retry-After | Seconds to wait before polling (202) or retrying (409, 429, 503). |
| Location | The scan to poll: /api/v1/scans/{id}. |
| Preference-Applied | wait=N when your Prefer: wait was honoured. |
| WWW-Authenticate | With 401: Bearer realm="perspicax", error="invalid_token". |
| X-Quota-Limit | Production 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-Remaining | The same answers: the scans left in the period. |
| X-Quota-Reset | The same answers: when the period restarts (ISO 8601, UTC): 00:00 UTC, or the end of the Enterprise contract period. |
| X-Quota-Overage-Used | The 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
429and503: waitRetry-Afterseconds, then retry.409: waitRetry-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
- 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
cursoror list filter; sharing a scan that failed, or a development-mode answer (it has no public page). 422: an unknownmodel, orautoCroporsharethat is not true or false (errorsnames 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:
detailanderrorssay what is wrong. On a 409, waitRetry-After(1 s) and send the same request with the same key, or pollLocation.
- 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 carriessuggestedPlan: "pro"and adocslink. - 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.
- 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.blockedsays which). The answer carriesquota. - 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.
- When
- We switched the API off for this workspace (
detailsays 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.
- 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.
- When
- The file, or the image behind the URL, is larger than 20 MB.
- What to do
- Send a smaller file.
- 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.
- 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 thatsha256and adocslink. - 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).
- 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
detailsays which. Check the file or the URL.
- 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.
- 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
detailnames the limit, with adocslink. - What to do
- Wait
Retry-Afterseconds, then retry. PreferPrefer: wait=30to a tight polling loop.
- 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
requestIdif it keeps happening.
Every answer carries X-Request-Id, and every problem repeats it as requestId. Quote it when you write to contact@perspicax.io.
{ "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" } ]}{ "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.
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.
- Postman collectionCollection v2.1: every endpoint, with example answersDownload Postman collection
- Postman environmentThe baseUrl and apiKey variablesDownload Postman environment
- OpenAPI 3.1For Insomnia, Bruno, Hoppscotch and code generatorsDownload OpenAPI 3.1
Set up Postman in five steps
- 1Download the two filesThe collection holds every request with example answers; the environment holds the base URL and your key.JSONperspicax.postman_collection.jsonJSONperspicax.postman_environment.json
- 2Import themIn Postman, choose Import and drop both files. The “Perspicax API v1” collection and the “Perspicax” environment appear.ImportJSONperspicax.postman_collection.jsonJSONperspicax.postman_environment.json
- 3Add your keySelect the Perspicax environment and paste your key intoPerspicaxbaseUrlhttps://perspicax.ioapiKey••••••••••••••••
apiKey, as its current value. It is a secret variable, so Postman masks it. Don’t share it. - 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.POST{{baseUrl}}/api/v1/scansSendimageharbour.jpg
- 5Read the answerThe verdict is in200 OK"label": "ai_generated""mode": "live"
result.label. The collection keeps the scan’s id inscanId, so Retrieve a scan works straight away. - 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.
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.
| RateLimit-Limit | Requests allowed per window for this key. |
|---|---|
| RateLimit-Remaining | Requests left in the current window. |
| RateLimit-Reset | Seconds until the window starts again. |
| RateLimit-Policy | The policy, such as 60;w=60: 60 requests per 60 seconds. |
| Retry-After | Seconds 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.
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{ "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.
{ "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.
| The same key and the same request | The same scan, with usage.cached: true. Nothing is counted again. |
|---|---|
| The same key and a different image, link or options | 422 idempotency_key_reused. Nothing is created. |
| The same key while the first request is still creating its scan | 409 validation_failed with Retry-After: 1 and Location. Retry with the same key, or poll. |
| The same key after that scan failed | The same error again. A failed scan keeps its key for 24 hours, so retry it with a new 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.
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 fortrueeye-2.2andtrueeye-2.1run 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/scanslists your scans, with filters and cursor pagination.GET /api/v1/usageanswers the workspace’s quota, the numbers the app shows; production answers carryX-Quota-*headers.- Public result pages:
sharewhen you create a scan, or share and stop sharing later. - A new default model; requests for
trueeye-2.1run 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.