Skip to main content

scamai

Official Python SDK for the ScamAI detection platform. One detect() call covers images, video and audio, with typed exceptions for every refusal, plus account, usage and history. Sync and async clients share the same surface.

Requirements

Python 3.9 or later. Server-side only: an API key grants full access to your account, so keep it out of client-side code and out of version control.

Installation

pip install scamai

Quickstart

from scamai import ScamAI

client = ScamAI()  # reads SCAMAI_API_KEY

det = client.detect("suspect.jpg")

print(det["verdict"])       # "LIKELY_AI"
print(det["score"])         # 0.99
print(det["credits_used"])  # 1

The async client is the same surface, awaited. Use it inside FastAPI, aiohttp or any event loop, where a blocking HTTP call would stall every other request:

from scamai import AsyncScamAI

async with AsyncScamAI() as client:
    det = await client.detect("suspect.jpg")

Authentication

Keys are created in the dashboard and shown once. The client reads SCAMAI_API_KEY from the environment by default:

export SCAMAI_API_KEY=sk_...

Pass it directly when your keys live somewhere else, such as a secrets manager:

client = ScamAI(api_key=secrets.get("scamai"))

Requests are authenticated with the x-api-key header. Authorization: Bearer is read by the gateway as a dashboard session token and answers 401, so the SDK never sends it.

The surface

client.detect(file, ...) The unified endpoint (POST /v1/detections). file is a path, bytes, an open binary file, or (filename, data, content_type).
client.detections.create_from_url(url, ...) Link intake, video only: a public video page or a direct link to a video file. The gateway fetches it.
client.detections.get(id) Read a past detection back. Returns the same envelope the original call answered.
client.tasks.receipt(task_id) The verdict receipt for a past detection.
client.account.profile / balance / ledger / subscriptions Who you are and what you have spent.
client.keys.list / create / revoke API keys. create() regenerates per scope, and the returned value is shown once.
client.history.list / stats Detection history and aggregates.
client.usage.pricing() The per-service price catalog.
client.request(method, path, ...) Escape hatch for any route the typed surface does not cover, with the SDK's auth, error handling and retry rules.

Responses are plain dicts, verbatim from the wire.

detect() and create_from_url() take the same keyword options:

Option Default
save True Keep a history row and the file server-side.
thumbnails False Video only. Ask for a small base64 JPEG of each scored frame as frames[].thumbnail_b64. Display material, not evidence, and it makes a long clip's response much larger.
timeout / timeout_ms client default Per-call deadline, in seconds or milliseconds. Pass one, not both.
retry False Opt in to retrying on 429/5xx. A retried detection is billed again.
filename, content_type inferred detect() only. Override what the file is called and typed as.

What comes back

One envelope for every media type. The base fields are always present. Video and audio each add their own, and a field that does not apply to a kind is absent rather than None, so use det.get(...) instead of comparing against None.

Field Kind
verdict all The routing decision: LIKELY_REAL, ALERT or LIKELY_AI.
score all 0 to 1 with four decimals, the probability the media is manipulated, or None when nothing could be scored. The only score. Sort a review queue on it.
summary all One plain-English sentence. For ALERT it is always "We detected some AI-related signals, but the result is not conclusive."
model all The public label, for example "Eva V1.6". Always a string.
credits_used all An integer: what the ledger debited for this run. 0 on a sandbox key, or when the same file was sent byte for byte from this account within 30 days.
zero_charge_reason all Only when credits_used is 0: "sandbox" or "duplicate".
media all {type, filename, mime_type, bytes}.
id all This detection's id. Pass it to detections.get() to read the run back.
created_at all RFC 3339, millisecond precision, Z.
status all Always "completed".
threshold_used video The line the per-frame chart is drawn against. Absent when there is nothing to draw.
frames video The per-frame series: {index, timestamp_s, score, cause?, thumbnail_b64?}. score has four decimals and is None for a frame that was not scored, and then cause says why, such as no_face or timeout. thumbnail_b64 only with thumbnails=True. Absent when no series was produced. One credit per scored frame, at most 20; a null frame is not charged.
duration_ms / segments audio Clip length, and the per-window timeline: {start_ms, end_ms, value, status, reason_codes?}. status is scored or abstained; reason_codes only on an abstained window. One credit per minute, rounded up, minimum 1.
source link runs {url, canonical_url, platform, title, uploader, duration_s, truncated, analyzed_duration_s}. Keys the resolver could not report are absent. truncated true means only the front of the video was fetched and scored.

