ScamAI raised $2.6M to combat AI-powered scams
scam.ai

~/docs cat detect-link.md

Detect from a link

Checks a video from a public link: a public video page, or a direct link to a video file. The server fetches it; you never download it yourself. Links are fetched for video only; an image or a recording is sent as a file. The reply is the video envelope plus a source block.

Send the link

curl
curl -X POST "https://api.scam.ai/v1/detections" \
  -H "x-api-key: <YOUR_API_KEY>" \
  -F "url=https://www.youtube.com/watch?v=dQw4w9WgXcQ"

A JSON body is accepted too, with save and thumbnails as booleans:

curl · json
curl -X POST "https://api.scam.ai/v1/detections" \
  -H "x-api-key: <YOUR_API_KEY>" \
  -H "Content-Type: application/json" \
  -d '{"url": "https://www.youtube.com/watch?v=dQw4w9WgXcQ", "thumbnails": true}'

Parameters

Request parameters
FieldTypeMeaning
urlstringThe link to a public video page or a video file. http or https only. Send url on its own; if a file is attached as well, the file is used.
save"true" | "false"Keep a history row and the file. Default "true". With "false" nothing is stored and the run cannot be retrieved later.
thumbnails"true" | "false"Include a small base64 JPEG of each frame the platform returned a still for, as frames[].thumbnail_b64. Default "false". For display only; it makes the response much larger.

The response

The video envelope with source added.

idstring
The detection id. Pass it to GET /v1/detections/{id} to read the run back.
statusstring
Always "completed". The answer arrives in the same request.
created_atstring
When the run finished. RFC 3339, millisecond precision, always Z.
mediaobject
What was checked: type (image, video, audio or document), filename, mime_type and bytes. The last three can be null when they were not known. mime_type is what the upload declared, so a client that sends no content type sees application/octet-stream here; type comes from the bytes themselves.
modelstring
The model that answered, "Eva V1.6".
verdictstring
LIKELY_REAL, ALERT or LIKELY_AI. Branch on this.
scorenumber | null
0 to 1, the probability the media is AI-made or manipulated. null when nothing could be scored.
summarystring
One sentence a person can read, matching the verdict.
credits_usednumber
What this run cost, an integer. 0 on a sandbox key, or when the same file was sent byte for byte from this account within 30 days.
zero_charge_reasonstring
Only when credits_used is 0: "sandbox" or "duplicate".
threshold_usednumber
The line the per-frame scores were judged against on this run. Draw it under frames[].score. Absent when there is nothing to draw.
framesarray
One entry per sampled frame, the whole clip: index, timestamp_s and score (0 to 1 with four decimals, or null when that frame was not scored). Absent when no frame series was produced.
frames[].causestring
Only beside a null score: why that frame has no score, for example timeout or no_face.
frames[].thumbnail_b64string
A small base64 JPEG of that frame, for display only. Present only when the request sent thumbnails=true.
sourceobject
Where the media came from: url, canonical_url, platform, title, uploader, duration_s, truncated and analyzed_duration_s. A key the platform did not report is absent, except truncated, which is always a boolean.
response · 200
{
  "id": "e4d2b8f1-3c6a-4d9e-b7f0-1a5c8e2d9b36",
  "status": "completed",
  "created_at": "2026-09-15T10:25:09.204Z",
  "media": {
    "type": "video",
    "filename": "youtube-dQw4w9WgXcQ.mp4",
    "mime_type": "video/mp4",
    "bytes": 21398112
  },
  "model": "Eva V1.6",
  "verdict": "LIKELY_REAL",
  "score": 0.08,
  "summary": "No strong signs of deepfake manipulation were found.",
  "credits_used": 20,
  "threshold_used": 0.5,
  "frames": [
    { "index": 0, "timestamp_s": 0.0, "score": 0.0611 },
    { "index": 1, "timestamp_s": 1.0, "score": 0.0842 }
  ],
  "source": {
    "url": "https://www.youtube.com/watch?v=dQw4w9WgXcQ",
    "canonical_url": "https://www.youtube.com/watch?v=dQw4w9WgXcQ",
    "platform": "youtube",
    "title": "Example video",
    "uploader": "Example channel",
    "duration_s": 212.0,
    "truncated": false,
    "analyzed_duration_s": 212.0
  }
}

The frames list is shortened here; a real reply carries every sampled frame.

Questions

How is a long video sampled, and what does truncated mean?
A video of 20 seconds or less is fetched whole. A longer one is not: the server fetches 20 one-second slivers spread evenly across its whole length and scores one frame from each, so analyzed_duration_s equals duration_s and truncated is false. In that case frames[].timestamp_s counts through the stitched slivers (0 to 19), not the original video. truncated is true only when just the front of the media was fetched, and then analyzed_duration_s is the length that was scored. A link to a video longer than 5 minutes, a playlist or a live stream is refused with 422; upload a clip instead.
Which links work?
Public video pages the resolver supports, and direct links to video files. A private or login-walled page, a page that is not a video, or a link the server cannot reach answers 400 or 422 with a message written for an end user. A video longer than 5 minutes, a playlist or a live stream answers 422. A fetched video over 100 MB answers 413. A deployment that cannot fetch links answers 501; upload the file instead.
Is a link charged differently from an upload?
No. A link becomes the same file the upload path sees, so it costs what that media costs: credits_used for a video link is the number of frames scored, at most 20.