> ## Documentation Index
> Fetch the complete documentation index at: https://docs.get-rial.com/llms.txt
> Use this file to discover all available pages before exploring further.

# Submit a capture against the verification

> Stage 3 of the upload flow (or stage 1 for inline-base64 payloads ≤ 6 MB). Two body shapes, discriminated by which field is present:

- **inline:** `{image_base64, surface?, client_captured_at?}` — the bytes ride in the request, capped at Lambda's 6 MB sync-invoke limit.
- **key_ref:** `{image_key, image_sha256, surface?, client_captured_at?}` — `image_key` is the URL returned by `/captures/upload-url`; the handler reads bytes from S3 and verifies the hex `image_sha256` matches before persisting.

Both shapes also accept an optional `step_key` naming which step of the verification's `steps` this capture fills. It may reference an image step (any state, capacity permitting) or an upload step with remaining capacity — upload steps hold pre-existing content and are reachable by explicit key only, there is no keyless fallback into them. Omitting `step_key` lands the capture in the first image step with remaining capacity (legacy keyless clients: the pre-steps hosted SPA, the SDK worker). A key that doesn't resolve to an evidence step with capacity — unknown, or naming a text step — is `400 unknown_step`; a step already at its `max` is `409 step_full`.

**Side effects:**
- Persists a `Capture` row atomically with capture-count + status transition guards.
- Emits `verification.capture.persisted` on the EventBridge bus. The per-capture analysis worker consumes it (today: `ANALYSIS=stub`; future: `crosscheck`).
- If this is the capture that fills the verification's `max_captures` quota, the analysis worker may also write a final verdict and transition status to `completed`.

**Attestation:** if `cf_turnstile_token` is present and the verifier is configured, the token is validated server-side and persisted as an `attestation` field on the capture row. A failed verification is recorded as `verdict: 'error'` but the capture is still persisted (attestation is signal, not gate).

**Returns:** the updated verification snapshot (post-write state).



## OpenAPI

````yaml /api-reference/openapi.json post /v1/verifications/{token}/captures
openapi: 3.1.0
info:
  title: rial-platform — Verifications API
  version: 0.1.0
  description: |-
    # rial-platform — Verifications API

    HTTP control plane for the rial-platform verifications-service:
    creating verifications, capturing media against them, finalising
    cases for batch analysis, and the operator-facing dashboard.

    ## Audience

    - **Tenants** integrating rial into a claims / verification flow —
      use the operator-issued API key and call `POST /v1/verifications`,
      then send the returned `capture_url` to your end-user.
    - **The dashboard SPA** (`app.get-rial.com`) — same surface,
      authenticated via the `rial_session` cookie.
    - **The WhatsApp bot (rialclaw)** — signs requests with HMAC
      (`X-Bot-Auth`), carries the target tenant in the body.
    - **End-user browsers** capturing photos against a fresh
      verification — authenticated by possession of the path `:token`
      alone (the token IS the credential, see ADR-0002).

    ## Auth modes

    - `cookieSession` — `rial_session` cookie set by Google OAuth at
      `/v1/auth/google/callback`. Dashboard browsers.
    - `bearerApiKey` — `Authorization: Bearer pk_live_<…>` API key,
      tenant-scoped. Server-to-server integrations.
    - `captureToken` — possession of the verification id (`vfy_…`)
      in the URL path authenticates the SPA capture flow. Same trust
      model as Stripe payment intent client_secret.
    - `botHmac` — `X-Bot-Auth: <hmac>` from the WhatsApp bot.

    ## State machine

    A verification's status: `pending` → `partially_captured` →
    `completed` | `expired` | `failed`. State transitions emit
    domain events on the internal EventBridge bus; webhook events
    are translated from those and delivered to the tenant's
    configured `webhook_url` (HMAC-signed via the per-tenant
    `webhook-signing` secret). `pending`/`partially_captured` can
    also side-branch to `abandoned` — reported by the capture screen
    via `POST /v1/verifications/:token/progress` when the end user
    leaves before finishing. It is not terminal: a later capture
    resurrects the row to `partially_captured` like any other, and no
    webhook event fires for the transition.

    ## Conventions

    - Wire format is **snake_case JSON** in both directions; the
      service maps to camelCase internally.
    - Errors are `{error: {code, message, fields?}}` — `code` is
      the stable string SDKs branch on; `message` is human-readable
      English; `fields` is present only on `invalid_request`
      validation failures.
    - IDs are ULIDs with type prefixes: `vfy_`, `cap_`, `tnt_`,
      `walotp_`, `mlk_`.
    - Pagination is opaque-cursor: client passes back whatever
      `next_cursor` the previous page returned; absent means done.

    ## Out of scope here

    - Webhook payloads delivered to tenant URLs — those are
      documented separately under `docs/webhook-events.md`. This
      spec covers only the inbound HTTP surface, not the outbound
      HTTP rial → tenant calls.
    - Internal EventBridge events on the `rial-platform-events` bus
      (`verification.created`, `verification.case.completed`, …) —
      consumer-internal, not part of the public contract.
  contact:
    name: rial-platform team
    url: https://github.com/Rial-ventures-Inc/rial-platform
  license:
    name: UNLICENSED — proprietary, internal use only
