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 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 |
balance |
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:
UnprocessableErroris an answer, not an outage. Show it to your user rather than retrying, and it is never charged.codeisUNDECODABLE_IMAGE,UNSUPPORTED_MEDIA_TYPE,LINK_NOT_RESOLVABLEorNOT_ASSESSABLE(withreasonssuch asno_face), and stays stable where the message does not.TimeoutErrordoes not mean the run did not happen. It may have completed and been billed. Checkhistory.list()before re-sending.MediaErrorandConfigErrorare 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.3
For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.
Source distribution (sdist)
| File | Size | Uploaded | Provenance | |
|---|---|---|---|---|
| scamai-0.1.3.tar.gz | 18.3 kB | Without provenance | Details |
Built distribution (wheel)
| File | Interpreter | ABI | Platform | Reset |
|---|---|---|---|---|
| scamai-0.1.3-py3-none-any.whl | Python 3 | none | any | Details |
Total release size: 37.2 kB
Release files / scamai-0.1.3.tar.gz
| Download URL | scamai-0.1.3.tar.gz |
|---|---|
| Size | 18.3 kB |
| Tags | Source |
|
SHA-256 checksum How to use checksums |
c18d912fd472fd42eb7b7664eaad783a3c8641aad6f20183ca36d5eff66cd942
|
|
BLAKE2b-256 checksum How to use checksums |
d90607b85f5b2d79304b8513469c4b36d8041160fd1fff362176d3f656094404
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
No |
| Uploaded via |
twine/6.2.0 CPython/3.9.6
|
Release files / scamai-0.1.3-py3-none-any.whl
| Download URL | scamai-0.1.3-py3-none-any.whl |
|---|---|
| Size | 18.9 kB |
| Tags | Python 3 |
|
SHA-256 checksum How to use checksums |
98876267b3b1097ebb64eb919d61cf50f2a1c083e183defd90b4d1d9baa0b66f
|
|
BLAKE2b-256 checksum How to use checksums |
689707f8948b88d9817ee640bb7c34482cb43173637d28ee74b8954fc074bcef
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
No |
| Uploaded via |
twine/6.2.0 CPython/3.9.6
|
Security for scamai 0.1.3
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.