Handle all three verdicts. ALERT is a live value on images, documents and video; audio answers LIKELY_REAL or LIKELY_AI only. A branch that omits one falls through silently.

An image:

{
  "id": "det_9f2c1a7e",
  "status": "completed",
  "created_at": "2026-08-06T09:14:22.108Z",
  "media": { "type": "image", "filename": "suspect.jpg", "mime_type": "image/jpeg", "bytes": 284193 },
  "model": "Eva V1.6",
  "verdict": "LIKELY_AI",
  "score": 0.92,
  "summary": "This image shows strong signs of being AI-generated or edited.",
  "credits_used": 1
}

A video adds the series. One frame here was not scored:

{
  "id": "det_4c8e2b91",
  "status": "completed",
  "created_at": "2026-09-15T10:02:41.517Z",
  "media": { "type": "video", "filename": "clip.mp4", "mime_type": "video/mp4", "bytes": 8123904 },
  "model": "Eva V1.6",
  "verdict": "ALERT",
  "score": 0.57,
  "summary": "We detected some AI-related signals, but the result is not conclusive.",
  "credits_used": 1,
  "threshold_used": 0.5,
  "frames": [
    { "index": 0, "timestamp_s": 0.0, "score": 0.5731 },
    { "index": 1, "timestamp_s": 1.0, "score": null, "cause": "no_face" }
  ]
}

A link run adds source:

det = client.detections.create_from_url("https://www.youtube.com/watch?v=dQw4w9WgXcQ")
{
  "id": "det_7d1a03f5",
  "status": "completed",
  "created_at": "2026-09-15T10:09:03.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": 6.3, "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": true,
    "analyzed_duration_s": 120.0
  }
}

Reading a detection back

detect() answers on the same request, so there is no job to poll. A long video holds the call open until it finishes.

det = client.detect("suspect.jpg")
store(det["id"])  # the handle to this run

# Later, the same envelope again.
again = client.detections.get(det["id"])

Without the id, find the run in the history and read it back from there. The same id also resolves a receipt, which is smaller and carries no PII:

page = client.history.list(limit=20, offset=0)
receipt = client.tasks.receipt(page["history"][0]["task_id"])

history.list() pages with limit and offset, and filters on service_type, success, start_date, end_date and search.

Errors

Every failure this package raises is a ScamAIError, so one except covers all of them:

from scamai import ScamAIError, CreditsError, UnprocessableError

try:
    client.detect("suspect.jpg")
except CreditsError as e:
    top_up(e.balance)
except UnprocessableError as e:
    show_to_user(str(e))
except ScamAIError as e:
    log_and_alert(e)
Exception Raised on Also carries
AuthError 401, and 403 for a bad key
ScopeError 403 with code API_KEY_SCOPE
CreditsError 402: INSUFFICIENT_CREDITS or DOCUMENT_PLAN_REQUIRED balance, document_plan_required, contact
UnprocessableError 422. Media we could not judge reasons
RateLimitError 429: the per-minute limit, or a usage ceiling (DAILY_LIMIT, MONTHLY_LIMIT) retry_after_seconds
PlatformError 5xx: DETECTORS_UNAVAILABLE (500), LINK_RESOLUTION_UNAVAILABLE (501), DETECTION_NOT_BILLABLE (502), DETECTION_PLATFORM_TIMEOUT or DETECTION_PLATFORM_UNREACHABLE (504)
APIError Any other HTTP status: 400 NO_FILE_UPLOADED or INVALID_URL, 403 EMAIL_VERIFICATION_REQUIRED, 404 NOT_FOUND, 413 FILE_TOO_LARGE or LINK_MEDIA_TOO_LARGE
TimeoutError The deadline passed
ConnectionError The host could not be reached
MediaError The file could not be read, before any request path
ConfigError Constructed without an API key

A PlatformError is never charged — with one exception: a run that ended in DETECTION_PLATFORM_TIMEOUT may still have completed on our side. Check your usage history before retrying the same media.

Everything above subclasses ScamAIError. The HTTP ones subclass APIError and carry status, code, body and request_id; quote request_id in a support request. TimeoutError and ConnectionError are ours, not the builtins, so except ScamAIError still catches them.