servers:
  - url: https://platform-staging.get-rial.com
    description: Staging (safe to hit; ANALYSIS=stub, CASE_ANALYSIS=gemini-case)
  - url: https://platform.get-rial.com
    description: Production (live tenant traffic; do not test against)
security: []
tags:
  - name: Verifications
    description: >-
      Create, fetch, list, and finalize verifications. The core of the API —
      every tenant integration starts here.
  - name: Captures
    description: >-
      Upload media against an existing verification. Two-stage flow: mint a
      presigned S3 URL → PUT bytes direct to S3 → POST submit-capture with the
      resulting key. Hardware attestation (Cloudflare Turnstile / Apple PAT) is
      verified per-capture.
  - name: Case Analysis
    description: >-
      Batch multi-image case analysis (GET-59). Finalize a verification with a
      claimant narrative; the worker runs Gemini multi-image cross-reasoning and
      emits a structured CaseAnalysisReport. Staging-only today per ADR-002
      sibling deferral.
  - name: Public Trial
    description: >-
      Unauthenticated trial endpoint behind `get-rial.com/trial`. Each call
      mints a fresh verification under the rial. tenant tagged
      `originChannel="wa"`, so the WA-completion worker delivers the verdict to
      the configured trial number.
  - name: Templates
    description: >-
      Publish, update, revoke and inspect link templates — the reusable capture
      links behind `/l/{org}/{slug}`. Callable with a secret key; each publish
      snapshots a versioned capture spec (GET-83).
