Metadata-Version: 2.4
Name: scamai
Version: 0.1.0
Summary: Official Python SDK for the ScamAI detection platform. One detect() for images, video and audio, plus account, usage and webhooks.
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, history and webhooks. 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_MANIPULATED"
print(det["confidence"])    # 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. The gateway fetches the media. |
| `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.webhooks.list / create(url) / remove(id) / test(id)` | `detection.completed` deliveries, HMAC-signed. |
| `verify_webhook_signature(raw_body, header, secret)` | Verify `X-Scamai-Signature`. |
| `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.

## 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_AUTHENTIC`, `SUSPICIOUS` or `LIKELY_AI_MANIPULATED`. |
| `confidence` | all | 0 to 1, or `None` when the detector did not commit. The only score. Sort a review queue on it. |
| `summary` | all | One plain-English sentence. |
| `model` | all | The public label, for example `"Eva V1.6"`. Always a string. |
| `credits_used` | all | What the ledger actually debited. |
| `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 | ISO 8601, UTC. |
| `object` / `status` | all | Always `"detection"` and `"completed"` on a synchronous run. |
| `frames` / `frames_analyzed` | video | The per-frame series, and its length. |
| `frames_metered` | video | Frames billed. Not the same as `frames_analyzed`. |
| `threshold_used` | video | The line the verdict was decided against. |
| `duration_ms` / `segments` | audio | Clip length (the meter), and the per-window timeline. |
| `zero_charge_reason` | any | Only on a free duplicate run. |
| `source` | any | Only when the media came from a link. |

Handle all three verdicts. A branch that omits one falls through silently.

## 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`.

## Webhooks

Register an endpoint, then verify every delivery before you trust it. The
signature is computed over the **raw** request body, so read the body as bytes
and verify it before any JSON parsing.

```python
endpoint = client.webhooks.create("https://example.com/hooks/scamai")
# endpoint["secret"] is returned once, here, and never again. Store it now.
```

`X-Scamai-Signature` carries `t=<unix seconds>,v1=<hex>`, where the hex is
`HMAC-SHA256(secret, "<t>.<raw_body>")`. This is the Stripe scheme, so existing
verification code ports over.

```python
import os
from fastapi import FastAPI, Request, Response
from scamai import verify_webhook_signature, ScamAIError

app = FastAPI()

@app.post("/hooks/scamai")
async def scamai_webhook(request: Request):
    try:
        event = verify_webhook_signature(
            await request.body(),                      # raw bytes, not a parsed dict
            request.headers.get("x-scamai-signature"),
            os.environ["SCAMAI_WEBHOOK_SECRET"],       # the secret from create()
        )
    except ScamAIError:
        return Response("bad signature", status_code=400)

    if event["type"] == "detection.completed":
        handle(event["data"])
    return Response(status_code=200)  # acknowledge fast, do the work off the request
```

Deliveries older than five minutes are rejected, which bounds replay of a
captured request. `client.webhooks.test(endpoint["id"])` sends a delivery so you
can confirm the endpoint before real traffic reaches it;
`event.get("test")` is true only for those.

A `detection.completed` delivery carries the run's own words, the same `verdict`
and `confidence` `detect()` returned for it:

| `event["data"]` | |
|---|---|
| `taskId` | The detection's id. The same one `detections.get()` takes. |
| `verdict` | `LIKELY_AUTHENTIC`, `SUSPICIOUS` or `LIKELY_AI_MANIPULATED`. Absent when the run scored nothing, never a stand-in value. |
| `confidence` | 0 to 1. Absent for the same reason `verdict` is. |
| `credits` | What the run was charged. |

```python
data = event["data"]

if data.get("verdict") == "LIKELY_AI_MANIPULATED":
    escalate(data["taskId"])
elif data.get("verdict") is None:
    pass  # the run scored nothing, which is not the same as authentic
```

## 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 | `balance`, `document_plan_required`, `contact` |
| `UnprocessableError` | 422. Media we could not judge | `reasons` |
| `RateLimitError` | 429 | `retry_after_seconds` |
| `PlatformError` | 5xx | |
| `APIError` | Any other HTTP status | |
| `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 | |
| `WebhookVerificationError` | A delivery did not verify | |

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` or `link_not_resolvable`, 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.

## 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).
