scamai 0.1.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- scamai-0.1.0/.gitignore +6 -0
- scamai-0.1.0/LICENSE +21 -0
- scamai-0.1.0/PKG-INFO +305 -0
- scamai-0.1.0/README.md +280 -0
- scamai-0.1.0/pyproject.toml +37 -0
- scamai-0.1.0/src/scamai/__init__.py +53 -0
- scamai-0.1.0/src/scamai/_async.py +251 -0
- scamai-0.1.0/src/scamai/_client.py +140 -0
- scamai-0.1.0/src/scamai/_errors.py +177 -0
- scamai-0.1.0/src/scamai/_sync.py +266 -0
- scamai-0.1.0/src/scamai/_webhooks.py +66 -0
- scamai-0.1.0/src/scamai/py.typed +0 -0
- scamai-0.1.0/tests/test_sdk.py +417 -0
scamai-0.1.0/.gitignore
ADDED
scamai-0.1.0/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Reality Inc. (scam.ai)
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
scamai-0.1.0/PKG-INFO
ADDED
|
@@ -0,0 +1,305 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: scamai
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Official Python SDK for the ScamAI detection platform. One detect() for images, video and audio, plus account, usage and webhooks.
|
|
5
|
+
Project-URL: Homepage, https://scam.ai
|
|
6
|
+
Project-URL: Documentation, https://app.scam.ai
|
|
7
|
+
Project-URL: Support, https://scam.ai/contact
|
|
8
|
+
Author: Scam.ai
|
|
9
|
+
License: MIT
|
|
10
|
+
License-File: LICENSE
|
|
11
|
+
Keywords: ai-generated,deepfake,detection,fraud,scamai
|
|
12
|
+
Classifier: Development Status :: 4 - Beta
|
|
13
|
+
Classifier: Intended Audience :: Developers
|
|
14
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
15
|
+
Classifier: Programming Language :: Python :: 3
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.9
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
19
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
20
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
21
|
+
Classifier: Typing :: Typed
|
|
22
|
+
Requires-Python: >=3.9
|
|
23
|
+
Requires-Dist: httpx>=0.27
|
|
24
|
+
Description-Content-Type: text/markdown
|
|
25
|
+
|
|
26
|
+
# scamai
|
|
27
|
+
|
|
28
|
+
Official Python SDK for the [ScamAI](https://scam.ai) detection platform. One
|
|
29
|
+
`detect()` call covers images, video and audio, with typed exceptions for every
|
|
30
|
+
refusal, plus account, usage, history and webhooks. Sync and async clients share
|
|
31
|
+
the same surface.
|
|
32
|
+
|
|
33
|
+
## Requirements
|
|
34
|
+
|
|
35
|
+
Python 3.9 or later. Server-side only: an API key grants full access to your
|
|
36
|
+
account, so keep it out of client-side code and out of version control.
|
|
37
|
+
|
|
38
|
+
## Installation
|
|
39
|
+
|
|
40
|
+
```bash
|
|
41
|
+
pip install scamai
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
## Quickstart
|
|
45
|
+
|
|
46
|
+
```python
|
|
47
|
+
from scamai import ScamAI
|
|
48
|
+
|
|
49
|
+
client = ScamAI() # reads SCAMAI_API_KEY
|
|
50
|
+
|
|
51
|
+
det = client.detect("suspect.jpg")
|
|
52
|
+
|
|
53
|
+
print(det["verdict"]) # "LIKELY_AI_MANIPULATED"
|
|
54
|
+
print(det["confidence"]) # 0.99
|
|
55
|
+
print(det["credits_used"]) # 1
|
|
56
|
+
```
|
|
57
|
+
|
|
58
|
+
The async client is the same surface, awaited. Use it inside FastAPI, aiohttp or
|
|
59
|
+
any event loop, where a blocking HTTP call would stall every other request:
|
|
60
|
+
|
|
61
|
+
```python
|
|
62
|
+
from scamai import AsyncScamAI
|
|
63
|
+
|
|
64
|
+
async with AsyncScamAI() as client:
|
|
65
|
+
det = await client.detect("suspect.jpg")
|
|
66
|
+
```
|
|
67
|
+
|
|
68
|
+
## Authentication
|
|
69
|
+
|
|
70
|
+
Keys are created in the [dashboard](https://app.scam.ai/api-keys) and shown
|
|
71
|
+
once. The client reads `SCAMAI_API_KEY` from the environment by default:
|
|
72
|
+
|
|
73
|
+
```bash
|
|
74
|
+
export SCAMAI_API_KEY=sk_...
|
|
75
|
+
```
|
|
76
|
+
|
|
77
|
+
Pass it directly when your keys live somewhere else, such as a secrets manager:
|
|
78
|
+
|
|
79
|
+
```python
|
|
80
|
+
client = ScamAI(api_key=secrets.get("scamai"))
|
|
81
|
+
```
|
|
82
|
+
|
|
83
|
+
Requests are authenticated with the `x-api-key` header. `Authorization: Bearer`
|
|
84
|
+
is read by the gateway as a dashboard session token and answers 401, so the SDK
|
|
85
|
+
never sends it.
|
|
86
|
+
|
|
87
|
+
## The surface
|
|
88
|
+
|
|
89
|
+
| | |
|
|
90
|
+
|---|---|
|
|
91
|
+
| `client.detect(file, ...)` | The unified endpoint (`POST /v1/detections`). `file` is a path, `bytes`, an open binary file, or `(filename, data, content_type)`. |
|
|
92
|
+
| `client.detections.create_from_url(url)` | Link intake. The gateway fetches the media. |
|
|
93
|
+
| `client.detections.get(id)` | Read a past detection back. Returns the same envelope the original call answered. |
|
|
94
|
+
| `client.tasks.receipt(task_id)` | The verdict receipt for a past detection. |
|
|
95
|
+
| `client.account.profile / balance / ledger / subscriptions` | Who you are and what you have spent. |
|
|
96
|
+
| `client.keys.list / create / revoke` | API keys. `create()` regenerates per scope, and the returned `value` is shown once. |
|
|
97
|
+
| `client.history.list / stats` | Detection history and aggregates. |
|
|
98
|
+
| `client.usage.pricing()` | The per-service price catalog. |
|
|
99
|
+
| `client.webhooks.list / create(url) / remove(id) / test(id)` | `detection.completed` deliveries, HMAC-signed. |
|
|
100
|
+
| `verify_webhook_signature(raw_body, header, secret)` | Verify `X-Scamai-Signature`. |
|
|
101
|
+
| `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. |
|
|
102
|
+
|
|
103
|
+
Responses are plain dicts, verbatim from the wire.
|
|
104
|
+
|
|
105
|
+
## What comes back
|
|
106
|
+
|
|
107
|
+
One envelope for every media type. The base fields are always present. Video and
|
|
108
|
+
audio each add their own, and a field that does not apply to a kind is **absent
|
|
109
|
+
rather than None**, so use `det.get(...)` instead of comparing against `None`.
|
|
110
|
+
|
|
111
|
+
| Field | Kind | |
|
|
112
|
+
|---|---|---|
|
|
113
|
+
| `verdict` | all | The routing decision: `LIKELY_AUTHENTIC`, `SUSPICIOUS` or `LIKELY_AI_MANIPULATED`. |
|
|
114
|
+
| `confidence` | all | 0 to 1, or `None` when the detector did not commit. The only score. Sort a review queue on it. |
|
|
115
|
+
| `summary` | all | One plain-English sentence. |
|
|
116
|
+
| `model` | all | The public label, for example `"Eva V1.6"`. Always a string. |
|
|
117
|
+
| `credits_used` | all | What the ledger actually debited. |
|
|
118
|
+
| `media` | all | `{type, filename, mime_type, bytes}`. |
|
|
119
|
+
| `id` | all | This detection's id. Pass it to `detections.get()` to read the run back. |
|
|
120
|
+
| `created_at` | all | ISO 8601, UTC. |
|
|
121
|
+
| `object` / `status` | all | Always `"detection"` and `"completed"` on a synchronous run. |
|
|
122
|
+
| `frames` / `frames_analyzed` | video | The per-frame series, and its length. |
|
|
123
|
+
| `frames_metered` | video | Frames billed. Not the same as `frames_analyzed`. |
|
|
124
|
+
| `threshold_used` | video | The line the verdict was decided against. |
|
|
125
|
+
| `duration_ms` / `segments` | audio | Clip length (the meter), and the per-window timeline. |
|
|
126
|
+
| `zero_charge_reason` | any | Only on a free duplicate run. |
|
|
127
|
+
| `source` | any | Only when the media came from a link. |
|
|
128
|
+
|
|
129
|
+
Handle all three verdicts. A branch that omits one falls through silently.
|
|
130
|
+
|
|
131
|
+
## Reading a detection back
|
|
132
|
+
|
|
133
|
+
`detect()` answers on the same request, so there is no job to poll. A long video
|
|
134
|
+
holds the call open until it finishes.
|
|
135
|
+
|
|
136
|
+
```python
|
|
137
|
+
det = client.detect("suspect.jpg")
|
|
138
|
+
store(det["id"]) # the handle to this run
|
|
139
|
+
|
|
140
|
+
# Later, the same envelope again.
|
|
141
|
+
again = client.detections.get(det["id"])
|
|
142
|
+
```
|
|
143
|
+
|
|
144
|
+
Without the id, find the run in the history and read it back from there. The
|
|
145
|
+
same id also resolves a receipt, which is smaller and carries no PII:
|
|
146
|
+
|
|
147
|
+
```python
|
|
148
|
+
page = client.history.list(limit=20, offset=0)
|
|
149
|
+
receipt = client.tasks.receipt(page["history"][0]["task_id"])
|
|
150
|
+
```
|
|
151
|
+
|
|
152
|
+
`history.list()` pages with `limit` and `offset`, and filters on `service_type`,
|
|
153
|
+
`success`, `start_date`, `end_date` and `search`.
|
|
154
|
+
|
|
155
|
+
## Webhooks
|
|
156
|
+
|
|
157
|
+
Register an endpoint, then verify every delivery before you trust it. The
|
|
158
|
+
signature is computed over the **raw** request body, so read the body as bytes
|
|
159
|
+
and verify it before any JSON parsing.
|
|
160
|
+
|
|
161
|
+
```python
|
|
162
|
+
endpoint = client.webhooks.create("https://example.com/hooks/scamai")
|
|
163
|
+
# endpoint["secret"] is returned once, here, and never again. Store it now.
|
|
164
|
+
```
|
|
165
|
+
|
|
166
|
+
`X-Scamai-Signature` carries `t=<unix seconds>,v1=<hex>`, where the hex is
|
|
167
|
+
`HMAC-SHA256(secret, "<t>.<raw_body>")`. This is the Stripe scheme, so existing
|
|
168
|
+
verification code ports over.
|
|
169
|
+
|
|
170
|
+
```python
|
|
171
|
+
import os
|
|
172
|
+
from fastapi import FastAPI, Request, Response
|
|
173
|
+
from scamai import verify_webhook_signature, ScamAIError
|
|
174
|
+
|
|
175
|
+
app = FastAPI()
|
|
176
|
+
|
|
177
|
+
@app.post("/hooks/scamai")
|
|
178
|
+
async def scamai_webhook(request: Request):
|
|
179
|
+
try:
|
|
180
|
+
event = verify_webhook_signature(
|
|
181
|
+
await request.body(), # raw bytes, not a parsed dict
|
|
182
|
+
request.headers.get("x-scamai-signature"),
|
|
183
|
+
os.environ["SCAMAI_WEBHOOK_SECRET"], # the secret from create()
|
|
184
|
+
)
|
|
185
|
+
except ScamAIError:
|
|
186
|
+
return Response("bad signature", status_code=400)
|
|
187
|
+
|
|
188
|
+
if event["type"] == "detection.completed":
|
|
189
|
+
handle(event["data"])
|
|
190
|
+
return Response(status_code=200) # acknowledge fast, do the work off the request
|
|
191
|
+
```
|
|
192
|
+
|
|
193
|
+
Deliveries older than five minutes are rejected, which bounds replay of a
|
|
194
|
+
captured request. `client.webhooks.test(endpoint["id"])` sends a delivery so you
|
|
195
|
+
can confirm the endpoint before real traffic reaches it;
|
|
196
|
+
`event.get("test")` is true only for those.
|
|
197
|
+
|
|
198
|
+
A `detection.completed` delivery carries the run's own words, the same `verdict`
|
|
199
|
+
and `confidence` `detect()` returned for it:
|
|
200
|
+
|
|
201
|
+
| `event["data"]` | |
|
|
202
|
+
|---|---|
|
|
203
|
+
| `taskId` | The detection's id. The same one `detections.get()` takes. |
|
|
204
|
+
| `verdict` | `LIKELY_AUTHENTIC`, `SUSPICIOUS` or `LIKELY_AI_MANIPULATED`. Absent when the run scored nothing, never a stand-in value. |
|
|
205
|
+
| `confidence` | 0 to 1. Absent for the same reason `verdict` is. |
|
|
206
|
+
| `credits` | What the run was charged. |
|
|
207
|
+
|
|
208
|
+
```python
|
|
209
|
+
data = event["data"]
|
|
210
|
+
|
|
211
|
+
if data.get("verdict") == "LIKELY_AI_MANIPULATED":
|
|
212
|
+
escalate(data["taskId"])
|
|
213
|
+
elif data.get("verdict") is None:
|
|
214
|
+
pass # the run scored nothing, which is not the same as authentic
|
|
215
|
+
```
|
|
216
|
+
|
|
217
|
+
## Errors
|
|
218
|
+
|
|
219
|
+
Every failure this package raises is a `ScamAIError`, so one `except` covers all
|
|
220
|
+
of them:
|
|
221
|
+
|
|
222
|
+
```python
|
|
223
|
+
from scamai import ScamAIError, CreditsError, UnprocessableError
|
|
224
|
+
|
|
225
|
+
try:
|
|
226
|
+
client.detect("suspect.jpg")
|
|
227
|
+
except CreditsError as e:
|
|
228
|
+
top_up(e.balance)
|
|
229
|
+
except UnprocessableError as e:
|
|
230
|
+
show_to_user(str(e))
|
|
231
|
+
except ScamAIError as e:
|
|
232
|
+
log_and_alert(e)
|
|
233
|
+
```
|
|
234
|
+
|
|
235
|
+
| Exception | Raised on | Also carries |
|
|
236
|
+
|---|---|---|
|
|
237
|
+
| `AuthError` | 401, and 403 for a bad key | |
|
|
238
|
+
| `ScopeError` | 403 with code `API_KEY_SCOPE` | |
|
|
239
|
+
| `CreditsError` | 402 | `balance`, `document_plan_required`, `contact` |
|
|
240
|
+
| `UnprocessableError` | 422. Media we could not judge | `reasons` |
|
|
241
|
+
| `RateLimitError` | 429 | `retry_after_seconds` |
|
|
242
|
+
| `PlatformError` | 5xx | |
|
|
243
|
+
| `APIError` | Any other HTTP status | |
|
|
244
|
+
| `TimeoutError` | The deadline passed | |
|
|
245
|
+
| `ConnectionError` | The host could not be reached | |
|
|
246
|
+
| `MediaError` | The file could not be read, before any request | `path` |
|
|
247
|
+
| `ConfigError` | Constructed without an API key | |
|
|
248
|
+
| `WebhookVerificationError` | A delivery did not verify | |
|
|
249
|
+
|
|
250
|
+
Everything above subclasses `ScamAIError`. The HTTP ones subclass `APIError` and
|
|
251
|
+
carry `status`, `code`, `body` and `request_id`; quote `request_id` in a support
|
|
252
|
+
request. `TimeoutError` and `ConnectionError` are ours, not the builtins, so
|
|
253
|
+
`except ScamAIError` still catches them.
|
|
254
|
+
|
|
255
|
+
Three of these are worth a note:
|
|
256
|
+
|
|
257
|
+
- **`UnprocessableError` is an answer, not an outage.** Show it to your user
|
|
258
|
+
rather than retrying, and it is never charged. `code` is `undecodable_image`,
|
|
259
|
+
`unsupported_media_type` or `link_not_resolvable`, and stays stable where the
|
|
260
|
+
message does not.
|
|
261
|
+
- **`TimeoutError` does not mean the run did not happen.** It may have completed
|
|
262
|
+
and been billed. Check `history.list()` before re-sending.
|
|
263
|
+
- **`MediaError` and `ConfigError` are raised before any request.** No call is
|
|
264
|
+
made, so nothing is charged.
|
|
265
|
+
|
|
266
|
+
## Configuration
|
|
267
|
+
|
|
268
|
+
```python
|
|
269
|
+
client = ScamAI(
|
|
270
|
+
api_key=os.environ["SCAMAI_API_KEY"],
|
|
271
|
+
base_url="https://api.scam.ai/api", # or SCAMAI_API_BASE
|
|
272
|
+
timeout=120.0, # seconds, following httpx
|
|
273
|
+
max_retries=2, # reads only, see below
|
|
274
|
+
default_headers={"x-source": "review-queue"},
|
|
275
|
+
)
|
|
276
|
+
```
|
|
277
|
+
|
|
278
|
+
`timeout` is in seconds. `timeout_ms` is the same deadline in milliseconds and
|
|
279
|
+
matches the TypeScript SDK's `timeoutMs`, so code ported between the two keeps
|
|
280
|
+
its meaning. Passing both raises `TypeError` rather than silently picking one.
|
|
281
|
+
|
|
282
|
+
**Detections are never retried automatically.** A retried `detect()` runs again
|
|
283
|
+
and is billed again, so retrying it has to be your decision:
|
|
284
|
+
|
|
285
|
+
```python
|
|
286
|
+
client.detect(file, retry=True) # opt in, knowing the cost
|
|
287
|
+
```
|
|
288
|
+
|
|
289
|
+
Reads retry on their own, up to `max_retries`, honouring `Retry-After` on a 429.
|
|
290
|
+
|
|
291
|
+
## Support
|
|
292
|
+
|
|
293
|
+
Keys, usage and billing are in the [dashboard](https://app.scam.ai). For
|
|
294
|
+
anything else, contact [support](https://scam.ai/contact) and include the
|
|
295
|
+
`request_id` from the error.
|
|
296
|
+
|
|
297
|
+
## Versioning
|
|
298
|
+
|
|
299
|
+
This package follows semantic versioning. While the major version is `0`, a
|
|
300
|
+
minor release may change the surface; pin an exact version if that matters to
|
|
301
|
+
you.
|
|
302
|
+
|
|
303
|
+
## License
|
|
304
|
+
|
|
305
|
+
MIT. The TypeScript twin is [`@scam-ai/sdk`](https://www.npmjs.com/package/@scam-ai/sdk).
|
scamai-0.1.0/README.md
ADDED
|
@@ -0,0 +1,280 @@
|
|
|
1
|
+
# scamai
|
|
2
|
+
|
|
3
|
+
Official Python SDK for the [ScamAI](https://scam.ai) detection platform. One
|
|
4
|
+
`detect()` call covers images, video and audio, with typed exceptions for every
|
|
5
|
+
refusal, plus account, usage, history and webhooks. Sync and async clients share
|
|
6
|
+
the same surface.
|
|
7
|
+
|
|
8
|
+
## Requirements
|
|
9
|
+
|
|
10
|
+
Python 3.9 or later. Server-side only: an API key grants full access to your
|
|
11
|
+
account, so keep it out of client-side code and out of version control.
|
|
12
|
+
|
|
13
|
+
## Installation
|
|
14
|
+
|
|
15
|
+
```bash
|
|
16
|
+
pip install scamai
|
|
17
|
+
```
|
|
18
|
+
|
|
19
|
+
## Quickstart
|
|
20
|
+
|
|
21
|
+
```python
|
|
22
|
+
from scamai import ScamAI
|
|
23
|
+
|
|
24
|
+
client = ScamAI() # reads SCAMAI_API_KEY
|
|
25
|
+
|
|
26
|
+
det = client.detect("suspect.jpg")
|
|
27
|
+
|
|
28
|
+
print(det["verdict"]) # "LIKELY_AI_MANIPULATED"
|
|
29
|
+
print(det["confidence"]) # 0.99
|
|
30
|
+
print(det["credits_used"]) # 1
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
The async client is the same surface, awaited. Use it inside FastAPI, aiohttp or
|
|
34
|
+
any event loop, where a blocking HTTP call would stall every other request:
|
|
35
|
+
|
|
36
|
+
```python
|
|
37
|
+
from scamai import AsyncScamAI
|
|
38
|
+
|
|
39
|
+
async with AsyncScamAI() as client:
|
|
40
|
+
det = await client.detect("suspect.jpg")
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
## Authentication
|
|
44
|
+
|
|
45
|
+
Keys are created in the [dashboard](https://app.scam.ai/api-keys) and shown
|
|
46
|
+
once. The client reads `SCAMAI_API_KEY` from the environment by default:
|
|
47
|
+
|
|
48
|
+
```bash
|
|
49
|
+
export SCAMAI_API_KEY=sk_...
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
Pass it directly when your keys live somewhere else, such as a secrets manager:
|
|
53
|
+
|
|
54
|
+
```python
|
|
55
|
+
client = ScamAI(api_key=secrets.get("scamai"))
|
|
56
|
+
```
|
|
57
|
+
|
|
58
|
+
Requests are authenticated with the `x-api-key` header. `Authorization: Bearer`
|
|
59
|
+
is read by the gateway as a dashboard session token and answers 401, so the SDK
|
|
60
|
+
never sends it.
|
|
61
|
+
|
|
62
|
+
## The surface
|
|
63
|
+
|
|
64
|
+
| | |
|
|
65
|
+
|---|---|
|
|
66
|
+
| `client.detect(file, ...)` | The unified endpoint (`POST /v1/detections`). `file` is a path, `bytes`, an open binary file, or `(filename, data, content_type)`. |
|
|
67
|
+
| `client.detections.create_from_url(url)` | Link intake. The gateway fetches the media. |
|
|
68
|
+
| `client.detections.get(id)` | Read a past detection back. Returns the same envelope the original call answered. |
|
|
69
|
+
| `client.tasks.receipt(task_id)` | The verdict receipt for a past detection. |
|
|
70
|
+
| `client.account.profile / balance / ledger / subscriptions` | Who you are and what you have spent. |
|
|
71
|
+
| `client.keys.list / create / revoke` | API keys. `create()` regenerates per scope, and the returned `value` is shown once. |
|
|
72
|
+
| `client.history.list / stats` | Detection history and aggregates. |
|
|
73
|
+
| `client.usage.pricing()` | The per-service price catalog. |
|
|
74
|
+
| `client.webhooks.list / create(url) / remove(id) / test(id)` | `detection.completed` deliveries, HMAC-signed. |
|
|
75
|
+
| `verify_webhook_signature(raw_body, header, secret)` | Verify `X-Scamai-Signature`. |
|
|
76
|
+
| `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. |
|
|
77
|
+
|
|
78
|
+
Responses are plain dicts, verbatim from the wire.
|
|
79
|
+
|
|
80
|
+
## What comes back
|
|
81
|
+
|
|
82
|
+
One envelope for every media type. The base fields are always present. Video and
|
|
83
|
+
audio each add their own, and a field that does not apply to a kind is **absent
|
|
84
|
+
rather than None**, so use `det.get(...)` instead of comparing against `None`.
|
|
85
|
+
|
|
86
|
+
| Field | Kind | |
|
|
87
|
+
|---|---|---|
|
|
88
|
+
| `verdict` | all | The routing decision: `LIKELY_AUTHENTIC`, `SUSPICIOUS` or `LIKELY_AI_MANIPULATED`. |
|
|
89
|
+
| `confidence` | all | 0 to 1, or `None` when the detector did not commit. The only score. Sort a review queue on it. |
|
|
90
|
+
| `summary` | all | One plain-English sentence. |
|
|
91
|
+
| `model` | all | The public label, for example `"Eva V1.6"`. Always a string. |
|
|
92
|
+
| `credits_used` | all | What the ledger actually debited. |
|
|
93
|
+
| `media` | all | `{type, filename, mime_type, bytes}`. |
|
|
94
|
+
| `id` | all | This detection's id. Pass it to `detections.get()` to read the run back. |
|
|
95
|
+
| `created_at` | all | ISO 8601, UTC. |
|
|
96
|
+
| `object` / `status` | all | Always `"detection"` and `"completed"` on a synchronous run. |
|
|
97
|
+
| `frames` / `frames_analyzed` | video | The per-frame series, and its length. |
|
|
98
|
+
| `frames_metered` | video | Frames billed. Not the same as `frames_analyzed`. |
|
|
99
|
+
| `threshold_used` | video | The line the verdict was decided against. |
|
|
100
|
+
| `duration_ms` / `segments` | audio | Clip length (the meter), and the per-window timeline. |
|
|
101
|
+
| `zero_charge_reason` | any | Only on a free duplicate run. |
|
|
102
|
+
| `source` | any | Only when the media came from a link. |
|
|
103
|
+
|
|
104
|
+
Handle all three verdicts. A branch that omits one falls through silently.
|
|
105
|
+
|
|
106
|
+
## Reading a detection back
|
|
107
|
+
|
|
108
|
+
`detect()` answers on the same request, so there is no job to poll. A long video
|
|
109
|
+
holds the call open until it finishes.
|
|
110
|
+
|
|
111
|
+
```python
|
|
112
|
+
det = client.detect("suspect.jpg")
|
|
113
|
+
store(det["id"]) # the handle to this run
|
|
114
|
+
|
|
115
|
+
# Later, the same envelope again.
|
|
116
|
+
again = client.detections.get(det["id"])
|
|
117
|
+
```
|
|
118
|
+
|
|
119
|
+
Without the id, find the run in the history and read it back from there. The
|
|
120
|
+
same id also resolves a receipt, which is smaller and carries no PII:
|
|
121
|
+
|
|
122
|
+
```python
|
|
123
|
+
page = client.history.list(limit=20, offset=0)
|
|
124
|
+
receipt = client.tasks.receipt(page["history"][0]["task_id"])
|
|
125
|
+
```
|
|
126
|
+
|
|
127
|
+
`history.list()` pages with `limit` and `offset`, and filters on `service_type`,
|
|
128
|
+
`success`, `start_date`, `end_date` and `search`.
|
|
129
|
+
|
|
130
|
+
## Webhooks
|
|
131
|
+
|
|
132
|
+
Register an endpoint, then verify every delivery before you trust it. The
|
|
133
|
+
signature is computed over the **raw** request body, so read the body as bytes
|
|
134
|
+
and verify it before any JSON parsing.
|
|
135
|
+
|
|
136
|
+
```python
|
|
137
|
+
endpoint = client.webhooks.create("https://example.com/hooks/scamai")
|
|
138
|
+
# endpoint["secret"] is returned once, here, and never again. Store it now.
|
|
139
|
+
```
|
|
140
|
+
|
|
141
|
+
`X-Scamai-Signature` carries `t=<unix seconds>,v1=<hex>`, where the hex is
|
|
142
|
+
`HMAC-SHA256(secret, "<t>.<raw_body>")`. This is the Stripe scheme, so existing
|
|
143
|
+
verification code ports over.
|
|
144
|
+
|
|
145
|
+
```python
|
|
146
|
+
import os
|
|
147
|
+
from fastapi import FastAPI, Request, Response
|
|
148
|
+
from scamai import verify_webhook_signature, ScamAIError
|
|
149
|
+
|
|
150
|
+
app = FastAPI()
|
|
151
|
+
|
|
152
|
+
@app.post("/hooks/scamai")
|
|
153
|
+
async def scamai_webhook(request: Request):
|
|
154
|
+
try:
|
|
155
|
+
event = verify_webhook_signature(
|
|
156
|
+
await request.body(), # raw bytes, not a parsed dict
|
|
157
|
+
request.headers.get("x-scamai-signature"),
|
|
158
|
+
os.environ["SCAMAI_WEBHOOK_SECRET"], # the secret from create()
|
|
159
|
+
)
|
|
160
|
+
except ScamAIError:
|
|
161
|
+
return Response("bad signature", status_code=400)
|
|
162
|
+
|
|
163
|
+
if event["type"] == "detection.completed":
|
|
164
|
+
handle(event["data"])
|
|
165
|
+
return Response(status_code=200) # acknowledge fast, do the work off the request
|
|
166
|
+
```
|
|
167
|
+
|
|
168
|
+
Deliveries older than five minutes are rejected, which bounds replay of a
|
|
169
|
+
captured request. `client.webhooks.test(endpoint["id"])` sends a delivery so you
|
|
170
|
+
can confirm the endpoint before real traffic reaches it;
|
|
171
|
+
`event.get("test")` is true only for those.
|
|
172
|
+
|
|
173
|
+
A `detection.completed` delivery carries the run's own words, the same `verdict`
|
|
174
|
+
and `confidence` `detect()` returned for it:
|
|
175
|
+
|
|
176
|
+
| `event["data"]` | |
|
|
177
|
+
|---|---|
|
|
178
|
+
| `taskId` | The detection's id. The same one `detections.get()` takes. |
|
|
179
|
+
| `verdict` | `LIKELY_AUTHENTIC`, `SUSPICIOUS` or `LIKELY_AI_MANIPULATED`. Absent when the run scored nothing, never a stand-in value. |
|
|
180
|
+
| `confidence` | 0 to 1. Absent for the same reason `verdict` is. |
|
|
181
|
+
| `credits` | What the run was charged. |
|
|
182
|
+
|
|
183
|
+
```python
|
|
184
|
+
data = event["data"]
|
|
185
|
+
|
|
186
|
+
if data.get("verdict") == "LIKELY_AI_MANIPULATED":
|
|
187
|
+
escalate(data["taskId"])
|
|
188
|
+
elif data.get("verdict") is None:
|
|
189
|
+
pass # the run scored nothing, which is not the same as authentic
|
|
190
|
+
```
|
|
191
|
+
|
|
192
|
+
## Errors
|
|
193
|
+
|
|
194
|
+
Every failure this package raises is a `ScamAIError`, so one `except` covers all
|
|
195
|
+
of them:
|
|
196
|
+
|
|
197
|
+
```python
|
|
198
|
+
from scamai import ScamAIError, CreditsError, UnprocessableError
|
|
199
|
+
|
|
200
|
+
try:
|
|
201
|
+
client.detect("suspect.jpg")
|
|
202
|
+
except CreditsError as e:
|
|
203
|
+
top_up(e.balance)
|
|
204
|
+
except UnprocessableError as e:
|
|
205
|
+
show_to_user(str(e))
|
|
206
|
+
except ScamAIError as e:
|
|
207
|
+
log_and_alert(e)
|
|
208
|
+
```
|
|
209
|
+
|
|
210
|
+
| Exception | Raised on | Also carries |
|
|
211
|
+
|---|---|---|
|
|
212
|
+
| `AuthError` | 401, and 403 for a bad key | |
|
|
213
|
+
| `ScopeError` | 403 with code `API_KEY_SCOPE` | |
|
|
214
|
+
| `CreditsError` | 402 | `balance`, `document_plan_required`, `contact` |
|
|
215
|
+
| `UnprocessableError` | 422. Media we could not judge | `reasons` |
|
|
216
|
+
| `RateLimitError` | 429 | `retry_after_seconds` |
|
|
217
|
+
| `PlatformError` | 5xx | |
|
|
218
|
+
| `APIError` | Any other HTTP status | |
|
|
219
|
+
| `TimeoutError` | The deadline passed | |
|
|
220
|
+
| `ConnectionError` | The host could not be reached | |
|
|
221
|
+
| `MediaError` | The file could not be read, before any request | `path` |
|
|
222
|
+
| `ConfigError` | Constructed without an API key | |
|
|
223
|
+
| `WebhookVerificationError` | A delivery did not verify | |
|
|
224
|
+
|
|
225
|
+
Everything above subclasses `ScamAIError`. The HTTP ones subclass `APIError` and
|
|
226
|
+
carry `status`, `code`, `body` and `request_id`; quote `request_id` in a support
|
|
227
|
+
request. `TimeoutError` and `ConnectionError` are ours, not the builtins, so
|
|
228
|
+
`except ScamAIError` still catches them.
|
|
229
|
+
|
|
230
|
+
Three of these are worth a note:
|
|
231
|
+
|
|
232
|
+
- **`UnprocessableError` is an answer, not an outage.** Show it to your user
|
|
233
|
+
rather than retrying, and it is never charged. `code` is `undecodable_image`,
|
|
234
|
+
`unsupported_media_type` or `link_not_resolvable`, and stays stable where the
|
|
235
|
+
message does not.
|
|
236
|
+
- **`TimeoutError` does not mean the run did not happen.** It may have completed
|
|
237
|
+
and been billed. Check `history.list()` before re-sending.
|
|
238
|
+
- **`MediaError` and `ConfigError` are raised before any request.** No call is
|
|
239
|
+
made, so nothing is charged.
|
|
240
|
+
|
|
241
|
+
## Configuration
|
|
242
|
+
|
|
243
|
+
```python
|
|
244
|
+
client = ScamAI(
|
|
245
|
+
api_key=os.environ["SCAMAI_API_KEY"],
|
|
246
|
+
base_url="https://api.scam.ai/api", # or SCAMAI_API_BASE
|
|
247
|
+
timeout=120.0, # seconds, following httpx
|
|
248
|
+
max_retries=2, # reads only, see below
|
|
249
|
+
default_headers={"x-source": "review-queue"},
|
|
250
|
+
)
|
|
251
|
+
```
|
|
252
|
+
|
|
253
|
+
`timeout` is in seconds. `timeout_ms` is the same deadline in milliseconds and
|
|
254
|
+
matches the TypeScript SDK's `timeoutMs`, so code ported between the two keeps
|
|
255
|
+
its meaning. Passing both raises `TypeError` rather than silently picking one.
|
|
256
|
+
|
|
257
|
+
**Detections are never retried automatically.** A retried `detect()` runs again
|
|
258
|
+
and is billed again, so retrying it has to be your decision:
|
|
259
|
+
|
|
260
|
+
```python
|
|
261
|
+
client.detect(file, retry=True) # opt in, knowing the cost
|
|
262
|
+
```
|
|
263
|
+
|
|
264
|
+
Reads retry on their own, up to `max_retries`, honouring `Retry-After` on a 429.
|
|
265
|
+
|
|
266
|
+
## Support
|
|
267
|
+
|
|
268
|
+
Keys, usage and billing are in the [dashboard](https://app.scam.ai). For
|
|
269
|
+
anything else, contact [support](https://scam.ai/contact) and include the
|
|
270
|
+
`request_id` from the error.
|
|
271
|
+
|
|
272
|
+
## Versioning
|
|
273
|
+
|
|
274
|
+
This package follows semantic versioning. While the major version is `0`, a
|
|
275
|
+
minor release may change the surface; pin an exact version if that matters to
|
|
276
|
+
you.
|
|
277
|
+
|
|
278
|
+
## License
|
|
279
|
+
|
|
280
|
+
MIT. The TypeScript twin is [`@scam-ai/sdk`](https://www.npmjs.com/package/@scam-ai/sdk).
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["hatchling"]
|
|
3
|
+
build-backend = "hatchling.build"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "scamai"
|
|
7
|
+
version = "0.1.0"
|
|
8
|
+
description = "Official Python SDK for the ScamAI detection platform. One detect() for images, video and audio, plus account, usage and webhooks."
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
license = { text = "MIT" }
|
|
11
|
+
requires-python = ">=3.9"
|
|
12
|
+
dependencies = ["httpx>=0.27"]
|
|
13
|
+
authors = [{ name = "Scam.ai" }]
|
|
14
|
+
keywords = ["scamai", "deepfake", "detection", "ai-generated", "fraud"]
|
|
15
|
+
classifiers = [
|
|
16
|
+
"Development Status :: 4 - Beta",
|
|
17
|
+
"Intended Audience :: Developers",
|
|
18
|
+
"License :: OSI Approved :: MIT License",
|
|
19
|
+
"Programming Language :: Python :: 3",
|
|
20
|
+
"Programming Language :: Python :: 3.9",
|
|
21
|
+
"Programming Language :: Python :: 3.10",
|
|
22
|
+
"Programming Language :: Python :: 3.11",
|
|
23
|
+
"Programming Language :: Python :: 3.12",
|
|
24
|
+
"Programming Language :: Python :: 3.13",
|
|
25
|
+
"Typing :: Typed",
|
|
26
|
+
]
|
|
27
|
+
|
|
28
|
+
[project.urls]
|
|
29
|
+
Homepage = "https://scam.ai"
|
|
30
|
+
Documentation = "https://app.scam.ai"
|
|
31
|
+
Support = "https://scam.ai/contact"
|
|
32
|
+
|
|
33
|
+
[tool.hatch.build.targets.wheel]
|
|
34
|
+
packages = ["src/scamai"]
|
|
35
|
+
|
|
36
|
+
[tool.pytest.ini_options]
|
|
37
|
+
testpaths = ["tests"]
|