Skip to content

Detect

POST /api/v1/detect runs the same engine as the app and returns the full evidence report — verdict, confidence interval, the evidence that produced it, and which signal decided.

API access is not on sale yet.

The endpoint is live and these docs describe it accurately. Access belongs to the Pro plan, which we have not opened for purchase — so a key cannot be issued today, and these pages are here to be read rather than acted on. Free accounts have no API access, and requests from one are refused with a 403. Nothing here is a waiting list; there is simply nothing to join yet.

Endpoint

One endpoint covers every modality:

POST https://sighting.ai/api/v1/detect

Every request needs a bearer key in the Authorization header — see Authentication.

Request fields

FieldTypeRequiredDescription
modality'text' | 'code' | 'image' | 'video'YesWhat kind of content you are submitting. 'code' is accepted and answered, but it never returns an authorship verdict — see Code returns no verdict.
textstringFor text/codeThe content to analyze. Up to 200,000 characters.
mediaRefstringFor image/videoThe media inline: a data: URL or a bare base64 string. Nothing else — see the note below. Capped at 25 MB of decoded media; larger returns 413.
sensitivitynumberNo0–1 (the app’s slider shows 0–100; the API takes the 0–1 form). Out-of-range values return 400. Higher pushes borderline scores toward "AI". Ignored for 'code', which has no score to push.
redactExcerptsbooleanNoZero-retention mode: evidence omits raw excerpts.
checkPlagiarismbooleanNoAlso run the plagiarism/originality pass.

Sending media

mediaRef carries the bytes themselves, inline in the JSON body:

data:image/jpeg;base64,/9j/4AAQSkZJRg...

A bare base64 string (no data: prefix) is accepted too. Storage references — s3://, gs://, a bucket key, an https:// URL — are not supported. We do not fetch media on your behalf, so a reference of that shape is rejected rather than resolved. Fetch it yourself and send the bytes.

One payload is capped at 25 MB decoded. Base64 inflates three bytes into four characters, so the practical ceiling on the source file is about 18 MB. Oversized payloads are rejected before anything is decoded.

Example request

curl
curl https://sighting.ai/api/v1/detect \
  -H "Authorization: Bearer sk_live_..." \
  -H "Content-Type: application/json" \
  -d '{
    "modality": "text",
    "text": "In today'\''s rapidly evolving digital landscape..."
  }'

Response

A successful request returns the full detection report:

200 OK
{
  "id": "5f2c9d1e-...",
  "modality": "text",
  "verdict": "ai",
  "aiLikelihood": 86,
  "confidence": { "score": 86, "low": 79, "high": 93, "label": "High" },
  "signals": [
    {
      "key": "classifier",
      "name": "AI-text classifier",
      "value": 87,
      "weight": 1.0,
      "explanation": "A ModernBERT-large model fine-tuned to separate human-written from AI-generated English prose. This is the only signal that decides this verdict."
    },
    {
      "key": "burstiness",
      "name": "Sentence-length variation",
      "value": 34,
      "weight": 0.0,
      "explanation": "Descriptive only: reported for context and combined into nothing."
    }
  ],
  "reasoning": ["..."],
  "evidence": {
    "modality": "text",
    "spans": [
      { "charStart": 42, "charEnd": 131, "excerpt": "...", "localScore": 91 }
    ]
  },
  "engineVersion": "text-statistical-v1",
  "createdAt": "2026-08-04T09:12:00Z"
}

Response fields

