{"openapi":"3.0.3","info":{"title":"Scam AI Platform API","version":"1.0","description":"The Scam AI detection platform, as reachable with an API key. One unified detection endpoint (`POST /v1/detections`) answers for photos, videos and audio in one envelope; everything else here is account, metering and result plumbing around it. To start: sign up at https://app.scam.ai, create a key at https://app.scam.ai/api-keys (or with `POST /users/api-key/generate` once you hold one), buy credits in the dashboard, and send the key as `x-api-key`. The human-readable walkthrough of the same surface is at https://scam.ai/docs.\n"},"servers":[{"url":"https://api.scam.ai","description":"Production."}],"security":[{"ApiKeyAuth":[]}],"tags":[{"name":"detection","description":"Run a detection and read it back."},{"name":"account","description":"The account behind the key — profile, credits, keys."},{"name":"usage","description":"Liveness and pricing."}],"paths":{"/v1/detections":{"post":{"tags":["detection"],"operationId":"createDetection","summary":"Run a detection on one file or link — the unified endpoint.","description":"Send a photo, video or audio file as multipart `file` (JPG, PNG, WEBP; MP4, MOV, WEBM; MP3, WAV, M4A, FLAC, OGG, AAC), or a link to a public video page or a media file as `url`; the server infers image / video / audio from the content, runs the right detectors, and answers in one envelope. Anything else is refused with 422 `UNSUPPORTED_MEDIA_TYPE`. Synchronous: the answer arrives in the exchange that asked for it, and every call is one run — a retried call is a second run and a second charge, except that the same bytes from the same account within 30 days meter zero (`zero_charge_reason: duplicate`). Requests are limited per key (100/min; `X-RateLimit-*` and `RateLimit-*` headers, `Retry-After` on 429). Credits are the only other gate: a run the balance cannot cover is refused with 402 `INSUFFICIENT_CREDITS` before it starts. A key the credit check has refused 20 times in an hour is answered 429 `REFUSAL_RATE_LIMITED` with `Retry-After: 3600` instead of a 21st 402; once the account has credits again the call runs.\n\nSend exactly one of `file` and `url`; if both arrive, the file is used and the link is ignored. The key must be universal or scoped to `AI_CONTENT_DETECTION` (any other scope answers 403 `API_KEY_SCOPE`). Latency: an image or a short recording answers in a few seconds, a video in up to about a minute, a link adds the fetch — set the client timeout to 300 s or more, and on a timeout read `GET /users/credits/ledger` before re-sending the same media: the run may have completed and been charged.\n","requestBody":{"required":true,"content":{"multipart/form-data":{"schema":{"type":"object","properties":{"file":{"type":"string","format":"binary","description":"The media. Image ≤10 MB, video ≤100 MB, audio ≤50 MB."},"url":{"type":"string","description":"A link to a public video page, or a direct link to an image, video or audio file, as an alternative to `file`; http or https. A video page longer than 5 minutes, a playlist or a live stream is refused with 422 `LINK_NOT_RESOLVABLE`; a fetched file over 100 MB with 413 `LINK_MEDIA_TOO_LARGE`.\n"},"thumbnails":{"type":"string","enum":["true","false"],"description":"Video only. Include a small base64 JPEG of each scored frame (`frames[].thumbnail_b64`), for display. Default \"false\".\n"}}}},"application/json":{"schema":{"type":"object","required":["url"],"properties":{"url":{"type":"string","description":"A link to a public video page, or a direct link to an image, video or audio file; http or https. The same limits as the multipart `url`.\n"},"thumbnails":{"type":"boolean","description":"Video only. Include `frames[].thumbnail_b64`. Default false."}}}}}},"responses":{"200":{"description":"The detection, completed.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Detection"},"examples":{"image":{"summary":"A photo, sent as multipart file","value":{"id":"6f1c2a3e-9b7d-4e5f-8a21-3c4d5e6f7a8b","status":"completed","created_at":"2026-09-15T10:12:33.123Z","media":{"type":"image","filename":"photo.jpg","mime_type":"image/jpeg","bytes":248113},"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}},"video":{"summary":"A clip, sent as multipart file (frames shortened)","value":{"id":"0b7d4a92-5e1c-4f38-9a6d-2c8e1f0b3d47","status":"completed","created_at":"2026-09-15T10:18:02.517Z","media":{"type":"video","filename":"clip.mp4","mime_type":"video/mp4","bytes":4123904},"model":"Eva V1.6","verdict":"ALERT","score":0.56,"summary":"We detected some AI-related signals, but the result is not conclusive.","credits_used":4,"threshold_used":0.5,"frames":[{"index":0,"timestamp_s":0,"score":0.5731},{"index":1,"timestamp_s":1,"score":0.5402},{"index":2,"timestamp_s":2,"score":null,"cause":"timeout"},{"index":3,"timestamp_s":3,"score":0.5688},{"index":4,"timestamp_s":4,"score":0.5519}]}},"audio":{"summary":"A recording, sent as multipart file","value":{"id":"c3a9e1d0-7f24-4b6a-8e15-9d0c2b7a4f61","status":"completed","created_at":"2026-09-15T10:21:40.088Z","media":{"type":"audio","filename":"voicemail.mp3","mime_type":"audio/mpeg","bytes":131072},"model":"Eva V1.6","verdict":"LIKELY_AI","score":0.96,"summary":"This recording shows strong signs of synthetic or cloned voice.","credits_used":1,"duration_ms":8000,"segments":[{"start_ms":0,"end_ms":4000,"value":0.9612,"status":"scored"},{"start_ms":4000,"end_ms":8000,"value":null,"status":"abstained","reason_codes":["no_voice_content"]}]}},"link":{"summary":"A public video page, sent as JSON url (frames shortened)","value":{"id":"e4d2b8f1-3c6a-4d9e-b7f0-1a5c8e2d9b36","status":"completed","created_at":"2026-09-15T10:25:09.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,"score":0.0611},{"index":1,"timestamp_s":1,"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,"truncated":false,"analyzed_duration_s":212}}}}}}},"400":{"$ref":"#/components/responses/BadRequest"},"401":{"$ref":"#/components/responses/Unauthorized"},"402":{"description":"Out of credits (`error.code: INSUFFICIENT_CREDITS`). Enforced before the detector runs; `data` carries the balance and the ceiling that refused.\n","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"},"example":{"success":false,"error":{"code":"INSUFFICIENT_CREDITS","message":"Insufficient credits"},"data":{"allowed":false,"limit":500,"currentUsage":500,"remaining":0,"balance":0,"reason":"INSUFFICIENT_CREDITS","refusalStatus":402}}}}},"403":{"$ref":"#/components/responses/ScopeForbidden"},"413":{"description":"File exceeds the cap for its media type (`FILE_TOO_LARGE`: image 10 MB, video 100 MB, audio 50 MB), or the video behind a link is over 100 MB (`LINK_MEDIA_TOO_LARGE`).\n","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"422":{"description":"Either the file is not something a detector can read (`UNSUPPORTED_MEDIA_TYPE` — not a photo, video or audio file we take; `UNDECODABLE_IMAGE` — empty or undecodable bytes; `LINK_NOT_RESOLVABLE` — the url is a web page that is not media, a private, deleted or login-walled video, a playlist or a live stream, or a video over 5 minutes; answered in the STRING error form with `retryable` and the resolver's `resolverCode`), or the platform looked and could not judge (`NOT_ASSESSABLE`) — an answer, not an outage. Today that is an audio outcome: `no_voice_content` (no speech in any window) or `audio_too_short` (the clip is shorter than the analyzer accepts). Every decodable image and video frame is scored, face or not, so for those a 422 means the bytes did not decode or the format is unsupported. In the `NOT_ASSESSABLE` case `reasons[]` carries the platform's stable lower-case refusal words; render them, do not retry. Never charged.\n","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"},"examples":{"not_assessable":{"summary":"The platform looked and could not judge — object form","value":{"success":false,"error":{"code":"NOT_ASSESSABLE","message":"no detector could score this media"},"reasons":["no_voice_content"],"request_id":"6f1c2a3e-9b7d-4e5f-8a21-3c4d5e6f7a8b"}},"link_not_resolvable":{"summary":"A link that is not media — the STRING error form","value":{"success":false,"error":"LINK_NOT_RESOLVABLE","message":"This link is not supported. Paste a public video link or a direct media file URL.","retryable":false,"resolverCode":"UNSUPPORTED_URL"}}}}}},"429":{"$ref":"#/components/responses/RateLimited"},"500":{"description":"`DETECTORS_UNAVAILABLE` — no detector could take the run — or `INTERNAL_ERROR`. Not charged. Retry with backoff, then contact support with the `request_id`.\n","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"501":{"description":"`LINK_RESOLUTION_UNAVAILABLE`: this deployment cannot fetch links. Upload the file instead.\n","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"502":{"description":"A verdict with nothing billable (`DETECTION_NOT_BILLABLE`), not charged; resubmit only if the answer is still needed. Or `LINK_RESOLUTION_FAILED`: the resolver could not fetch the media behind the link; retry once, then upload the file.\n","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"503":{"description":"`BILLING_UNAVAILABLE`: billing could not be reached — the balance could not be checked (nothing ran), or the run finished but its charge could not be recorded (the result was withheld). Nothing was charged and nothing was stored; retry with backoff.\n","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"504":{"description":"`DETECTION_PLATFORM_TIMEOUT` — the platform did not answer in time; the run may still have completed and been charged, so read the credit ledger before re-sending — or `DETECTION_PLATFORM_UNREACHABLE` / `LINK_RESOLUTION_TIMEOUT`, both retryable with backoff.\n","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"default":{"description":"Any other status is an error in the same shape.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}}}}},"/v1/detections/{id}":{"get":{"tags":["detection"],"operationId":"getDetection","summary":"Retrieve a detection after the fact.","description":"The exact body the POST answered, owner-only. 404 for an id this account never ran.\n","parameters":[{"name":"id","in":"path","required":true,"description":"The `id` from the create response.","schema":{"type":"string"},"example":"6f1c2a3e-9b7d-4e5f-8a21-3c4d5e6f7a8b"}],"responses":{"200":{"description":"The detection, as originally answered.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Detection"}}}},"401":{"$ref":"#/components/responses/Unauthorized"},"404":{"description":"Not stored for this account, or predates retrievable storage.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}}}}},"/v1/detections/{id}/feedback":{"post":{"tags":["detection"],"operationId":"answerDetectionFeedback","summary":"Say whether a verdict was right.","description":"Records the answer to \"was this verdict right?\" for a detection the account ran. One answer per detection: the first answers 201, and a later POST replaces it (the comment too, so an answer sent without a comment clears the old one) and answers 200. The answer is stored beside the run and the score it was given, which is how a wrong verdict reaches the people who can fix it. Free; it does not count toward the per-minute run limit.\n","parameters":[{"name":"id","in":"path","required":true,"description":"The `id` from the create response.","schema":{"type":"string"},"example":"6f1c2a3e-9b7d-4e5f-8a21-3c4d5e6f7a8b"}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/DetectionFeedbackRequest"}}}},"responses":{"200":{"description":"A later answer; it replaced the earlier one.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/DetectionFeedbackResponse"}}}},"201":{"description":"The first answer for this detection, recorded.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/DetectionFeedbackResponse"}}}},"400":{"description":"`INVALID_ANSWER` (not one of `yes`, `no`, `unsure`), `COMMENT_TOO_LONG` (over 500 characters once trimmed) or `INVALID_COMMENT` (not text).\n","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"401":{"$ref":"#/components/responses/Unauthorized"},"404":{"description":"`NOT_FOUND`: no stored, successful detection with that id for this account (an id that is someone else's answers the same).\n","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}}}},"get":{"tags":["detection"],"operationId":"getDetectionFeedback","summary":"Read back the answer given for a detection.","parameters":[{"name":"id","in":"path","required":true,"description":"The `id` from the create response.","schema":{"type":"string"},"example":"6f1c2a3e-9b7d-4e5f-8a21-3c4d5e6f7a8b"}],"responses":{"200":{"description":"The stored answer.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/DetectionFeedbackResponse"}}}},"401":{"$ref":"#/components/responses/Unauthorized"},"404":{"description":"`NOT_FOUND`: no stored, successful detection with that id for this account; or `NO_FEEDBACK`: the detection is the account's and has not been answered yet.\n","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}}}}},"/users/profile":{"get":{"tags":["account"],"operationId":"getProfile","summary":"The authenticated account.","responses":{"200":{"description":"Profile and credit balance.","content":{"application/json":{"schema":{"type":"object","required":["success","user"],"properties":{"success":{"type":"boolean"},"user":{"$ref":"#/components/schemas/Profile"},"credits":{"type":"number"}}}}}},"401":{"$ref":"#/components/responses/Unauthorized"}}}},"/users/credits":{"get":{"tags":["account"],"operationId":"getCreditBalance","summary":"Prepaid credit balance.","description":"The credits left on the account, the number `credits_used` is debited from. Free to call.","responses":{"200":{"description":"The balance.","content":{"application/json":{"schema":{"type":"object","required":["success","userId","balance"],"properties":{"success":{"type":"boolean"},"userId":{"type":"string"},"balance":{"type":"number"}}}}}},"401":{"$ref":"#/components/responses/Unauthorized"}}}},"/users/credits/ledger":{"get":{"tags":["account"],"operationId":"getCreditLedger","summary":"Paginated credit ledger, newest first.","description":"Every credit movement, newest first — each detection's debit, purchases, grants. After a timeout on `POST /v1/detections`, read this before re-sending the same media: a `DETECTION_DEBIT` row for it means the run completed and was charged.\n","parameters":[{"name":"limit","in":"query","schema":{"type":"integer","default":20,"maximum":100}},{"name":"offset","in":"query","schema":{"type":"integer","default":0}}],"responses":{"200":{"description":"Ledger page.","content":{"application/json":{"schema":{"type":"object","required":["success","userId","entries","pagination"],"properties":{"success":{"type":"boolean"},"userId":{"type":"string"},"entries":{"type":"array","items":{"$ref":"#/components/schemas/LedgerEntry"}},"pagination":{"$ref":"#/components/schemas/Pagination"}}}}}},"401":{"$ref":"#/components/responses/Unauthorized"}}}},"/users/api-key/generate":{"post":{"tags":["account"],"operationId":"generateApiKey","summary":"Create an API key.","description":"A key is added; the keys the account already holds keep working. Up to ten active keys per scope. The plaintext `value` appears only in this response; keep `id` to revoke the key later.\n","requestBody":{"content":{"application/json":{"schema":{"type":"object","properties":{"serviceType":{"type":"string","enum":["UNIVERSAL","AI_CONTENT_DETECTION","FACE_SWAP_DETECTION","QR_URL_DETECTION","AGE_DETECTION","AI_MANIPULATED"],"description":"Omit it, or send \"UNIVERSAL\", for a key that works everywhere — the right choice for an integration that calls `POST /v1/detections`, which also accepts `AI_CONTENT_DETECTION`. Any other scope cannot call it. Keys accumulate within their scope, up to ten active ones; creating a key never retires an existing one.\n"},"name":{"type":"string"}}}}}},"responses":{"200":{"description":"The new key, shown once.","content":{"application/json":{"schema":{"type":"object","required":["success","apiKey"],"properties":{"success":{"type":"boolean"},"message":{"type":"string"},"warning":{"type":"string","description":"A reminder that `apiKey.value` is shown only once."},"user":{"type":"object","properties":{"id":{"type":"string"},"email":{"type":"string"}}},"apiKey":{"type":"object","required":["id","value"],"properties":{"id":{"type":"string"},"serviceType":{"type":"string","nullable":true},"isUniversal":{"type":"boolean"},"name":{"type":"string"},"value":{"type":"string","description":"The plaintext key (sk_ + 64 hex). Never shown again."},"created":{"type":"string"},"lastUpdated":{"type":"string"},"note":{"type":"string"}}}}}}}},"401":{"$ref":"#/components/responses/Unauthorized"},"403":{"description":"A service-scoped key may not create keys outside its own scope.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}}}}},"/users/api-key/revoke/{apiKeyId}":{"delete":{"tags":["account"],"operationId":"revokeApiKeyById","summary":"Revoke one API key by id.","description":"The `id` from the create response or from the dashboard. A revoked key fails validation on its next request.","parameters":[{"name":"apiKeyId","in":"path","required":true,"schema":{"type":"string"}}],"responses":{"200":{"$ref":"#/components/responses/KeyRevoked"},"401":{"$ref":"#/components/responses/Unauthorized"},"404":{"description":"No key with that id on this account.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}}}}},"/health":{"get":{"tags":["usage"],"operationId":"getHealth","summary":"Liveness.","description":"Answers 200 whenever the gateway process is up. No key needed; says nothing about the detectors.","security":[],"responses":{"200":{"description":"Alive.","content":{"application/json":{"schema":{"type":"object","required":["status"],"properties":{"status":{"type":"string"}}}}}}}}}},"components":{"securitySchemes":{"ApiKeyAuth":{"type":"apiKey","in":"header","name":"x-api-key","description":"The only API-key transport. `Authorization: Bearer sk_...` is NOT accepted — a bearer token is parsed as a dashboard JWT. An unknown key answers 401; a service-scoped key answers 403 API_KEY_SCOPE outside its service's mounts; universal keys pass every scope. Keys are created at https://app.scam.ai/api-keys (sign up first) or with `POST /users/api-key/generate`; the plaintext is shown once. `POST /v1/detections` takes a universal key or one scoped to `AI_CONTENT_DETECTION`.\n"}},"responses":{"BadRequest":{"description":"Malformed request.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"Unauthorized":{"description":"No API key was sent, or the key is not recognised.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"ScopeForbidden":{"description":"The key may not call this route (`error: API_KEY_SCOPE` — for `/v1/detections` the key must be universal or scoped to `AI_CONTENT_DETECTION`), the account is inactive, or the email is unverified (`FORBIDDEN`, message starting `EMAIL_VERIFICATION_REQUIRED`).\n","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"RateLimited":{"description":"Rate limited. The per-minute limiter answers `{error, message}` with no `success` key (or plain text) and a `Retry-After` header — honour it, else back off. A second, per-key limiter answers the standard `ErrorResponse` with `error.code: REFUSAL_RATE_LIMITED` and `Retry-After: 3600`: the key was refused for credits 20 times in the last hour, and a call the credit check refuses is answered 429 instead of 402 until the oldest of those refusals is an hour old. Adding credits lifts it at once (a call the account can pay for runs); the 429s themselves are not counted, so they do not extend the wait.\n","headers":{"Retry-After":{"description":"Seconds to wait before calling again.","schema":{"type":"integer"}}},"content":{"application/json":{"schema":{"anyOf":[{"$ref":"#/components/schemas/ErrorResponse"},{"$ref":"#/components/schemas/PlainError"}]},"examples":{"refusal_rate_limited":{"summary":"A key refused 20 times in the last hour","value":{"success":false,"error":{"code":"REFUSAL_RATE_LIMITED","message":"This key has been refused 20 times in the last hour. Add credits or wait an hour."}}}}},"text/plain":{"schema":{"type":"string"}}}},"KeyRevoked":{"description":"Revoked. The key stops validating on the next request.","content":{"application/json":{"schema":{"type":"object","required":["success"],"properties":{"success":{"type":"boolean"},"message":{"type":"string"},"user":{"type":"object","properties":{"id":{"type":"string"},"email":{"type":"string"}}},"revokedKey":{"type":"object","properties":{"id":{"type":"string"},"serviceType":{"type":"string","nullable":true},"isUniversal":{"type":"boolean"},"name":{"type":"string","nullable":true}}}}}}}}},"schemas":{"ErrorResponse":{"description":"The gateway's error shapes, honestly: route and platform refusals on /v1/detections answer `{success:false, error:{code,message}}`; link refusals (`INVALID_URL`, `LINK_*`, with `retryable` and the resolver's `resolverCode`) and the key-scope refusal (`API_KEY_SCOPE`) answer `{success:false, error:\"<CODE>\", message}`. `error` is therefore a union — branch on `typeof error === \"string\" ? error : error.code`. The SDKs normalise both into one exception type. Every code is UPPER_SNAKE_CASE (`UNSUPPORTED_MEDIA_TYPE`, `NOT_ASSESSABLE`, `LINK_NOT_RESOLVABLE`, `INSUFFICIENT_CREDITS`, `REFUSAL_RATE_LIMITED`, …); `reasons[]` values are the detectors' own lower-case words, not codes. `resolverCode` on a link refusal is one of UNSUPPORTED_URL, NOT_A_VIDEO, VIDEO_PRIVATE, VIDEO_DELETED, LOGIN_REQUIRED, AGE_RESTRICTED, GEO_BLOCKED, PLATFORM_BLOCKED, TOO_LONG, MEDIA_TOO_LARGE, FETCH_TIMEOUT, FETCH_FAILED, RATE_LIMITED, AT_CAPACITY, EXTRACTOR_OUTDATED. Every response — success or error — also carries an `x-request-id` header; quote it to support when a body has no `request_id`.\n","type":"object","required":["success"],"properties":{"success":{"type":"boolean","enum":[false]},"error":{"oneOf":[{"type":"string"},{"type":"object","properties":{"code":{"type":"string"},"message":{"type":"string"}}}]},"message":{"type":"string"},"data":{"type":"object","description":"Refusal detail on a 402 — the credit check that refused. Absent on every other error.\n","properties":{"allowed":{"type":"boolean","enum":[false]},"balance":{"type":"number","description":"Prepaid credits on the account."},"reason":{"type":"string","enum":["INSUFFICIENT_CREDITS"]},"refusalStatus":{"type":"integer","enum":[402]}}},"reasons":{"type":"array","items":{"type":"string"},"description":"Platform refusal codes on 422 — an answer, not an outage."},"retryable":{"type":"boolean","description":"On a link refusal — whether the same link may succeed later."},"resolverCode":{"type":"string","description":"On a link refusal — the resolver's own reason (values listed above)."},"request_id":{"type":"string","description":"Also sent as the `x-request-id` response header on every response."},"timestamp":{"type":"string"}}},"PlainError":{"description":"The `{error, message}` shape with NO `success` field — what the per-minute rate limiter and the legacy task routes answer. Kept as-is rather than papered over: the spec records what the wire carries.\n","type":"object","required":["error"],"properties":{"error":{"type":"string"},"message":{"type":"string"}}},"Detection":{"description":"The one envelope for every media type. Every field says something true about THIS run; nothing is derivable from another field. The base fields are shared; video and audio each add their own, marked below.\n","type":"object","required":["id","status","created_at","media","model","verdict","score","summary","credits_used"],"properties":{"id":{"type":"string"},"status":{"type":"string","enum":["completed"]},"created_at":{"type":"string","description":"RFC 3339, millisecond precision, `Z`."},"media":{"type":"object","required":["type","filename","mime_type","bytes"],"properties":{"type":{"type":"string","enum":["image","video","audio"]},"filename":{"type":"string","nullable":true},"mime_type":{"type":"string","nullable":true},"bytes":{"type":"number","nullable":true}}},"model":{"type":"string","description":"The label of the published Eva version that ran this detection: \"Eva V1.5\" (the default) or \"Eva V1.6\". Which version answers is set per account; the endpoint, envelope and price are the same for both."},"verdict":{"type":"string","enum":["LIKELY_REAL","ALERT","LIKELY_AI"],"description":"Branch on this. A routing decision — let it through (LIKELY_REAL), put a human on it (ALERT), or block it (LIKELY_AI)."},"score":{"type":"number","nullable":true,"minimum":0,"maximum":1,"description":"Likelihood the media is manipulated, 0–1 with two decimals (per-frame and per-window values carry four). Sort on this. Null when nothing could score."},"summary":{"type":"string","description":"One sentence a person can read."},"credits_used":{"type":"number","description":"An integer — what the ledger actually debited for this run. 1 per image; 1 per scored video frame, at most 20; 1 per audio minute, rounded up, minimum 1. 0 on a sandbox key, or when the same bytes were sent from this account within 30 days. Prefer this over any catalog quote."},"threshold_used":{"type":"number","description":"Video only — the line the per-frame scores were judged against. Absent when there is nothing to draw."},"frames":{"type":"array","description":"Video only — the per-frame series, the whole clip. About one frame a second up to 20 s, 20 spread over a longer clip. Absent when no series was produced.","items":{"type":"object","required":["index","timestamp_s","score"],"properties":{"index":{"type":"number"},"timestamp_s":{"type":"number"},"score":{"type":"number","nullable":true,"description":"Likelihood this frame is manipulated, four decimals; null means not scored (and not charged), never 0."},"cause":{"type":"string","description":"Why the frame was not scored. Only beside a null score."},"thumbnail_b64":{"type":"string","description":"Base64 JPEG of the frame, for display only. Only when the request asked with `thumbnails`."}}}},"duration_ms":{"type":"number","description":"Audio only — clip length in milliseconds. Audio is billed per minute, rounded up, minimum 1."},"segments":{"type":"array","description":"Audio only — one entry per window. An abstained window also carries `reason_codes` (e.g. `no_voice_content`). Audio answers LIKELY_REAL or LIKELY_AI only.","items":{"type":"object","required":["start_ms","end_ms"],"properties":{"start_ms":{"type":"number"},"end_ms":{"type":"number"},"value":{"type":"number","nullable":true,"description":"P(synthetic) for the window; null when the window was not scored."},"status":{"type":"string","description":"`scored` when the window has a value, `abstained` when it does not."},"reason_codes":{"type":"array","items":{"type":"string"},"description":"Only on an abstained window — why it was not scored, e.g. `no_voice_content`."}}}},"zero_charge_reason":{"type":"string","enum":["sandbox","duplicate"],"description":"Present only when `credits_used` is 0, and says why."},"source":{"type":"object","description":"Present only when the media came from a link. The only place the answer says how much of the video the scored moments cover.\n","required":["url","truncated"],"properties":{"url":{"type":"string","description":"What the caller sent, verbatim."},"canonical_url":{"type":"string","description":"What the platform calls the same media."},"platform":{"type":"string"},"title":{"type":"string"},"uploader":{"type":"string"},"duration_s":{"type":"number","description":"The whole video's length in seconds."},"truncated":{"type":"boolean","description":"True when a prefix was scored rather than the whole video."},"analyzed_duration_s":{"type":"number","description":"The length of what was scored. Equals `duration_s` unless truncated."}}}}},"DetectionFeedbackRequest":{"type":"object","required":["answer"],"properties":{"answer":{"type":"string","enum":["yes","no","unsure"],"description":"Whether the verdict was right. Lower case, exactly one of the three."},"comment":{"type":"string","nullable":true,"maxLength":500,"description":"Optional, at most 500 characters once trimmed; stored null when empty. A later answer without one clears the earlier comment.\n"}}},"DetectionFeedback":{"type":"object","required":["id","answer","comment","created_at","updated_at"],"properties":{"id":{"type":"string"},"answer":{"type":"string","enum":["yes","no","unsure"]},"comment":{"type":"string","nullable":true},"created_at":{"type":"string","format":"date-time","description":"When the first answer was recorded."},"updated_at":{"type":"string","format":"date-time","description":"When the answer last changed. Equals `created_at` until it does."}}},"DetectionFeedbackResponse":{"type":"object","required":["success","data"],"properties":{"success":{"type":"boolean","enum":[true]},"data":{"$ref":"#/components/schemas/DetectionFeedback"}}},"Profile":{"type":"object","required":["id","email"],"properties":{"id":{"type":"string"},"email":{"type":"string"},"name":{"type":"string","nullable":true},"tier":{"type":"string","nullable":true,"deprecated":true,"description":"Always null. The pricing tier it named no longer exists; the field stays so a client reading it does not break, and goes in a later major version.\n"},"emailVerified":{"type":"boolean"},"createdAt":{"type":"string"}}},"LedgerEntry":{"type":"object","required":["id","delta","reason","created_at"],"properties":{"id":{"type":"string"},"delta":{"type":"number"},"reason":{"type":"string","description":"DETECTION_DEBIT · PURCHASE_CREDIT · ADMIN_ADJUSTMENT · MARKETPLACE_LICENSE · SIGNUP_GRANT (the credits a new account starts with), plus the historic TRIAL_EXTENSION / TRIAL_EXTENSION_EXPIRY / WELCOME_GRANT / MONTHLY_GRANT rows.\n"},"ref":{"type":"string","nullable":true},"created_at":{"type":"string"}}},"Pagination":{"type":"object","required":["limit","offset","total"],"properties":{"limit":{"type":"integer"},"offset":{"type":"integer"},"total":{"type":"integer"},"has_more":{"type":"boolean"}}}}}}