paths:
  /v1/verifications/{token}/captures:
    post:
      tags:
        - Captures
      summary: Submit a capture against the verification
      description: >-
        Stage 3 of the upload flow (or stage 1 for inline-base64 payloads ≤ 6
        MB). Two body shapes, discriminated by which field is present:


        - **inline:** `{image_base64, surface?, client_captured_at?}` — the
        bytes ride in the request, capped at Lambda's 6 MB sync-invoke limit.

        - **key_ref:** `{image_key, image_sha256, surface?,
        client_captured_at?}` — `image_key` is the URL returned by
        `/captures/upload-url`; the handler reads bytes from S3 and verifies the
        hex `image_sha256` matches before persisting.


        Both shapes also accept an optional `step_key` naming which step of the
        verification's `steps` this capture fills. It may reference an image
        step (any state, capacity permitting) or an upload step with remaining
        capacity — upload steps hold pre-existing content and are reachable by
        explicit key only, there is no keyless fallback into them. Omitting
        `step_key` lands the capture in the first image step with remaining
        capacity (legacy keyless clients: the pre-steps hosted SPA, the SDK
        worker). A key that doesn't resolve to an evidence step with capacity —
        unknown, or naming a text step — is `400 unknown_step`; a step already
        at its `max` is `409 step_full`.


        **Side effects:**

        - Persists a `Capture` row atomically with capture-count + status
        transition guards.

        - Emits `verification.capture.persisted` on the EventBridge bus. The
        per-capture analysis worker consumes it (today: `ANALYSIS=stub`; future:
        `crosscheck`).

        - If this is the capture that fills the verification's `max_captures`
        quota, the analysis worker may also write a final verdict and transition
        status to `completed`.


        **Attestation:** if `cf_turnstile_token` is present and the verifier is
        configured, the token is validated server-side and persisted as an
        `attestation` field on the capture row. A failed verification is
        recorded as `verdict: 'error'` but the capture is still persisted
        (attestation is signal, not gate).


        **Returns:** the updated verification snapshot (post-write state).
      parameters:
        - schema:
            type: string
            pattern: ^vfy_[0-9A-HJKMNP-TV-Z]{26}$
            example: vfy_01HXYZABCDEFGHJKMNPQRSTVWX
          required: true
          name: token
          in: path
      requestBody:
        required: true
        content:
          application/json:
            schema:
              anyOf:
                - type: object
                  properties:
                    image_base64:
                      type: string
                    surface:
                      type: string
                    cf_turnstile_token:
                      type: string
                    step_key:
                      type: string
                  required:
                    - image_base64
                - type: object
                  properties:
                    image_key:
                      type: string
                    image_sha256:
                      type: string
                    surface:
                      type: string
                    cf_turnstile_token:
                      type: string
                    step_key:
                      type: string
                  required:
                    - image_key
                    - image_sha256
            example:
              image_key: >-
                s3://rial-platform-evidence-staging-977916686093/vfy_01HXYZ/cap_01ABC
              image_sha256: aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa
              surface: hosted_link
              cf_turnstile_token: 0.<turnstile-response-token>
              step_key: id_document
      responses:
        '201':
          description: >-
            Capture persisted. Returns the updated verification — status will be
            `partially_captured` until the worker writes the verdict.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Verification'
        '400':
          description: >-
            Invalid body, sha256 mismatch, or an unknown step key
            (`unknown_step` — `step_key` references a nonexistent or
            non-evidence step).
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
              example:
                error:
                  code: invalid_request
                  message: Request body failed validation
        '404':
          description: Unknown verification id.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
              example:
                error:
                  code: not_found
                  message: Resource not found
        '409':
          description: >-
            The target step already holds its maximum captures (`step_full`).
            With `max_captures` sugar this is the old "quota exhausted" case;
            with explicit `steps` it is per slot.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
              example:
                error:
                  code: step_full
                  message: >-
                    Step "capture_1" has already received its maximum number of
                    captures
        '410':
          description: Verification expired (`expires_at` < now).
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
              example:
                error:
                  code: expired
                  message: Verification has expired
      security:
        - captureToken: []