FieldTypeDescription
verdictstring'ai' | 'human' | 'mixed' | 'uncertain'. A 'human' verdict ALWAYS requires positive provenance of the exact bytes, so in practice only image and video can return it, and only from a signed capture manifest. Text never returns 'human' — a low score there is 'uncertain'. Code always returns 'uncertain' and can return nothing else; see Code returns no verdict.
aiLikelihoodnumberPoint estimate 0–100 that the content is AI-generated. For code it is always 50 and is not an estimate: the field is required by the contract, and the midpoint is the only value that asserts nothing. Do not render it as a score.
confidenceobject{ score, low, high, label }. low/high bound the uncertainty band — never show the bare score without it. The width is declared policy on every modality: a documented judgement call, not a measured coverage rate. Text uses one policy, image and video another (reported as ladder.intervalBasis), and neither is calibrated — no coverage study backs either. Code returns the whole scale, low: 0, high: 100, because nothing about authorship was measured and so no part of it has been ruled out.
signals[]object[]Interpretable weighted signals: { key, name, value, weight, explanation }. Empty for image and video, whose rungs are ranked rather than weighted (see ladder). Text weights them into its score. Code reports observations here with weight: 0 on every one — they describe the file, they are not combined, and they feed no verdict, so the weights do not sum to 1.
reasoning[]string[]Ordered plain-language reasoning for the verdict.
evidenceobjectFor text: { spans: [{ charStart, charEnd, excerpt, localScore }] }. For code the same shape, with spans always empty: a span carries a localScore, which on source would be an accusation pinned to particular lines of somebody's file. For image: { regions, provenance }regions is empty today, because no signal we ship localises evidence to part of a picture. For video: { segments, provenance }, where segments are keyframe boundaries and carry no score of their own.
ladderobject?Image and video only. The evidence-ladder decision record: rungsTried[] (every rung, including the ones that recovered nothing), decidedBy, verdictReason, intervalBasis, ladderVersion. Absent for text and code.
ladder.rungsTried[]object[]One finding per lane: { id, rung, status, direction, tier, implementation, summary }, plus elapsedMs and a lane-specific detail when the lane measured something. implementation pins the exact code, model or library version that produced the finding, so an old report can be re-audited against it.
verdictReasonstring?Machine-readable VERDICT·REASON_CODE, e.g. UNCERTAIN·NO_PROVENANCE vs UNCERTAIN·MANIFEST_INVALID. Orthogonal to verdict, so two very different uncertainties stay distinguishable.
engineVersionstringWhich engine produced this, e.g. "text-statistical-v1". The code lane reports "code-observations-v1"; a report from the retired "code-statistical-v1" predates this policy and did carry an authorship verdict.

Code returns no verdict

modality: 'code' is accepted, runs, and returns a full report. Its verdict is 'uncertain' and cannot be anything else — not 'ai', not 'human', not 'mixed' — for any input, at any sensitivity. That is enforced in the engine, not by a threshold that happens never to be crossed.

This is a considered position, not a lane waiting on a model:

  • Nothing licensed works at a size we can run. There is no openly licensed code-detector checkpoint we may ship.
  • There is nothing honest to calibrate against. The one benchmark good enough to pick a threshold on is licensed CC BY-NC-ND, so a commercial product may not use it — and public repositories since 2022 are AI-assisted often enough that a corpus of "human" code is contaminated by construction.
  • The error lands on the developer. Benchmarking finds code detectors misclassify human-written code as AI more often than they miss AI-written code. The characteristic mistake of a code detector is therefore falsely accusing an author of not writing their own work — with a grade, a job or a contract attached to it. Idiomatic code is supposed to converge; that is what a formatter and a style guide are for, and the uniformity a detector reads as machine-written is mostly evidence the author knew the conventions.

What you do get is measurement. signals[] carries plain counts and ratios over the file you sent — each with weight: 0, because none of them is combined into anything. spans is always empty, aiLikelihood is always 50, and confidence spans the whole scale.

