Metadata-Version: 2.4
Name: scamai
Version: 0.1.3
Summary: Official Python SDK for the ScamAI detection platform. One detect() for images, video and audio, plus account and usage.
Project-URL: Homepage, https://scam.ai
Project-URL: Documentation, https://app.scam.ai
Project-URL: Support, https://scam.ai/contact
Author: Scam.ai
License: MIT
License-File: LICENSE
Keywords: ai-generated,deepfake,detection,fraud,scamai
Classifier: Development Status :: 4 - Beta
Classifier: Intended Audience :: Developers
Classifier: License :: OSI Approved :: MIT License
Classifier: Programming Language :: Python :: 3
Classifier: Programming Language :: Python :: 3.9
Classifier: Programming Language :: Python :: 3.10
Classifier: Programming Language :: Python :: 3.11
Classifier: Programming Language :: Python :: 3.12
Classifier: Programming Language :: Python :: 3.13
Classifier: Typing :: Typed
Requires-Python: >=3.9
Requires-Dist: httpx>=0.27
Description-Content-Type: text/markdown

# scamai

Official Python SDK for the [ScamAI](https://scam.ai) 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

```bash
pip install scamai
```

## Quickstart

```python
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:

```python
from scamai import AsyncScamAI

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

## Authentication

Keys are created in the [dashboard](https://app.scam.ai/api-keys) and shown
once. The client reads `SCAMAI_API_KEY` from the environment by default:

```bash
export SCAMAI_API_KEY=sk_...
```

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

```python
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:

```json
{
  "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:

```json
{
  "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`:

```python
det = client.detections.create_from_url("https://www.youtube.com/watch?v=dQw4w9WgXcQ")
```

```json
{
  "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.

```python
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:

```python
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:

```python
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:

- **`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

```python
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:

```python
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](https://app.scam.ai). For
anything else, contact [support](https://scam.ai/contact) 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`](https://www.npmjs.com/package/@scam-ai/sdk).