components:
  schemas:
    Verification:
      type: object
      properties:
        id:
          type: string
          pattern: ^vfy_[0-9A-HJKMNP-TV-Z]{26}$
        status:
          type: string
          enum:
            - pending
            - partially_captured
            - completed
            - expired
            - failed
            - abandoned
          description: >-
            Lifecycle state. `pending` → first capture flips to
            `partially_captured` → analysis sets the verdict and transitions to
            `completed`. Terminal: `completed`, `expired`, `failed`. `abandoned`
            is a side-branch off `pending`/`partially_captured`, reported by the
            capture screen via `POST /v1/verifications/:token/progress` when the
            end user leaves before finishing — NOT terminal: a later capture
            resurrects the row to `partially_captured` like any other.
        capture_url:
          type: string
          format: uri
        created_at:
          type: string
          format: date-time
        expires_at:
          type: string
          format: date-time
        captures_count:
          type: integer
          minimum: 0
        config:
          type: object
          properties:
            max_captures:
              type: integer
              minimum: 1
              maximum: 20
              description: >-
                Derived total capacity (sum of steps[].max). `steps` is the
                authoritative spec.
            steps:
              type: array
              items:
                anyOf:
                  - type: object
                    properties:
                      key:
                        type: string
                        minLength: 1
                        maxLength: 64
                      type:
                        type: string
                        enum:
                          - image
                      description:
                        type: string
                        maxLength: 500
                      min:
                        type: integer
                        minimum: 0
                      max:
                        type: integer
                        minimum: 1
                    required:
                      - key
                      - type
                      - description
                      - min
                      - max
                  - type: object
                    properties:
                      key:
                        type: string
                        minLength: 1
                        maxLength: 64
                      type:
                        type: string
                        enum:
                          - text
                      description:
                        type: string
                        maxLength: 500
                      required:
                        type: boolean
                      validation:
                        type: object
                        properties:
                          min_length:
                            type: integer
                            minimum: 0
                          max_length:
                            type: integer
                            minimum: 1
                            maximum: 2000
                          format:
                            type: string
                            enum:
                              - free_text
                              - numeric_id
                              - alphanumeric_id
                        required:
                          - max_length
                          - format
                    required:
                      - key
                      - type
                      - description
                      - required
                      - validation
                  - type: object
                    properties:
                      key:
                        type: string
                        minLength: 1
                        maxLength: 64
                      type:
                        type: string
                        enum:
                          - upload
                      description:
                        type: string
                        maxLength: 500
                      min:
                        type: integer
                        minimum: 0
                      max:
                        type: integer
                        minimum: 1
                      accept:
                        type: array
                        items:
                          type: string
                          enum:
                            - image/jpeg
                            - image/png
                            - image/webp
                            - image/heic
                            - image/heif
                      max_size_bytes:
                        type: integer
                        minimum: 1
                        maximum: 26214400
                    required:
                      - key
                      - type
                      - description
                      - min
                      - max
                      - accept
                      - max_size_bytes
                description: >-
                  One slot of the capture spec — a discriminated union on
                  `type`. IMAGE slots: `key` is the stable slot id echoed back
                  as `step_key` on every capture; `description` is the
                  instruction shown to the person capturing; `min`/`max` bound
                  how many captures the slot takes (`min: 0` = optional slot).
                  TEXT slots are declared form fields answered on the capture
                  page: `required` gates completion, `validation` is declarative
                  and bounded (length bounds + an enumerated format —
                  `numeric_id` digits only, `alphanumeric_id`
                  letters/digits/hyphens; no arbitrary regex). UPLOAD slots are
                  filled with preexisting content the client uploads instead of
                  capturing live: `accept` is the allowed media types (defaults
                  to all supported image types), `max_size_bytes` bounds each
                  upload (defaults to 10 MB, hard-capped at 25 MB).
            signals_required:
              type: array
              items:
                type: string
                enum:
                  - screen
                  - ai
                  - reverse
                  - context
                  - depth
                description: >-
                  A fraud-signal the tenant requires for the verification to
                  count as complete.
            mode:
              type: string
              enum:
                - verification
                - audit
              description: >-
                Echo of the `mode` the verification was created with. `audit`
                marks a run whose evidence is preexisting content pushed via API
                rather than captured live by a device — `steps` cannot stand in
                for it, since an audited file fills an ordinary `image` slot.
                Absent when the mint did not set it.
            context:
              type: object
              properties:
                kind:
                  type: string
                  minLength: 1
                  maxLength: 64
                summary:
                  type: string
                  minLength: 1
                  maxLength: 500
                attributes:
                  type: object
                  additionalProperties:
                    anyOf:
                      - type: string
                      - type: number
                      - type: boolean
              required:
                - kind
                - summary
              description: >-
                Free-form context the tenant attaches at issue time. The
                analysis pipeline cross-references this against what is observed
                in the capture (the `context_match` signal). Shape is identical
                to the aiornot-proxy `/reports/cross-check` `context` field —
                reuse, not translation.
            webhook_url:
              type: string
              format: uri
            notify_email:
              type: string
              format: email
            metadata:
              type: object
              additionalProperties:
                type: string
            expected_location:
              type: string
              description: >-
                Address the captures are expected to happen at; activates
                presence verification (`location_match`).
            expected_object:
              type: string
              description: >-
                Free-text description of the object the photo must show;
                activates the object check (`object_match`).
            condition_aspects:
              type: array
              items:
                type: string
              description: >-
                Free-form tenant-defined aspect names the condition check scores
                — any language, any domain, no fixed vocabulary (1..6 items,
                each non-empty and ≤40 chars); activates `condition`.
          required:
            - max_captures
            - steps
            - signals_required
          description: >-
            Tenant-supplied configuration baked into the token at issue time.
            Immutable after creation — changing config means issuing a new
            verification. `steps` is the normalized capture spec: requests that
            sent `max_captures: N` read back as a single anonymous step `{ key:
            "capture_1", min: N, max: N }`.
        answers:
          type: object
          additionalProperties:
            type: object
            properties:
              value:
                type: string
                minLength: 1
                maxLength: 2000
              answered_at:
                type: string
                format: date-time
            required:
              - value
              - answered_at
            description: >-
              One answered text step. `value` is USER-DECLARED — typed by the
              person capturing, never sensor-attested like the photos. Potential
              PII: it rides the operator/developer contracts and the webhook
              only, never OG cards or third-party public surfaces.
        record_seal:
          type: object
          properties:
            algorithm:
              type: string
              enum:
                - sha256
            hash:
              type: string
              minLength: 64
              maxLength: 64
            covers:
              type: array
              items:
                type: string
                enum:
                  - record
                  - answers
            answers_provenance:
              type: string
              enum:
                - user_declared
          required:
            - algorithm
            - hash
            - covers
            - answers_provenance
          description: >-
            Integrity seal over the verification record including `answers`.
            Present only when answers exist and sealing is on (`seal !==
            false`). The hash proves the stored answers have not changed — it
            does NOT claim they are true or sensor-attested;
            `answers_provenance` carries that distinction explicitly.
        verdict:
          type: object
          properties:
            label:
              type: string
              enum:
                - verified
                - suspicious
                - failed
            score:
              type: number
              minimum: 0
              maximum: 1
            signals:
              type: object
              properties:
                screen_detection:
                  anyOf:
                    - type: object
                      properties:
                        screen_detected:
                          type: boolean
                        confidence:
                          type: number
                          minimum: 0
                          maximum: 1
                      required:
                        - screen_detected
                        - confidence
                    - type: object
                      properties:
                        status:
                          type: string
                          enum:
                            - not_applicable
                        reason:
                          type: string
                          enum:
                            - uploaded_content
                      required:
                        - status
                        - reason
                ai_detection:
                  anyOf:
                    - type: object
                      properties:
                        label:
                          type: string
                          enum:
                            - likely_human
                            - likely_ai
                            - unknown
                        confidence:
                          type: number
                          minimum: 0
                          maximum: 1
                      required:
                        - label
                        - confidence
                    - type: object
                      properties:
                        status:
                          type: string
                          enum:
                            - not_applicable
                        reason:
                          type: string
                          enum:
                            - uploaded_content
                      required:
                        - status
                        - reason
                reverse_search:
                  anyOf:
                    - type: object
                      properties:
                        found_online:
                          type: boolean
                        match_count:
                          type: integer
                          minimum: 0
                      required:
                        - found_online
                        - match_count
                    - type: object
                      properties:
                        status:
                          type: string
                          enum:
                            - not_applicable
                        reason:
                          type: string
                          enum:
                            - uploaded_content
                      required:
                        - status
                        - reason
                depth:
                  anyOf:
                    - type: object
                      properties:
                        source:
                          type: string
                        confidence:
                          type: number
                          minimum: 0
                          maximum: 1
                      required:
                        - source
                        - confidence
                    - type: object
                      properties:
                        status:
                          type: string
                          enum:
                            - not_applicable
                        reason:
                          type: string
                          enum:
                            - uploaded_content
                      required:
                        - status
                        - reason
              description: >-
                Per-signal evidence behind the verdict. Each signal is either
                its evaluated shape, or `{ status: "not_applicable", reason:
                "uploaded_content" }` when the slot was never evaluated because
                the underlying content was uploaded rather than captured live —
                never rendered as a failure.
            reason_code:
              type: string
              enum:
                - screen_detected
                - ai_generated
                - context_mismatch
                - reverse_search_match
                - depth_anomaly
              description: >-
                Which signal drove a non-`verified` label, in priority order
                (screen > AI > context > reverse search > depth) when more than
                one tripped. Absent on `verified` verdicts.
          required:
            - label
            - score
            - signals
          description: >-
            Aggregate verdict produced by the per-capture analysis pipeline.
            `label` is the human-readable bucket; `score` is the confidence in
            the verdict.
        narrative:
          type: string
          maxLength: 2000
        case_analysis:
          type: object
          properties:
            overall_risk_score:
              type: number
              minimum: 0
              maximum: 1
            confidence:
              type: number
              minimum: 0
              maximum: 1
            dimensions:
              type: object
              properties:
                narrative_image_match:
                  type: object
                  properties:
                    score:
                      type: number
                      minimum: 0
                      maximum: 1
                    evidence:
                      type: string
                      minLength: 1
                      maxLength: 1000
                    concerns:
                      type: array
                      items:
                        type: string
                        minLength: 1
                        maxLength: 300
                      maxItems: 12
                  required:
                    - score
                    - evidence
                cross_image_consistency:
                  type: object
                  properties:
                    score:
                      type: number
                      minimum: 0
                      maximum: 1
                    evidence:
                      type: string
                      minLength: 1
                      maxLength: 1000
                    concerns:
                      type: array
                      items:
                        type: string
                        minLength: 1
                        maxLength: 300
                      maxItems: 12
                  required:
                    - score
                    - evidence
                physical_plausibility:
                  type: object
                  properties:
                    score:
                      type: number
                      minimum: 0
                      maximum: 1
                    evidence:
                      type: string
                      minLength: 1
                      maxLength: 1000
                    concerns:
                      type: array
                      items:
                        type: string
                        minLength: 1
                        maxLength: 300
                      maxItems: 12
                  required:
                    - score
                    - evidence
              required:
                - cross_image_consistency
                - physical_plausibility
            review_priority:
              type: string
              enum:
                - low
                - medium
                - high
            uncertainty_areas:
              type: array
              items:
                type: string
                minLength: 1
                maxLength: 300
              maxItems: 12
            model:
              type: string
              minLength: 1
              maxLength: 200
            rubric_version:
              type: integer
              minimum: 1
              maximum: 10
            consistency_level:
              type: number
              minimum: 0
              maximum: 1
            analysis:
              type: array
              items:
                type: object
                properties:
                  capture_id:
                    type: string
                    minLength: 1
                    maxLength: 64
                  ai:
                    type: string
                    enum:
                      - low
                      - medium
                      - high
                  screen:
                    type: boolean
                required:
                  - capture_id
                  - ai
                  - screen
              maxItems: 25
            text_analysis:
              type: string
              minLength: 1
              maxLength: 2000
          required:
            - overall_risk_score
            - confidence
            - dimensions
            - review_priority
            - uncertainty_areas
            - model
          description: >-
            Structured output from the multi-image case-analysis worker (Gemini
            batch reasoning). Per-dimension scores 0..1 where 0 = nothing
            suspicious on that dimension and 1 = that dimension alone is grounds
            to reject. `overall_risk_score` aggregates with cross-dimensional
            reasoning, not a simple average.
        object_match:
          type: object
          properties:
            status:
              type: string
              enum:
                - match
                - mismatch
                - inconclusive
            expected_object:
              type: string
          required:
            - status
            - expected_object
          description: >-
            Object-check result. Present only when the verification was created
            with `expected_object`. Independent of the fraud verdict.
        condition:
          type: object
          properties:
            score:
              type: number
              minimum: 0
              maximum: 10
            label:
              type: string
              enum:
                - good
                - fair
                - poor
            aspects:
              type: array
              items:
                type: object
                properties:
                  name:
                    type: string
                  score:
                    type: number
                    minimum: 0
                    maximum: 10
                  reasoning:
                    type: string
                required:
                  - name
                  - score
                  - reasoning
                description: >-
                  One scored aspect of the condition assessment. `name` echoes
                  the free-form tenant-defined aspect from `condition_aspects`
                  verbatim — any language, any domain, no fixed vocabulary.
                  `score` is 0..10 with one decimal; `reasoning` is a
                  one-or-two-sentence visual justification.
          required:
            - score
            - label
            - aspects
          description: >-
            Condition assessment. Present only when the verification was created
            with `condition_aspects` — the free-form tenant-defined aspect names
            (any language, any domain). `score` is the one-decimal average of
            aspect scores; `label` buckets it (>=7.5 good, >=5 fair, else poor).
            Independent of the fraud verdict.
        progress:
          type: object
          properties:
            schema_version:
              type: number
              enum:
                - 1
            step:
              type: string
              enum:
                - landing
                - ready
                - capturing
                - reviewing
                - uploading
                - camera_blocked
                - success
            step_index:
              type: integer
              minimum: 0
            camera_permission:
              type: string
              enum:
                - granted
                - denied
                - unavailable
                - unknown
            elapsed_ms:
              type: integer
              minimum: 0
            updated_at:
              type: string
              format: date-time
          required:
            - schema_version
            - step
            - elapsed_ms
            - updated_at
          description: >-
            Latest funnel-progress snapshot reported via `POST
            /v1/verifications/:token/progress`. A snapshot, not an event log —
            each call overwrites the previous one. Operator-only; never present
            on the public projection.
      required:
        - id
        - status
        - capture_url
        - created_at
        - expires_at
        - captures_count
        - config
      description: >-
        Canonical wire shape for a verification. `narrative` is present only
        after `POST /v1/verifications/:id/finalize` ran. `verdict` is present
        only after analysis completed. `case_analysis` is the structured Gemini
        multi-image rubric, embedded inline by the sync finalize path when
        analysis succeeded.
    ErrorResponse:
      type: object
      properties:
        error:
          type: object
          properties:
            code:
              type: string
            message:
              type: string
            fields:
              type: array
              items:
                type: object
                properties:
                  path:
                    type: string
                  message:
                    type: string
                required:
                  - path
                  - message
          required:
            - code
            - message
      required:
        - error
      description: >-
        Uniform error envelope. `code` is the stable string SDKs branch on
        (`invalid_request`, `unauthorized`, `not_found`, `expired`, `step_full`,
        `unknown_step`, `steps_incomplete`, `already_finalized`,
        `too_many_requests`, `storage_unavailable`, `internal_error`). `fields`
        is only present on `invalid_request` validation failures.
  securitySchemes:
    captureToken:
      type: apiKey
      in: query
      name: token
      description: >-
        Possession of the verification id in the URL path
        (`/v1/verifications/:token/...`) authenticates the SPA capture flow. No
        header — the id is the credential. ULIDs are large enough that
        existence-by-id is not a meaningful leak. Used for `POST
        /captures/upload-url`, `POST /captures`, `GET /v1/verify/:token`.

````