200 OK — code (signals abbreviated)
{
  "modality": "code",
  "verdict": "uncertain",
  "verdictReason": "UNCERTAIN·CODE_AUTHORSHIP_NOT_ASSESSED",
  "aiLikelihood": 50.0,
  "confidence": { "score": 50.0, "low": 0.0, "high": 100.0, "label": "Very Low" },
  "signals": [
    {
      "key": "comment_line_share",
      "name": "Comment-line share",
      "value": 40.0,
      "weight": 0.0,
      "explanation": "Percentage of non-blank lines that open with one of the comment markers we recognise (#, //, /*, *, --, %, ;, <!--). A language whose marker is not in that list reads as 0 here."
    },
    {
      "key": "indent_regularity",
      "name": "Indent regularity",
      "value": 100.0,
      "weight": 0.0,
      "explanation": "Percentage of indented lines whose indent is a whole multiple of the depth this file indents to most often. Auto-formatted code sits near 100, whoever ran the formatter."
    }
  ],
  "reasoning": [
    "No authorship judgement was made on this code, and none will be: we do not offer AI-authorship detection for source code.",
    "This is a considered position, not a feature that is on its way. No method at a size we can run distinguishes AI-written code from human-written code reliably, the one benchmark good enough to check a threshold against is licensed for non-commercial use only, and public repositories since 2022 are AI-assisted often enough that there is no clean corpus of human code left to measure against.",
    "The error would land on the developer. Benchmarking of code detectors finds human-written code misclassified as AI more often than AI-written code is missed, so the characteristic mistake of a code detector is falsely accusing an author of not writing their own work. Idiomatic code is meant to converge — that is what a formatter and a style guide are for — and the uniformity a detector reads as machine-written is mostly evidence the author knew the conventions.",
    "The measurements below describe this file and nothing else. They are not weighted, not combined, and not evidence of who wrote it."
  ],
  "evidence": { "modality": "code", "spans": [] },
  "engineVersion": "code-observations-v1"
}

Four of the six observations are elided above for length. A file shorter than five non-blank lines returns signals: [] and says so in reasoning rather than publishing ratios computed over a handful of lines.

If you are branching on this, match verdictReason — the code UNCERTAIN·CODE_AUTHORSHIP_NOT_ASSESSED is stable — rather than parsing the prose.

Image and video: the evidence ladder

Media verdicts are not one blended score. Several independent signals are tried — a signed C2PA manifest, a watermark decoder, classical file forensics, a perceptual hash, a learned classifier — and the strongest one that fires decides alone. ladder.decidedBy names it, and ladder.rungsTried shows every lane including the silent ones.

Two rules the response always obeys:

  • A quiet ladder is never an acquittal. A rung that recovered nothing reports clear, not a human direction — an AI image that was screenshotted or passed through social media presents exactly the same way.
  • Only positive provenance of those exact bytes can produce a human verdict, and only from a signer that chains to a trust list we accept. The rule is not limited to the ladder: no lane in this API returns human from a score. Text and code once did, below a threshold, and it was wrong for the same reason it would be wrong here — a plainly-written or lightly-paraphrased AI passage scores low too. Those lanes now return uncertain instead. In practice this means human is reachable only for image and video, and only with a signed capture manifest.

This is a real response, captured from a JPEG carrying no provenance of any kind — the ordinary case, and the one people most often misread as an acquittal. It was captured before the pixel classifier shipped, so its classifier rung reports itself unconfigured; read the note under the block for what that rung returns today. Nothing else about the response has changed, and it is reproduced as captured rather than edited, because a hand-written "capture" is exactly the kind of thing this product exists to catch:

200 OK — image
{
  "modality": "image",
  "verdict": "uncertain",
  "verdictReason": "UNCERTAIN·NO_PROVENANCE",
  "aiLikelihood": 50,
  "confidence": { "score": 50, "low": 25, "high": 75, "label": "Very Low" },
  "signals": [],
  "reasoning": [
    "No lane recovered positive provenance for these exact bytes.",
    "That is not evidence of human authorship: an AI-generated image that was screenshotted, re-encoded, or passed through social media presents exactly this way."
  ],
  "evidence": {
    "modality": "image",
    "regions": [],
    "provenance": [
      {
        "kind": "none",
        "present": false,
        "detail": "No C2PA manifest was recovered from this file. That is not evidence of human authorship."
      }
    ]
  },
  "ladder": {
    "rungsTried": [
      {
        "id": "c2pa",
        "rung": 1,
        "status": "clear",
        "direction": "inconclusive",
        "tier": null,
        "implementation": "c2pa-python-0.37.7/c2pa-rs-0.90.14",
        "summary": "No C2PA manifest is embedded in these bytes, which is the normal case for any file that has been re-encoded or passed through a social platform."
      },
      {
        "id": "watermark",
        "rung": 2,
        "status": "skipped",
        "direction": "inconclusive",
        "tier": null,
        "implementation": "watermark-rung-v1",
        "summary": "No soft-binding watermark decoder is installed, so no watermark algorithm was checked on this file."
      },
      {
        "id": "forensics",
        "rung": 3,
        "status": "clear",
        "direction": "inconclusive",
        "tier": null,
        "implementation": "forensics-classical-v1 (Pillow 11.1.0, numpy 2.2.1)",
        "summary": "JPEG ballistics recorded 6 observation(s); the quantisation tables indicate a libjpeg-family encoder using standard IJG tables at quality 88, which identifies the last encoder and not the origin of the picture."
      },
      {
        "id": "fingerprint",
        "rung": 4,
        "status": "clear",
        "direction": "inconclusive",
        "tier": null,
        "implementation": "phash-dct-64-v1 (Pillow 11.1.0, numpy 2.2.1)",
        "summary": "Perceptual hash phash-dct-64 computed as d7286893609797c7; no fingerprint index was queried because the corpus store does not exist yet, so this hash is returned for external lookup only."
      },
      {
        "id": "classifier",
        "rung": 5,
        "status": "skipped",
        "direction": "inconclusive",
        "tier": null,
        "implementation": "classifier-onnx-v1",
        "summary": "The classifier rung is not configured: INFERENCE_CLASSIFIER_MODEL_PATH is unset, so no model was run on this file."
      }
    ],
    "decidedBy": null,
    "verdictReason": "UNCERTAIN·NO_PROVENANCE",
    "intervalBasis": "declared-policy",
    "ladderVersion": "ladder-policy-v2"
  },
  "engineVersion": "image-ladder-v1"
}

Two fields are elided above for length: every rung also reports elapsedMs, and a rung that measured something carries a lane-specific detail object with the measurements themselves — the forensics rung's detail.observations, for instance, lists each JPEG marker it read and states what that observation can and cannot support.

What rung 5 returns today, on image. The classifier weights now ship with the service, so on an image that rung runs rather than reporting itself unconfigured. It carries detail with modelId, modelVersion, the rawScore, the threshold, and thresholdSource: "declared-default". At or above the threshold it is status: "fired", direction: "synthetic" — the weakest lane on the ladder, and the only one that can identify an unsigned AI image. Below it the rung is status: "clear", direction: "inconclusive", and the summary says why in as many words: a laundered AI image scores low as well, so a low score does not indicate human origin. thresholdSource is the field to read before quoting the number anywhere — it is a declared default, not a value fitted to our data, and no coverage study stands behind it. Video has no classifier: there the rung scores sampled keyframes with this same image model and reports them, and the ladder never lets that score decide.

Good to know

  • Text runs on a transformer classifier we host ourselves — nothing is sent to a third-party detector, and the same input always produces the same report. So does the code lane's measurement pass.
  • Code returns uncertain by design, always. See Code returns no verdict.
  • Video is decided by provenance or not at all. A signed file can be settled outright; an unsigned one gets a real provenance report and an uncertain verdict, because nothing that can honestly decide it exists yet. Concretely: of the five rungs, only c2pa can produce a verdict. watermark has no decoder for video, forensics reports the last program that wrote the container rather than the origin of the footage, fingerprint has no corpus to query, and classifier scores sampled keyframes with the image model and reports those scores without ever deciding on them. Every open synthetic-video detector we evaluated is licensed against commercial use, ships no weights, or scores at chance on real-world video, so we run none.
  • Image differs from video in exactly one rung. classifier is configured there, so a file no manifest settled is still read at the pixel level and an AI image can be identified without provenance. It cannot go the other way: a score below the threshold is reported as clear / inconclusive, never as human. The threshold is declared policy with no calibration study behind it, which is why this reference publishes no accuracy figure for it.
  • Keys are scoped; detection requests need the detect:write scope.
  • With redactExcerpts: true, stored evidence keeps offsets and scores but drops the raw text.

Failures use one envelope across the whole API — see Errors.