Three of these are worth a note:

  • UnprocessableError is an answer, not an outage. Show it to your user rather than retrying, and it is never charged. code is UNDECODABLE_IMAGE, UNSUPPORTED_MEDIA_TYPE, LINK_NOT_RESOLVABLE or NOT_ASSESSABLE (with reasons such as no_face), and stays stable where the message does not.
  • TimeoutError does not mean the run did not happen. It may have completed and been billed. Check history.list() before re-sending.
  • MediaError and ConfigError are raised before any request. No call is made, so nothing is charged.

Configuration

client = ScamAI(
    api_key=os.environ["SCAMAI_API_KEY"],
    base_url="https://api.scam.ai/api",  # or SCAMAI_API_BASE
    timeout=120.0,                       # seconds, following httpx
    max_retries=2,                       # reads only, see below
    default_headers={"x-source": "review-queue"},
)

timeout is in seconds. timeout_ms is the same deadline in milliseconds and matches the TypeScript SDK's timeoutMs, so code ported between the two keeps its meaning. Passing both raises TypeError rather than silently picking one.

Detections are never retried automatically. A retried detect() runs again and is billed again, so retrying it has to be your decision:

client.detect(file, retry=True)  # opt in, knowing the cost

Reads retry on their own, up to max_retries, honouring Retry-After on a 429.

Requests are limited per key: 60 a minute by default, 80 or 100 on higher tiers, reported in X-RateLimit-* and RateLimit-* headers. Accounts also carry a daily and a monthly usage ceiling; a run past either is refused before it starts with a RateLimitError whose code is DAILY_LIMIT or MONTHLY_LIMIT.

Support

Keys, usage and billing are in the dashboard. For anything else, contact support and include the request_id from the error.

Versioning

This package follows semantic versioning. While the major version is 0, a minor release may change the surface; pin an exact version if that matters to you.

License

MIT. The TypeScript twin is @scam-ai/sdk.

Metadata

Release files for scamai 0.1.2

For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.

Source distribution (sdist)

Source distribution for scamai 0.1.2
File Size Uploaded Provenance
scamai-0.1.2.tar.gz 18.5 kB Without provenance Details

Built distribution (wheel)

Table of built distributions (wheels) for scamai 0.1.2
File Interpreter ABI Platform
scamai-0.1.2-py3-none-any.whl Python 3 none any Details

Total release size: 37.5 kB

Release files / scamai-0.1.2.tar.gz

Download URL scamai-0.1.2.tar.gz
Size 18.5 kB
Tags Source
SHA-256 checksum
How to use checksums
69478c45d53d312b133d8adecae195ae164f101704e93bc44c8f3bc39fdee154
BLAKE2b-256 checksum
How to use checksums
8db111dddd077851ae325783cc1e801dd17885d49141738343df8f744462fc69
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
No
Uploaded via twine/7.0.0 CPython/3.12.11

Release files / scamai-0.1.2-py3-none-any.whl

Download URL scamai-0.1.2-py3-none-any.whl
Size 19.0 kB
Tags Python 3
SHA-256 checksum
How to use checksums
a1840e7f95562ae295ff109956f2fd8292f33c412e1e53c2065015ad7b700ddc
BLAKE2b-256 checksum
How to use checksums
d0b14b40ca1d0a05f80c0677805fba952bd3f01eae61794cddc1a4d8be6735ac
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
No
Uploaded via twine/7.0.0 CPython/3.12.11

Security for scamai 0.1.2

Provenance

Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.

No provenance found

None of the files in this release were uploaded with provenance metadata.

A note on package security

PyPI is a repository for distributing packages, not a service for auditing them. While third-party security researchers and our staff do their best to detect and remove malicious packages, PyPI cannot guarantee the quality, security or suitability of any given package.

To learn more about protecting yourself from malicious actors, visit our package security documentation.

Anthropic, PBC Visionary sponsor Bloomberg Visionary sponsor Hudson River Trading Visionary sponsor Meta Visionary sponsor NVIDIA Visionary sponsor Microsoft Sustainability sponsor Depot Continuous Integration AWS Cloud computing and Security Sponsor Datadog Monitoring Fastly CDN Google Download Analytics Sentry Error logging StatusPage Status page