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

        Send 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.
      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`.
                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".
          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`.
                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.
          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`).
          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.
          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`.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '501':
          description: |
            `LINK_RESOLUTION_UNAVAILABLE`: this deployment cannot fetch links. Upload the file instead.
          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.
          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.
          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.
          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.
      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.
      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.
      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.
                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`.
  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`).
      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.
      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`.
      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.
          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.
      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.
      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.
          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.
        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.
        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
