{"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.\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"}}}}}}},"/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.\n","content":{"application/json":{"schema":{"anyOf":[{"$ref":"#/components/schemas/ErrorResponse"},{"$ref":"#/components/schemas/PlainError"}]}},"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`, …); `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."}}}}},"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"}}}}}}