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.
@@ -0,0 +1,6 @@
1
+ .venv/
2
+ dist/
3
+ build/
4
+ *.egg-info/
5
+ __pycache__/
6
+ .pytest_cache/
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"]