openapi: 3.1.0
info:
  title: Watchtower seller API
  version: "1"
  description: |
    Watchtower certifies how a video was recorded (decisions 134, 144 and 146): live, in one continuous take, with
    the Watchtower app on a verified phone (App Attest), after the server issued its challenge, at the date and
    time the server stated, with the challenge number visible in the image only where the server read it
    (takes[].challenge = visible; no_ejecutado says it was not run), with no cuts or edits detected, and that the
    original has not changed since (sha256 and C2PA seal). It also states, each as what it is: that the
    original's hash was committed to the server and time-stamped (RFC 3161, qualified only with a QTSP;
    takes[].timestamps: comprometido, verificado or no_recibido; decision 152), and, for the accepted take only,
    that the file received matches that hash (a rejected take may carry a verificado stamp of a file never received
    or not matching);
    the asset identity the seller declared when requesting it (asset, decision 147: "declared"); the account that
    started the take (takes[].recorded_by, decision 150); and that the step markers and declarations follow the
    frozen script (takes[].script_check and script_results, decision 148: "declared by whoever recorded it").
    It does NOT certify: the asset's condition, nor that damage exists or is new; that what was filmed is the
    declared asset, nor that it is the physical object rather than a screen or a replay, nor its authenticity;
    that the take is impartial (one of the parties records it); which person held the phone; nor that every room
    or part was covered. A certification is one video. This API lets a seller (or an agent acting for one) do
    everything the dashboard does, except recording, which happens only in the Watchtower app because the
    proof depends on the device.

    Typical flow for an agent:
      1. POST /products (external_ref optional) -> product id.
      2. POST /products/{product_id}/certifications {requested_by: "comprador", buyer_email: "luis@correo.example",
         record_days: 7, step_template_id: 3 (optional, from GET /step_templates)} -> Watchtower mails the buyer, who installs the app, signs in with that email and records it
         before record_by (buyer.notice says how the mail is going). A seller's (requested_by "vendedor") is
         recorded with a company phone.
      3. Poll GET /certifications/{id} until status is "verified" or "denied" (denial_reason says why;
         takes[].reject_reason says why a take was rejected and must be repeated). A denied one can be retried
         while retryable is true (POST /certifications/{id}/retry): a new certification, up to 3 attempts.
      4. For a seller's verified certification: GET /certifications/{id}/publication (candidates, one
         every 500 ms, with image URLs), then PUT the set to make public (video and/or frames).
      5. PUT /certifications/{id}/public_view {public: true}, or share view.url together with view.password.
      6. GET /certifications/{id}/video and /manifest to download the original and its C2PA manifest, and
         /frames/{frame_id} for a sealed frame (frame_id from get_publication). The page /v/ never offers the video
         or a frame as a file: only its manifest.

    Authentication: `Authorization: Bearer wt_live_...`. A gestor creates and revokes tokens in the
    dashboard (/api_tokens); the secret is shown once. GET /api_tokens lists them, never their secret. Every action made with a token is recorded in the
    product's event chain as actor `api_token:<id>`.

    Isolation: a token only sees its own seller's (tenant's) products and certifications. Anything else is a
    404 not_found, exactly like an id that does not exist.

    Errors: always `{"error": {"code": "...", "message": "..."}}` with the HTTP status. Codes: unauthorized
    (401), forbidden (403), not_found (404), no_video / no_manifest (404), parameter_missing, invalid_parameter,
    invalid_external_ref, invalid_challenge_medium, invalid_certification_request, invalid_label, unknown_candidate,
    unknown_frame, buyer_email_required, invalid_buyer_email, unchanged, invalid_step_template, unknown_vertical, invalid_asset_kind,
    address_required, invalid_identifier, scheme_not_allowed, identifier_unchanged, invalid_retired_reason,
    option_not_allowed_for_sector (422),
    invalid_json (400), not_publishable, external_ref_taken, challenge_medium_locked, asset_kind_locked,
    identifier_taken, identifier_retired, last_address_unit, not_denied, edit_suspected, view_expired, attempts_exhausted,
    not_a_buyer_certification, buyer_notice_resend_limit, buyer_email_changed, step_template_name_taken (409), rate_limited, buyer_notice_quota,
    buyer_notice_cooldown (429). Dossiers: invalid_dossier_kind, invalid_response_days, invalid_party_role,
    invalid_party_email, invalid_display_name, invalid_phase, dossier_product_mismatch, dossier_seller_only,
    dossier_script_inherited (422), dossier_already_open, dossier_external_ref_taken, dossier_not_open,
    dossier_phase_taken, dossier_entry_not_approved, dossier_private, party_exists, party_revoked, party_notice_resend_limit (409),
    party_notice_quota, party_notice_cooldown (429).

    Lists: `?page=1&per_page=50` (max 100) -> `{data: [...], page, per_page, next_page, total, total_pages}`;
    next_page is null on the last page; total counts every row on every page, total_pages is at least 1.

    Rate limit: 120 requests a minute per token, across all endpoints. Headers X-RateLimit-Limit and
    X-RateLimit-Remaining; over it, 429 with Retry-After (seconds). 30 failed authentications (401) a minute
    from one IP and that IP gets 429 rate_limited, even with a valid token, until the minute ends.

    Times are ISO 8601 UTC with microseconds.
servers:
  - url: /api/v1
security:
  - sellerToken: []
tags:
  - name: products
  - name: certifications
  - name: publication
  - name: page
  - name: downloads
  - name: account
  - name: api_tokens
  - name: step_templates
  - name: verticals
  - name: dossiers
paths:
  /products:
    get:
      tags: [products]
      operationId: list_products
      summary: List the seller's products, newest first.
      parameters:
        - $ref: "#/components/parameters/page"
        - $ref: "#/components/parameters/per_page"
        - name: external_ref
          in: query
          description: Exact match on the external reference (order number, stock code).
          schema: { type: string }
        - name: q
          in: query
          description: >-
            As the search box of the dashboard's Productos: products whose external_ref starts with q (any case) or
            whose id is q exactly ("#10042" matches only the product 10042; plain digits also match refs starting with
            them). At least 3 characters (a #id at any length), at most 254, surrounding spaces ignored; empty is no
            search. %, _ and \ are literal. Too short or too long: 422 invalid_parameter. The order stays newest first.
          schema: { type: string }
        - name: last_certification_status
          in: query
          description: >-
            Only the products whose last certification (the newest) has this status, as the dashboard's filter of
            Productos (awaiting_recording is its tile «Por grabar», processing «Verificando»; pending, both, its old
            «Pendiente»). A product with no certification never matches. Any other value: 422 invalid_parameter.
          schema: { $ref: "#/components/schemas/StatusFilter" }
      responses:
        "200":
          description: A page of products.
          content:
            application/json:
              schema:
                allOf:
                  - $ref: "#/components/schemas/Page"
                  - type: object
                    properties:
                      data: { type: array, items: { $ref: "#/components/schemas/Product" } }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "422": { $ref: "#/components/responses/Invalid" }
        "429": { $ref: "#/components/responses/RateLimited" }
    post:
      tags: [products]
      operationId: create_product
      summary: Create a product (an item the seller will certify). Idempotent by external_ref.
      description: |
        With an external_ref the seller already has, returns that product with 200 instead of creating a
        new one (safe to retry), as it is (its challenge_medium included). Without external_ref, each call
        creates a new product. challenge_medium (default papel) is the kind of object: teclado, a phone (the
        app guides through 3 of its zones); papel, any other object. It is fixed once the product has a certification.

        asset_kind (decision 147) is what the product is, for its declared identity: vivienda (a flat) or otro
        (anything else, a phone included); without it, otro, whatever the challenge_medium (147.1, amended
        3-oct-2026). Any other value is 422 invalid_asset_kind. identifiers are added in
        the same call: a vivienda needs an address_unit among them (422 address_required). An identifier the seller
        already has active on another product is 409 identifier_taken and nothing is created: never merged into
        that product (pick it explicitly instead).

        The pair (asset_kind, challenge_medium), defaults included, must be one of the organization's sector
        (decision 180; get_account's allowed_options.objects), else 422 option_not_allowed_for_sector. otro + papel
        (both omitted) is allowed in every sector.
      requestBody:
        content:
          application/json:
            schema:
              type: object
              properties:
                external_ref:
                  type: [string, "null"]
                  pattern: '^[^\s\x00-\x1F\x7F]{1,100}$'
                  description: Optional order number or stock code; 1-100 printable characters, no spaces.
                challenge_medium:
                  $ref: "#/components/schemas/ChallengeMedium"
                asset_kind:
                  $ref: "#/components/schemas/AssetKindInput"
                identifiers:
                  type: array
                  maxItems: 10
                  description: The product's identifiers (decision 147), each as add_product_identifier takes it.
                  items: { $ref: "#/components/schemas/AssetIdentifierInput" }
      responses:
        "201": { description: Created., content: { application/json: { schema: { $ref: "#/components/schemas/Product" } } } }
        "200": { description: Already existed with that external_ref., content: { application/json: { schema: { $ref: "#/components/schemas/Product" } } } }
        "401":
          description: |
            unauthorized. POST /products is shared with the capture app: a request whose Authorization is not a
            wt_live_ token (none, or a device token) is answered by the capture app, as
            {"error": "unauthorized", "message": "..."} (error is a string there, not {code, message}).
          content:
            application/json:
              schema:
                oneOf:
                  - $ref: "#/components/schemas/Error"
                  - type: object
                    required: [error, message]
                    properties: { error: { type: string, const: unauthorized }, message: { type: string } }
        "409": { $ref: "#/components/responses/IdentifierTaken" }
        "422": { $ref: "#/components/responses/Invalid" }
        "429": { $ref: "#/components/responses/RateLimited" }
  /products/{id}:
    parameters:
      - $ref: "#/components/parameters/product_id"
    get:
      tags: [products]
      operationId: get_product
      summary: Show one product.
      responses:
        "200": { description: The product., content: { application/json: { schema: { $ref: "#/components/schemas/Product" } } } }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/NotFound" }
        "429": { $ref: "#/components/responses/RateLimited" }
    patch:
      tags: [products]
      operationId: update_product
      summary: Change or clear (null) the product's external_ref, or change its challenge_medium or asset_kind.
      description: |
        Only the fields sent change (at least one; none: 422 parameter_missing). challenge_medium and asset_kind
        only while the product has no certification: after that, 409 challenge_medium_locked / asset_kind_locked.
        asset_kind (decision 147): the active identifiers of a scheme the new kind does not take are retired in the
        same call with reason error (177: a vivienda that becomes otro retires its address_unit and
        cadastral_ref); to make it a vivienda, send its address_unit in identifiers in the same call (422
        address_required otherwise). identifiers are added as add_product_identifier adds them. A change of
        asset_kind or challenge_medium must leave a pair of the organization's sector (decision 180; get_account's
        allowed_options.objects), else 422 option_not_allowed_for_sector; a product that keeps its pair is never
        refused for it.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              minProperties: 1
              properties:
                external_ref: { type: [string, "null"], pattern: '^[^\s\x00-\x1F\x7F]{1,100}$' }
                challenge_medium: { $ref: "#/components/schemas/ChallengeMedium" }
                asset_kind: { $ref: "#/components/schemas/AssetKindInput" }
                identifiers:
                  type: array
                  maxItems: 10
                  description: Identifiers to add in the same transaction (decision 147).
                  items: { $ref: "#/components/schemas/AssetIdentifierInput" }
      responses:
        "200": { description: The product as it is now., content: { application/json: { schema: { $ref: "#/components/schemas/Product" } } } }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/NotFound" }
        "409": { description: "external_ref_taken: another product of the seller has it. challenge_medium_locked / asset_kind_locked: the product already has a certification. identifier_taken: an identifier sent is already active on a product of the seller.", content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } } }
        "422": { $ref: "#/components/responses/Invalid" }
        "429": { $ref: "#/components/responses/RateLimited" }
  /products/{id}/identifiers:
    parameters:
      - $ref: "#/components/parameters/product_id"
    post:
      tags: [products]
      operationId: add_product_identifier
      summary: Add an identifier to a product (a flat's address, its cadastral reference, a code of yours).
      description: |
        Decision 147. scheme address_unit takes details {street, number, floor?, door?, postal_code, city,
        country? (ES)} and builds value from them; cadastral_ref and custom take value. A vivienda takes
        address_unit, cadastral_ref and custom; otro, custom (422 scheme_not_allowed otherwise). An
        identifier is never edited: replace it (replace_product_identifier) or retire it. Unique among the seller's
        active identifiers once normalized (case, spaces, dashes, dots and slashes do not count; address_unit and
        custom ignore accents, so Trastéro-4 is Trastero-4; an address_unit also ignores ordinal marks): 409 identifier_taken,
        never a merge. The product's event chain records asset_identifier_added with the identifier's id and scheme,
        never its value.
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/AssetIdentifierInput" }
      responses:
        "201": { description: The new identifier., content: { application/json: { schema: { $ref: "#/components/schemas/AssetIdentifier" } } } }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/NotFound" }
        "409": { $ref: "#/components/responses/IdentifierTaken" }
        "422": { $ref: "#/components/responses/Invalid" }
        "429": { $ref: "#/components/responses/RateLimited" }
  /products/{id}/identifiers/{identifier_id}/replace:
    parameters:
      - $ref: "#/components/parameters/product_id"
      - $ref: "#/components/parameters/identifier_id"
    post:
      tags: [products]
      operationId: replace_product_identifier
      summary: Correct an identifier of a product, keeping the old one in its history.
      description: |
        Decision 147. Retires the identifier (retired_reason sustituido) and adds the new one, of the same scheme, in
        one transaction: a vivienda never goes without its address. Send value (cadastral_ref, custom) or details
        (address_unit); label is the new one's (left out: the old one's). The same value it has (once normalized):
        422 identifier_unchanged. Already retired: 409 identifier_retired. Another active identifier of the seller
        already is the new value: 409 identifier_taken, and nothing changes.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              properties:
                value: { type: string, description: "The new value (cadastral_ref, custom)." }
                details: { $ref: "#/components/schemas/AddressDetails" }
                label: { type: [string, "null"], maxLength: 60 }
      responses:
        "201": { description: The new identifier (the old one is in get_product_history)., content: { application/json: { schema: { $ref: "#/components/schemas/AssetIdentifier" } } } }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/NotFound" }
        "409": { description: "identifier_taken: another active identifier of the seller already is the new value. identifier_retired: this one is already retired.", content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } } }
        "422": { $ref: "#/components/responses/Invalid" }
        "429": { $ref: "#/components/responses/RateLimited" }
  /products/{id}/identifiers/{identifier_id}/retire:
    parameters:
      - $ref: "#/components/parameters/product_id"
      - $ref: "#/components/parameters/identifier_id"
    post:
      tags: [products]
      operationId: retire_product_identifier
      summary: Retire an identifier of a product (it was wrong, or no longer identifies it).
      description: |
        Decision 147. reason error (it was never this asset's) or baja (it no longer identifies it). The row stays in
        the history (get_product_history) and its value is free for another product. A vivienda keeps at least one
        active address_unit: retiring the last one is 409 last_address_unit (replace it instead). Already retired:
        409 identifier_retired.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [reason]
              properties:
                reason: { type: string, enum: [error, baja] }
      responses:
        "200": { description: The identifier now retired., content: { application/json: { schema: { $ref: "#/components/schemas/AssetIdentifier" } } } }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/NotFound" }
        "409": { description: "identifier_retired: already retired. last_address_unit: the vivienda's last active address.", content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } } }
        "422": { $ref: "#/components/responses/Invalid" }
        "429": { $ref: "#/components/responses/RateLimited" }
  /products/{id}/history:
    parameters:
      - $ref: "#/components/parameters/product_id"
    get:
      tags: [products]
      operationId: get_product_history
      summary: The product's identity history and its certifications, oldest first.
      description: |
        Decision 147. identifiers: every identifier it had, active and retired (with retired_reason). entries: the
        identity events of its chain (product_created, product_updated, asset_identifier_added,
        asset_identifier_retired, asset_identifier_renormalization_skipped; their payload names identifiers by id,
        never their value) and one
        certification_requested per certification, by time. Not paginated.
      responses:
        "200": { description: The history., content: { application/json: { schema: { $ref: "#/components/schemas/AssetHistory" } } } }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/NotFound" }
        "429": { $ref: "#/components/responses/RateLimited" }
  /products/{product_id}/certifications:
    parameters:
      - name: product_id
        in: path
        required: true
        schema: { type: string }
    post:
      tags: [certifications]
      operationId: request_certification
      summary: Open a certification of the product; for a buyer's, Watchtower mails the buyer.
      description: |
        buyer_email is REQUIRED when requested_by is "comprador" (422 buyer_email_required) and must be left out
        when it is "vendedor". Watchtower mails that address a fixed notice (no text of yours) after the
        certification is created; buyer.notice.status says how it is going (pending, sending, sent, failed), and
        a failed mail never undoes the certification: resend it (POST /certifications/{id}/buyer_notice). The
        buyer installs the app and signs in with that email; nothing to send them. The answer is the same whether
        the email already has a Watchtower account or not. A take can start until record_by, record_days days
        (1-365, default 7) from now; a certification is recorded once, and a rejected take can be repeated until
        then. Mail quotas (per company and per recipient, per day): 429 buyer_notice_quota, nothing created.
        step_template_id (optional, either role): one of your step templates (GET /step_templates); its steps are
        copied into the certification as they are (steps) and its script frozen (script: version, rules, capture
        profile, steps_sha256), and the app shows them while recording. steps_from_certification_id (optional,
        instead of step_template_id): an approved certification of this same product that followed a script; its
        frozen script is copied with the same steps_sha256 (an exit follows its entry's script). An id that is not
        one of your templates, a certification that is not an approved one of this product with a script, or both
        fields at once: 422 invalid_certification_request, nothing created. Changing or deleting the template later
        never changes this copy. The product's declared identity (asset_kind and its active identifiers) is frozen
        too (asset in get_certification); its seal carries only a salted commitment, never the identifiers.
        dossier_id and phase (optional, both or neither: 422 invalid_phase; decision 149.9): the certification is the
        entrada or the salida of that dossier of this product (422 dossier_product_mismatch otherwise), recorded by
        the seller (requested_by vendedor; 422 dossier_seller_only). Only while the dossier is open (409
        dossier_not_open) and no other certification of it holds that phase, waiting for a take, being verified or
        approved (409 dossier_phase_taken; a denied one is retried or asked again). A salida needs an approved entrada
        (409 dossier_entry_not_approved) and follows its script (same steps_sha256): leave step_template_id and
        steps_from_certification_id out (422 dossier_script_inherited).
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [requested_by]
              properties:
                requested_by:
                  type: string
                  enum: [vendedor, comprador]
                  description: Who records it, the seller (vendedor) or the buyer (comprador). Only a seller's can be made public.
                label: { type: [string, "null"], maxLength: 100, description: Free text for the seller. }
                buyer_email:
                  type: [string, "null"]
                  maxLength: 254
                  description: The buyer's email. Required when requested_by is comprador; leave it out for vendedor.
                record_days: { type: integer, minimum: 1, maximum: 365, default: 7, description: Days to start a take, from now (record_by). }
                step_template_id:
                  type: [integer, "null"]
                  description: One of your step templates, for either role; its steps are copied as they are and its script frozen.
                steps_from_certification_id:
                  type: [integer, "null"]
                  description: An approved certification of this product that followed a script; its frozen script is copied (same steps_sha256). Not with step_template_id.
                dossier_id:
                  type: [integer, string, "null"]
                  description: One of your dossiers of this product, by its id or its EX- id; with phase.
                phase:
                  type: [string, "null"]
                  enum: [entrada, salida, null]
                  description: The certification's phase in the dossier; with dossier_id.
      responses:
        "201":
          description: Created.
          content: { application/json: { schema: { $ref: "#/components/schemas/Certification" } } }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/NotFound" }
        "409": { description: "dossier_not_open, dossier_phase_taken or dossier_entry_not_approved.", content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } } }
        "422": { $ref: "#/components/responses/Invalid" }
        "429": { $ref: "#/components/responses/MailQuota" }
  /certifications:
    get:
      tags: [certifications]
      operationId: list_certifications
      summary: List the seller's certifications, newest first or by state, each with its page (/v/) and whether it waits for a take we could not verify.
      description: >-
        As the dashboard's «Certificaciones» (and a product's page with product_id): its filter is status (awaiting_recording
        is «Por grabar», processing «Verificando»; pending, both, is a product's «Pendientes»), its order sort=state, and counts says how many each option of its filter keeps.
      parameters:
        - $ref: "#/components/parameters/page"
        - $ref: "#/components/parameters/per_page"
        - name: product_id
          in: query
          schema: { type: string }
        - name: q
          in: query
          description: >-
            As the search box of the dashboard's «Certificaciones»: certifications whose buyer_email starts with q (any
            case) or whose public_id starts with the code (with WT, with or without its dash, any case, O/I/L read as
            0/1/1: "wt-7k3m", "WT7K3M"; the 8 characters of a code, with or without WT, are an exact match). At least 3
            characters, at most 254, surrounding spaces ignored; empty is no search. %, _ and \ are literal. Too short
            or too long: 422 invalid_parameter. counts are those of what q finds; sort and pages as without it.
          schema: { type: string }
        - name: status
          in: query
          description: Only the certifications with this status (pending, both awaiting_recording and processing). Any other value, 422 invalid_parameter.
          schema: { $ref: "#/components/schemas/StatusFilter" }
        - name: sort
          in: query
          description: >-
            newest (the default): newest first. state: as the dashboard, denied, then awaiting_recording, then
            processing, then verified, newest first within each. Any other value, 422 invalid_parameter.
          schema: { type: string, enum: [newest, state], default: newest }
      responses:
        "200":
          description: A page of certifications.
          content:
            application/json:
              schema:
                allOf:
                  - $ref: "#/components/schemas/Page"
                  - type: object
                    properties:
                      data: { type: array, items: { $ref: "#/components/schemas/Certification" } }
                      counts:
                        type: object
                        description: >-
                          How many certifications each option of the dashboard's filters keeps (product_id applies,
                          status does not): all, verified, pending (awaiting_recording and processing), denied, and
                          awaiting_recording and processing on their own (the tiles «Por grabar» and «Verificando» of
                          «Certificaciones»).
                        properties:
                          all: { type: integer }
                          verified: { type: integer }
                          pending: { type: integer }
                          denied: { type: integer }
                          awaiting_recording: { type: integer }
                          processing: { type: integer }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "422": { $ref: "#/components/responses/Invalid" }
        "429": { $ref: "#/components/responses/RateLimited" }
  /certifications/{id}:
    parameters:
      - $ref: "#/components/parameters/certification_id"
    get:
      tags: [certifications]
      operationId: get_certification
      summary: "Status of one certification, with its takes (the account that started each, its time-stamps, its challenge number and declared steps), its page (/v/) and what is public."
      responses:
        "200": { description: The certification., content: { application/json: { schema: { $ref: "#/components/schemas/CertificationDetail" } } } }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/NotFound" }
        "429": { $ref: "#/components/responses/RateLimited" }
  /certifications/{certification_id}/publication:
    parameters:
      - name: certification_id
        in: path
        required: true
        schema: { type: integer }
    get:
      tags: [publication]
      operationId: get_publication
      summary: What is public now, and the candidates (one frame every 500 ms) that can be published.
      description: |
        Only for a seller's verified certification. candidates_status is "preparing" while they are being
        extracted: ask again in a few seconds. Each candidate's image_url is a signed URL that expires at
        image_urls_expire_at and is fetched with the same Bearer token (image/jpeg, 320 px).
      responses:
        "200": { description: The publication state., content: { application/json: { schema: { $ref: "#/components/schemas/Publication" } } } }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/NotFound" }
        "409": { $ref: "#/components/responses/NotPublishable" }
        "429": { $ref: "#/components/responses/RateLimited" }
    put:
      tags: [publication]
      operationId: set_publication
      summary: Set exactly what is public (declarative).
      description: |
        Send the WHOLE set that should be public now: what is not listed is unpublished, what is already
        public stays (a frame is sealed with C2PA once). To add one frame, send the current candidate_ids
        plus the new one. Frames published before the carousel existed (loose_frames) go by frame_ids.
        The public page only shows a frame once it is sealed (frames[].sealed).
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [video, candidate_ids]
              properties:
                video: { type: boolean, description: Whether the video is public. }
                candidate_ids: { type: array, items: { type: integer } }
                frame_ids: { type: array, items: { type: integer }, default: [] }
      responses:
        "200":
          description: The publication state after the change, and how many items changed.
          content:
            application/json:
              schema:
                allOf:
                  - $ref: "#/components/schemas/Publication"
                  - type: object
                    properties:
                      changes:
                        type: object
                        properties:
                          published: { type: integer }
                          unpublished: { type: integer }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/NotFound" }
        "409": { $ref: "#/components/responses/NotPublishable" }
        "422": { $ref: "#/components/responses/Invalid" }
        "429": { $ref: "#/components/responses/RateLimited" }
  /frame_candidates/{token}:
    parameters:
      - name: token
        in: path
        required: true
        schema: { type: string }
    get:
      tags: [publication]
      operationId: get_candidate_image
      summary: The still of one candidate (use the image_url from get_publication as is).
      responses:
        "200": { description: The JPEG., content: { image/jpeg: { schema: { type: string, format: binary } } } }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { description: Expired, tampered or not yours., content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } } }
        "429": { $ref: "#/components/responses/RateLimited" }
  /certifications/{id}/public_view:
    parameters:
      - $ref: "#/components/parameters/certification_id"
    put:
      tags: [page]
      operationId: set_public_mode
      summary: Turn the page's public mode on (no password) or off.
      description: Only a seller's verified certification; a denied or a buyer's one always asks for its password (409 not_publishable), and so does a dossier's (409 dossier_private).
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [public]
              properties:
                public: { type: boolean }
      responses:
        "200": { description: The page., content: { application/json: { schema: { $ref: "#/components/schemas/View" } } } }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/NotFound" }
        "409": { $ref: "#/components/responses/NotPublishable" }
        "422": { $ref: "#/components/responses/Invalid" }
        "429": { $ref: "#/components/responses/RateLimited" }
  /certifications/{id}/buyer_notice:
    parameters:
      - $ref: "#/components/parameters/certification_id"
    post:
      tags: [certifications]
      operationId: resend_buyer_notice
      summary: Mail the buyer of a buyer's certification again.
      description: |
        The same fixed notice to the same buyer_email, queued again (buyer.notice.status pending). At most once
        every 10 minutes (429 buyer_notice_cooldown) and 3 times per certification (409 buyer_notice_resend_limit);
        it counts in the mail quotas (429 buyer_notice_quota). Only a buyer's certification (409
        not_a_buyer_certification).
      responses:
        "202":
          description: Queued.
          content: { application/json: { schema: { $ref: "#/components/schemas/Certification" } } }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/NotFound" }
        "409": { description: "not_a_buyer_certification or buyer_notice_resend_limit.", content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } } }
        "422": { $ref: "#/components/responses/Invalid" }
        "429": { $ref: "#/components/responses/MailQuota" }
  /certifications/{id}/buyer:
    parameters:
      - $ref: "#/components/parameters/certification_id"
    put:
      tags: [certifications]
      operationId: set_buyer_email
      summary: Correct the buyer's email of a buyer's certification.
      description: |
        Allowed in any state. The previous buyer loses access to the certification on their next request; the
        new email's buyer gets it (once they sign in with it) and is mailed the notice. A take the previous buyer
        already started still ends and uploads, and its result is the new buyer's. The same email it has: 422
        unchanged. Counts in the mail quotas (429 buyer_notice_quota). The answer is the same whether the email
        has a Watchtower account or not. A retried certification's next attempt is corrected on its own.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [buyer_email]
              properties:
                buyer_email: { type: string, maxLength: 254 }
      responses:
        "200":
          description: Corrected; the new buyer's mail is queued.
          content: { application/json: { schema: { $ref: "#/components/schemas/Certification" } } }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/NotFound" }
        "409": { description: "not_a_buyer_certification.", content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } } }
        "422": { $ref: "#/components/responses/Invalid" }
        "429": { $ref: "#/components/responses/MailQuota" }
  /certifications/{id}/retry:
    parameters:
      - $ref: "#/components/parameters/certification_id"
    post:
      tags: [certifications]
      operationId: retry_certification
      summary: Retry a denied certification with a new certification, its next attempt.
      description: |
        The denied certification stays denied (its outcome, denial_reason and page do not change). A NEW
        certification of the same product is opened, with the same requested_by, label and steps, attempt_number
        one more and parent_id the denied one, and its own record_by, record_days days from now (1-365; by default
        as many as the denied one's). A buyer's keeps its buyer, with no new mail (the buyer sees the chain in the
        app). A chain has at most 3 attempts, the first included; the denied one's retryable and attempts_remaining
        say whether one is left. Already retried: 200 with that next attempt.
        Refused (409): not_denied; edit_suspected (denial_reason sospecha_de_edicion is never retried); view_expired
        (its page expired, a year after the denial: never retried after that); attempts_exhausted (it was the 3rd attempt).
        A dossier's certification (149.9): its next attempt keeps its dossier and phase, only while the dossier is open
        (409 dossier_not_open) and no other certification of it holds that phase (409 dossier_phase_taken).
      requestBody:
        required: false
        content:
          application/json:
            schema:
              type: object
              properties:
                record_days: { type: integer, minimum: 1, maximum: 365 }
      responses:
        "201":
          description: The next attempt.
          content: { application/json: { schema: { $ref: "#/components/schemas/Certification" } } }
        "200": { description: Already retried; its next attempt., content: { application/json: { schema: { $ref: "#/components/schemas/Certification" } } } }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/NotFound" }
        "409": { description: "not_denied, edit_suspected (sospecha_de_edicion), view_expired (its page expired) or attempts_exhausted.", content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } } }
        "422": { $ref: "#/components/responses/Invalid" }
        "429": { $ref: "#/components/responses/RateLimited" }
  /certifications/{id}/video:
    parameters:
      - $ref: "#/components/parameters/certification_id"
    get:
      tags: [downloads]
      operationId: download_video
      summary: Download the original video (verified or denied, published or not).
      description: The original as stored, with no manifest inside; check it against the separate C2PA manifest.
      responses:
        "200": { description: The video., content: { video/mp4: { schema: { type: string, format: binary } } } }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { description: "not_found, or no_video: not verified or denied yet, no take stored, or its original purged.", content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } } }
        "429": { $ref: "#/components/responses/RateLimited" }
  /certifications/{id}/manifest:
    parameters:
      - $ref: "#/components/parameters/certification_id"
    get:
      tags: [downloads]
      operationId: download_manifest
      summary: Download the video's C2PA manifest (.c2pa).
      responses:
        "200": { description: The manifest., content: { application/c2pa: { schema: { type: string, format: binary } } } }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { description: "not_found, no_video or no_manifest.", content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } } }
        "429": { $ref: "#/components/responses/RateLimited" }
  /certifications/{id}/frames/{frame_id}:
    parameters:
      - $ref: "#/components/parameters/certification_id"
      - { name: frame_id, in: path, required: true, schema: { type: integer }, description: "A frame of this certification: get_publication's frames[].frame_id, candidates[].frame_id or loose_frames[].frame_id." }
    get:
      tags: [downloads]
      operationId: download_frame
      summary: Download a sealed frame of the video (JPEG, its C2PA manifest inside), published or not.
      description: >-
        A frame the seller picked from the video (a seller's approved certification), sealed with C2PA with the video
        as ingredient. Published or not; while the video's original is stored.
      responses:
        "200": { description: The frame., content: { image/jpeg: { schema: { type: string, format: binary } } } }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { description: "not_found, no_video, or no_frame: not a sealed frame of this certification.", content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } } }
        "429": { $ref: "#/components/responses/RateLimited" }
  /certifications/{id}/downloads:
    parameters:
      - $ref: "#/components/parameters/certification_id"
    get:
      tags: [downloads]
      operationId: list_downloads
      summary: Who downloaded this certification's video, manifest or frames, newest first.
      description: >-
        One entry per download of the original video, its C2PA manifest or a frame, by any channel: this API (the token
        that did it), the dashboard (the user) or the public page /v/ (no actor; visitor_hash is the same for the
        same client and certification within a UTC day, to count unique downloads, and never the IP). kind playback: the video
        played on the public page, once per visit (never each seek); video, manifest and frame are downloads on purpose.
        The public page only downloads the manifest (video rows of channel public_v are from before it stopped).
        total counts every entry; downloads_total (video, manifest and frame) and playbacks_total (playback) split it,
        as the dashboard's «Qué publico» shows them.
      parameters:
        - $ref: "#/components/parameters/page"
        - $ref: "#/components/parameters/per_page"
      responses:
        "200":
          description: A page of downloads.
          content:
            application/json:
              schema:
                allOf:
                  - $ref: "#/components/schemas/Page"
                  - type: object
                    properties:
                      data: { type: array, items: { $ref: "#/components/schemas/Download" } }
                      total: { type: integer, description: Every entry of this certification (downloads and plays), on every page. }
                      downloads_total: { type: integer, description: "Downloads on purpose (kind video, manifest or frame), on every page." }
                      playbacks_total: { type: integer, description: "Plays on the public page (kind playback), on every page." }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/NotFound" }
        "422": { $ref: "#/components/responses/Invalid" }
        "429": { $ref: "#/components/responses/RateLimited" }
  /account:
    get:
      tags: [account]
      operationId: get_account
      summary: Who the token acts for, the dashboard's Settings.
      description: >-
        The seller's organization, the user who created the token (the token acts in their name) and the token
        itself (hint: the wt_live_ prefix and the first six characters; never the secret). token.created_by is the
        gestor who created the token (the same person as user). members and pending_invitations are the team of
        Settings, read only: inviting, revoking and accepting stay in the dashboard. organization.sector (decision
        180) is chosen when the company is created and never changes; allowed_options says what it allows: the
        objects as (asset_kind, challenge_medium) pairs for create_product and update_product, and the verticals
        and capture profiles for the step templates. Anything else is 422 option_not_allowed_for_sector.
      responses:
        "200":
          description: The account.
          content:
            application/json:
              schema:
                type: object
                properties:
                  organization:
                    type: object
                    properties:
                      id: { type: integer }
                      name: { type: string }
                      sector: { type: string, enum: [inmobiliario, moviles, general] }
                  allowed_options:
                    type: object
                    description: What the organization's sector allows (decision 180).
                    properties:
                      objects:
                        type: array
                        items:
                          type: object
                          properties:
                            asset_kind: { type: string, enum: [otro, vivienda] }
                            challenge_medium: { $ref: "#/components/schemas/ChallengeMedium" }
                      verticals: { type: array, items: { type: string } }
                      capture_profiles: { type: array, items: { type: string, enum: [estandar, inmueble] } }
                  user:
                    type: object
                    properties: { id: { type: integer }, email: { type: string }, role: { type: string } }
                  token:
                    type: object
                    properties:
                      id: { type: integer }
                      name: { type: string }
                      hint: { type: string }
                      created_at: { type: string, format: date-time }
                      created_by:
                        type: object
                        description: The gestor who created the token.
                        properties: { id: { type: integer }, email: { type: string } }
                  members:
                    type: array
                    description: The organization's members, by email. acting is the user the token acts for.
                    items:
                      type: object
                      properties:
                        id: { type: integer }
                        email: { type: string }
                        role: { type: string, enum: [operario, gestor] }
                        acting: { type: boolean }
                  pending_invitations:
                    type: array
                    description: >-
                      The team invitations not yet accepted, revoked or expired, the oldest first; expires_at is until
                      when the invited email can accept it.
                    items:
                      type: object
                      properties:
                        id: { type: integer }
                        email: { type: string }
                        role: { type: string, enum: [operario, gestor] }
                        expires_at: { type: string, format: date-time }
                  field_settings: { $ref: "#/components/schemas/FieldSettings" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "429": { $ref: "#/components/responses/RateLimited" }
  /account/field_settings:
    patch:
      tags: [account]
      operationId: update_account_field_settings
      summary: Change the organization's field settings (how takes go up); a gestor's token only.
      description: >-
        upload_network_policy: wifi_only (the default) uploads takes over wifi only; cellular_allowed also lets them
        go up by mobile data, but only from a phone whose account consented in the app on that phone. Only a
        gestor's token can change it (403 forbidden otherwise). The challenge ceilings (5 an hour per account, 30
        an hour per organization) and the takes a day per account are fixed by Watchtower: they are not settings,
        and sending them is 422 invalid_parameter.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [upload_network_policy]
              additionalProperties: false
              properties:
                upload_network_policy: { $ref: "#/components/schemas/UploadNetworkPolicy" }
      responses:
        "200": { description: The field settings as they are now., content: { application/json: { schema: { $ref: "#/components/schemas/FieldSettings" } } } }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "422": { $ref: "#/components/responses/Invalid" }
        "429": { $ref: "#/components/responses/RateLimited" }
  /api_tokens:
    get:
      tags: [api_tokens]
      operationId: list_api_tokens
      summary: The seller's API tokens, the list of the dashboard's /api_tokens (read only).
      description: >-
        The active ones first, the newest first; then the revoked ones, the last revoked first. Never the secret
        nor its hash: hint is the wt_live_ prefix and the first six characters, to tell tokens apart. created_by is
        the gestor who created the token (the token acts in their name). Creating and revoking a token are only
        done in the dashboard, by a gestor with a session. Only a gestor's token can list them (403 forbidden
        otherwise, as the dashboard).
      parameters:
        - $ref: "#/components/parameters/page"
        - $ref: "#/components/parameters/per_page"
      responses:
        "200":
          description: A page of API tokens.
          content:
            application/json:
              schema:
                allOf:
                  - $ref: "#/components/schemas/Page"
                  - type: object
                    properties:
                      data: { type: array, items: { $ref: "#/components/schemas/ApiToken" } }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "422": { $ref: "#/components/responses/Invalid" }
        "429": { $ref: "#/components/responses/RateLimited" }
  /step_templates:
    get:
      tags: [step_templates]
      operationId: list_step_templates
      summary: Your templates of guide steps («Plantillas» in the dashboard), by name.
      description: >-
        A template is a named list of 1 to 40 steps (label, optional instruction, and optionally a key, whether it
        is required, its minimum seconds and whether it asks for a declared state) the app shows the buyer, in order,
        while recording a certification requested with its step_template_id. A guide, never a reason to reject a
        take. version goes up by one each time its steps, its rules or its capture profile change (never with a
        rename). Deleted (archived) templates are not listed.
      parameters:
        - $ref: "#/components/parameters/page"
        - $ref: "#/components/parameters/per_page"
        - name: q
          in: query
          description: >-
            As the search box of the dashboard's «Plantillas»: the templates whose name contains q (any case), from 1
            character, at most 254, surrounding spaces ignored; empty is no search. %, _ and \ are literal. Too long:
            422 invalid_parameter.
          schema: { type: string }
      responses:
        "200":
          description: A page of step templates.
          content:
            application/json:
              schema:
                allOf:
                  - $ref: "#/components/schemas/Page"
                  - type: object
                    properties:
                      data: { type: array, items: { $ref: "#/components/schemas/StepTemplate" } }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "422": { $ref: "#/components/responses/Invalid" }
        "429": { $ref: "#/components/responses/RateLimited" }
    post:
      tags: [step_templates]
      operationId: create_step_template
      summary: Save a template of guide steps.
      description: >-
        name is unique among your templates not deleted (409 step_template_name_taken); steps are 1 to 40, in the
        order the app shows them (422 invalid_step_template otherwise). name and steps are required unless
        from_vertical is given: the template starts as the catalog template of that vertical (GET /verticals; its
        name, steps, rules, capture profile and vertical), and each field sent replaces its own. A vertical not in the
        catalog: 422 unknown_vertical. With rules.enforce true every step needs its key. The new template has version 1.
        The vertical (or from_vertical) and the capture profile must be ones the organization's sector allows
        (decision 180; get_account's allowed_options), else 422 option_not_allowed_for_sector.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              properties:
                name: { type: string, minLength: 1, maxLength: 100 }
                steps: { $ref: "#/components/schemas/StepList" }
                from_vertical: { type: string, description: "Start from the catalog template of this vertical (GET /verticals), e.g. vivienda." }
                vertical: { type: [string, "null"], description: "A vertical of the catalog (GET /verticals), e.g. vivienda; null: none." }
                capture_profile: { type: string, enum: [estandar, inmueble], description: "How the app records it: estandar (as always) or inmueble (a flat: longer, 1080p)." }
                rules: { $ref: "#/components/schemas/StepRules" }
      responses:
        "201": { description: Created., content: { application/json: { schema: { $ref: "#/components/schemas/StepTemplate" } } } }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "409": { description: "step_template_name_taken: another template of yours has that name.", content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } } }
        "422": { $ref: "#/components/responses/Invalid" }
        "429": { $ref: "#/components/responses/RateLimited" }
  /step_templates/{id}:
    parameters:
      - $ref: "#/components/parameters/step_template_id"
    get:
      tags: [step_templates]
      operationId: get_step_template
      summary: Show one template of guide steps.
      responses:
        "200": { description: The template., content: { application/json: { schema: { $ref: "#/components/schemas/StepTemplate" } } } }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/NotFound" }
        "429": { $ref: "#/components/responses/RateLimited" }
    patch:
      tags: [step_templates]
      operationId: update_step_template
      summary: Rename a template or replace its steps, rules, capture profile or vertical.
      description: >-
        Only the fields sent change (at least one; none: 422 parameter_missing); steps replaces the whole list and
        rules the rules. version goes up by one when the steps, the rules or the capture profile change (a rename or a
        vertical never). The certifications already requested with it keep their own copy of its steps. A new
        vertical or capture profile must be one the organization's sector allows (decision 180; get_account's
        allowed_options), else 422 option_not_allowed_for_sector; the ones the template already has are kept.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              minProperties: 1
              properties:
                name: { type: string, minLength: 1, maxLength: 100 }
                steps: { $ref: "#/components/schemas/StepList" }
                vertical: { type: [string, "null"], description: "A vertical of the catalog (GET /verticals), e.g. vivienda; null: none." }
                capture_profile: { type: string, enum: [estandar, inmueble], description: "How the app records it: estandar (as always) or inmueble (a flat: longer, 1080p)." }
                rules: { $ref: "#/components/schemas/StepRules" }
      responses:
        "200": { description: The template as it is now., content: { application/json: { schema: { $ref: "#/components/schemas/StepTemplate" } } } }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/NotFound" }
        "409": { description: "step_template_name_taken: another template of yours has that name.", content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } } }
        "422": { $ref: "#/components/responses/Invalid" }
        "429": { $ref: "#/components/responses/RateLimited" }
    delete:
      tags: [step_templates]
      operationId: delete_step_template
      summary: Delete (archive) a template of guide steps.
      description: >-
        The template is archived: it leaves the list and can no longer be read, changed or used for a certification
        (404 from then on), and its name is free again for a new one. The certifications already requested with it
        keep their own copy of its steps; nothing else changes. Only with a gestor's token (decision 125.10): an
        operario's gets 403 forbidden and the template stays.
      responses:
        "200":
          description: Archived.
          content:
            application/json:
              schema:
                type: object
                properties:
                  id: { type: integer }
                  deleted: { type: boolean, const: true }
                  archived_at: { type: string, format: date-time }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "429": { $ref: "#/components/responses/RateLimited" }
  /verticals:
    get:
      tags: [verticals]
      operationId: list_verticals
      summary: The catalog of verticals, each one with its ready-made template of steps.
      description: >-
        A vertical is a kind of asset Watchtower certifies with a template of its own (vivienda: a rented flat, entry
        and exit). Copy one into your templates with POST /step_templates {from_vertical: key}. Only the verticals
        of the organization's sector are listed (decision 180): none for moviles and general.
      responses:
        "200":
          description: The verticals of the catalog the organization's sector allows.
          content:
            application/json:
              schema:
                type: object
                properties:
                  data: { type: array, items: { $ref: "#/components/schemas/Vertical" } }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "429": { $ref: "#/components/responses/RateLimited" }
  /dossiers:
    get:
      tags: [dossiers]
      operationId: list_dossiers
      summary: Your dossiers (a product's rental, with its parties), newest first.
      description: >-
        A dossier (EX-xxxxxxxx) follows one product's rental: its parties (inquilino, propietario, observador) are
        named by email and mailed. product_id and status narrow the list.
      parameters:
        - $ref: "#/components/parameters/page"
        - $ref: "#/components/parameters/per_page"
        - { name: product_id, in: query, schema: { type: string }, description: Only this product's dossiers. }
        - { name: status, in: query, schema: { $ref: "#/components/schemas/DossierStatus" } }
      responses:
        "200":
          description: A page of dossiers.
          content:
            application/json:
              schema:
                allOf:
                  - $ref: "#/components/schemas/Page"
                  - type: object
                    properties:
                      data: { type: array, items: { $ref: "#/components/schemas/Dossier" } }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "422": { $ref: "#/components/responses/Invalid" }
        "429": { $ref: "#/components/responses/RateLimited" }
    post:
      tags: [dossiers]
      operationId: create_dossier
      summary: Open a dossier over one of your products.
      description: >-
        One open alquiler dossier per product (409 dossier_already_open: cancel it first). external_ref is yours, unique
        among your dossiers (409 dossier_external_ref_taken); response_days (1 to 60, 10 by default) is how long a
        party has to answer a review.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [product_id]
              properties:
                product_id: { type: string, description: The product's id (digits). }
                kind: { type: string, enum: [alquiler], default: alquiler }
                external_ref: { type: [string, "null"], pattern: '^[^\s\x00-\x1F\x7F]{1,100}$' }
                response_days: { type: integer, minimum: 1, maximum: 60, default: 10 }
      responses:
        "201": { description: Opened., content: { application/json: { schema: { $ref: "#/components/schemas/Dossier" } } } }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/NotFound" }
        "409": { description: "dossier_already_open or dossier_external_ref_taken.", content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } } }
        "422": { $ref: "#/components/responses/Invalid" }
        "429": { $ref: "#/components/responses/RateLimited" }
  /dossiers/{id}:
    parameters:
      - $ref: "#/components/parameters/dossier_id"
    get:
      tags: [dossiers]
      operationId: get_dossier
      summary: Show one dossier with its parties.
      responses:
        "200": { description: The dossier., content: { application/json: { schema: { $ref: "#/components/schemas/Dossier" } } } }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/NotFound" }
        "429": { $ref: "#/components/responses/RateLimited" }
    patch:
      tags: [dossiers]
      operationId: update_dossier
      summary: Change an open dossier's external_ref or response_days.
      description: >-
        Only the fields sent change (at least one; none: 422 parameter_missing). Only while open (409 dossier_not_open).
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              minProperties: 1
              properties:
                external_ref: { type: [string, "null"], pattern: '^[^\s\x00-\x1F\x7F]{1,100}$' }
                response_days: { type: integer, minimum: 1, maximum: 60 }
      responses:
        "200": { description: The dossier as it is now., content: { application/json: { schema: { $ref: "#/components/schemas/Dossier" } } } }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/NotFound" }
        "409": { description: "dossier_not_open or dossier_external_ref_taken.", content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } } }
        "422": { $ref: "#/components/responses/Invalid" }
        "429": { $ref: "#/components/responses/RateLimited" }
  /dossiers/{id}/cancel:
    parameters:
      - $ref: "#/components/parameters/dossier_id"
    post:
      tags: [dossiers]
      operationId: cancel_dossier
      summary: Cancel an open dossier.
      description: >-
        status cancelado; the product can then have another dossier. Its parties stay as they were. Only while open
        (409 dossier_not_open).
      responses:
        "200": { description: Cancelled., content: { application/json: { schema: { $ref: "#/components/schemas/Dossier" } } } }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/NotFound" }
        "409": { description: "dossier_not_open.", content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } } }
        "429": { $ref: "#/components/responses/RateLimited" }
  /dossiers/{id}/parties:
    parameters:
      - $ref: "#/components/parameters/dossier_id"
    post:
      tags: [dossiers]
      operationId: add_dossier_party
      summary: Name a party of an open dossier by email; Watchtower mails them.
      description: >-
        A fixed notice (the company's name, the dossier's EX- id and the role; never external_ref) is queued
        (notice.status pending); they sign in with that email to see it. One active party per email and role (409
        party_exists). It counts in the mail quotas (429 party_notice_quota). The answer is the same whether the email
        has an account. Only while open (409 dossier_not_open).
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [email, role]
              properties:
                email: { type: string, maxLength: 254 }
                role: { $ref: "#/components/schemas/PartyRole" }
                display_name: { type: [string, "null"], maxLength: 60 }
      responses:
        "201": { description: Added; its mail queued., content: { application/json: { schema: { $ref: "#/components/schemas/DossierParty" } } } }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/NotFound" }
        "409": { description: "party_exists or dossier_not_open.", content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } } }
        "422": { $ref: "#/components/responses/Invalid" }
        "429": { $ref: "#/components/responses/PartyMailQuota" }
  /dossiers/{id}/parties/{party_id}:
    parameters:
      - $ref: "#/components/parameters/dossier_id"
      - $ref: "#/components/parameters/party_id"
    delete:
      tags: [dossiers]
      operationId: revoke_dossier_party
      summary: Revoke a party of a dossier.
      description: >-
        The party loses the dossier; its row stays (revoked, revoked_at). In any state of the dossier. Already revoked:
        409 party_revoked.
      responses:
        "200": { description: Revoked., content: { application/json: { schema: { $ref: "#/components/schemas/DossierParty" } } } }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/NotFound" }
        "409": { description: "party_revoked.", content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } } }
        "429": { $ref: "#/components/responses/RateLimited" }
  /dossiers/{id}/parties/{party_id}/notice:
    parameters:
      - $ref: "#/components/parameters/dossier_id"
      - $ref: "#/components/parameters/party_id"
    post:
      tags: [dossiers]
      operationId: resend_party_notice
      summary: Mail a party of an open dossier again.
      description: >-
        The same fixed notice, queued again (notice.status pending). At most once every 10 minutes (429
        party_notice_cooldown) and 3 times per party (409 party_notice_resend_limit); it counts in the mail quotas
        (429 party_notice_quota). Not for a revoked party (409 party_revoked) nor a dossier no longer open (409
        dossier_not_open).
      responses:
        "202": { description: Queued., content: { application/json: { schema: { $ref: "#/components/schemas/DossierParty" } } } }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/NotFound" }
        "409": { description: "party_revoked, party_notice_resend_limit or dossier_not_open.", content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } } }
        "429": { $ref: "#/components/responses/PartyMailQuota" }
components:
  securitySchemes:
    sellerToken:
      type: http
      scheme: bearer
      description: A seller API token, wt_live_... (dashboard, /api_tokens).
  parameters:
    page: { name: page, in: query, schema: { type: integer, minimum: 1, maximum: 10000, default: 1 } }
    per_page: { name: per_page, in: query, schema: { type: integer, minimum: 1, maximum: 100, default: 50 } }
    product_id: { name: id, in: path, required: true, schema: { type: string }, description: The product's id (digits). }
    certification_id: { name: id, in: path, required: true, schema: { type: integer } }
    step_template_id: { name: id, in: path, required: true, schema: { type: integer }, description: The step template's id. }
    dossier_id: { name: id, in: path, required: true, schema: { type: string }, description: "The dossier's id (digits) or its EX- id." }
    party_id: { name: party_id, in: path, required: true, schema: { type: integer }, description: The party's id. }
    identifier_id: { name: identifier_id, in: path, required: true, schema: { type: integer }, description: "The identifier's id (Product.identifiers[].id)." }
  responses:
    Unauthorized: { description: "unauthorized: missing, unknown or revoked token.", content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } } }
    Forbidden: { description: "forbidden: the token's user may not do this (only a gestor lists the API tokens, changes the field settings or deletes a step template).", content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } } }
    NotFound: { description: "not_found: does not exist or is another seller's.", content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } } }
    Invalid: { description: A parameter is missing or invalid (see error.code)., content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } } }
    NotPublishable: { description: "not_publishable: a buyer's certification, or one not verified (or not approved) yet. dossier_private: a dossier's certification (149.2), never published nor public.", content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } } }
    RateLimited: { description: "rate_limited: over 120 requests a minute for this token; see Retry-After.", content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } } }
    IdentifierTaken: { description: "identifier_taken: the seller already has that identifier active on a product (the message says which); never merged.", content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } } }
    MailQuota: { description: "buyer_notice_quota: the day's mail quota of the company or of that recipient is used up (nothing was done); buyer_notice_cooldown: resent less than 10 minutes ago; rate_limited: see RateLimited.", content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } } }
    PartyMailQuota: { description: "party_notice_quota: the day's mail quota of the company or of that recipient is used up (nothing was done); party_notice_cooldown: resent less than 10 minutes ago; rate_limited: see RateLimited.", content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } } }
  schemas:
    DossierStatus: { type: string, enum: [abierto, cerrado, cancelado] }
    PartyRole: { type: string, enum: [inquilino, propietario, observador] }
    Dossier:
      type: object
      properties:
        id: { type: integer }
        public_id: { type: string, description: "EX- and 8 characters." }
        product_id: { type: string }
        kind: { type: string, enum: [alquiler] }
        external_ref: { type: [string, "null"] }
        status: { $ref: "#/components/schemas/DossierStatus" }
        response_days: { type: integer }
        opened_at: { type: string, format: date-time }
        closed_at: { type: [string, "null"], format: date-time }
        cancelled_at: { type: [string, "null"], format: date-time }
        parties: { type: array, items: { $ref: "#/components/schemas/DossierParty" } }
        certifications:
          type: array
          description: Its certifications in the order they were asked (decision 149.9), retries included.
          items:
            type: object
            properties:
              id: { type: integer }
              public_id: { type: string }
              phase: { type: string, enum: [entrada, salida] }
              status: { $ref: "#/components/schemas/Status" }
              outcome: { type: [string, "null"], enum: [aprobada, denegada, null] }
              attempt_number: { type: integer }
              parent_id: { type: [integer, "null"] }
              steps_sha256: { type: [string, "null"] }
              created_at: { type: string, format: date-time }
    DossierParty:
      type: object
      description: Never whether the email has an account.
      properties:
        id: { type: integer }
        role: { $ref: "#/components/schemas/PartyRole" }
        email: { type: string }
        display_name: { type: [string, "null"] }
        notice:
          type: object
          properties:
            status: { type: string, enum: [pending, sending, sent, failed] }
            sent_at: { type: [string, "null"], format: date-time }
            resends: { type: integer }
        revoked: { type: boolean }
        revoked_at: { type: [string, "null"], format: date-time }
        created_at: { type: string, format: date-time }
    Error:
      type: object
      required: [error]
      properties:
        error:
          type: object
          required: [code, message]
          properties:
            code: { type: string }
            message: { type: string }
    Page:
      type: object
      properties:
        page: { type: integer }
        per_page: { type: integer }
        next_page: { type: [integer, "null"] }
        total: { type: integer, description: Every row of the list, on every page. }
        total_pages: { type: integer, description: "Pages of per_page rows; at least 1, as the dashboard's «Página X de Y»." }
    ApiToken:
      type: object
      properties:
        id: { type: integer }
        name: { type: string }
        hint: { type: string, description: "The wt_live_ prefix and the first six characters; never the secret." }
        created_at: { type: string, format: date-time }
        last_used_at: { type: [string, "null"], format: date-time, description: Null when never used. }
        revoked: { type: boolean }
        revoked_at: { type: [string, "null"], format: date-time }
        created_by:
          type: object
          description: The gestor who created the token, in whose name it acts.
          properties: { id: { type: integer }, email: { type: string } }
    Product:
      type: object
      properties:
        id: { type: string, description: Internal id (digits). }
        external_ref: { type: [string, "null"] }
        challenge_medium: { $ref: "#/components/schemas/ChallengeMedium" }
        asset_kind: { $ref: "#/components/schemas/AssetKind" }
        identifiers:
          type: array
          description: Its active identifiers, oldest first (the retired ones are in get_product_history).
          items: { $ref: "#/components/schemas/AssetIdentifier" }
        created_at: { type: string, format: date-time }
        certifications_count: { type: integer }
        last_certification:
          description: "Its newest certification (created_at, then id), as the badge of its row in the dashboard's Productos; null without any. last_certification_status filters by its status."
          oneOf:
            - type: "null"
            - type: object
              properties:
                id: { type: integer }
                public_id: { type: string, example: WT-7K3M9QX2 }
                status: { $ref: "#/components/schemas/Status" }
    UploadNetworkPolicy:
      type: string
      enum: [wifi_only, cellular_allowed]
      description: >-
        How the organization's takes go up. wifi_only: over wifi only (the default). cellular_allowed: also by
        mobile data, from a phone whose account consented in the app on that phone.
    FieldSettings:
      type: object
      description: The organization's field settings, as a gestor changes them.
      properties:
        upload_network_policy: { $ref: "#/components/schemas/UploadNetworkPolicy" }
    AssetKind:
      type: string
      enum: [otro, vivienda]
      description: |
        What the product is, for its declared identity (decision 147). vivienda: a flat, with its address
        (address_unit, required) and optionally its cadastral reference (cadastral_ref) or a code (custom). otro:
        anything else, a phone included (challenge_medium does not decide it); it takes custom. Fixed once the
        product has a certification.
    AssetKindInput:
      type: string
      enum: [otro, vivienda]
      description: What a seller declares (decision 147.1, amended 3-oct-2026); without it, otro.
    AddressDetails:
      type: object
      description: "A flat's address (scheme address_unit). Required: street, number, postal_code (5 digits in Spain), city. Each field at most 100 characters."
      required: [street, number, postal_code, city]
      additionalProperties: false
      properties:
        street: { type: string, example: "C/ Llull" }
        number: { type: string, example: "12" }
        floor: { type: [string, "null"], example: "3º" }
        door: { type: [string, "null"], example: "2ª" }
        postal_code: { type: string, example: "08005" }
        city: { type: string, example: Barcelona }
        country: { type: [string, "null"], description: "ISO 3166 alpha-2; ES by default.", example: ES }
    AssetIdentifierInput:
      type: object
      required: [scheme]
      properties:
        scheme: { type: string, enum: [address_unit, cadastral_ref, custom] }
        value: { type: string, description: "cadastral_ref: the 20 characters of a Catastro reference (control letters checked; spaces ignored). custom: 1-100 printable characters, kept as sent; compared without case, accents, spaces, dashes, dots or slashes. Not for address_unit." }
        details: { $ref: "#/components/schemas/AddressDetails" }
        label: { type: [string, "null"], maxLength: 60, description: "Your name for it (Trastero, Piso principal)." }
    AssetIdentifier:
      type: object
      properties:
        id: { type: integer }
        scheme: { type: string, enum: [address_unit, cadastral_ref, custom] }
        value: { type: string, description: "As shown: an address_unit's is built from its details (C/ Llull 12, 3º 2ª, 08005 Barcelona)." }
        details: { type: object, description: "An address_unit's fields (AddressDetails, country filled in); {} for the others." }
        label: { type: [string, "null"] }
        created_at: { type: string, format: date-time }
        retired_at: { type: [string, "null"], format: date-time }
        retired_reason: { type: [string, "null"], enum: [error, sustituido, baja, null] }
    AssetHistory:
      type: object
      properties:
        product_id: { type: string }
        asset_kind: { $ref: "#/components/schemas/AssetKind" }
        identifiers: { type: array, items: { $ref: "#/components/schemas/AssetIdentifier" } }
        entries:
          type: array
          items:
            type: object
            properties:
              at: { type: string, format: date-time }
              type: { type: string, enum: [product_created, product_updated, asset_identifier_added, asset_identifier_retired, asset_identifier_renormalization_skipped, certification_requested] }
              actor: { type: [string, "null"], description: "Who did it (user:<id>, api_token:<id>…); null for a certification_requested entry." }
              payload: { type: [object, "null"], description: "The event's payload (identifier_id, scheme, reason, replaces, conflicts_with…; never an identifier's value); null for a certification. asset_identifier_renormalization_skipped (decision 147.6): this custom identifier kept the form it had with accents because conflicts_with (another active one of the seller) already has it without them; retire or replace one of the two." }
              certification:
                oneOf:
                  - type: "null"
                  - type: object
                    properties:
                      id: { type: integer }
                      public_id: { type: string, example: WT-7K3M9QX2 }
                      requested_by: { type: string }
                      label: { type: [string, "null"] }
                      status: { $ref: "#/components/schemas/Status" }
    ChallengeMedium:
      type: string
      enum: [papel, teclado]
      default: papel
      description: |
        The kind of object, as far as the take's guide goes. teclado: a phone; the app guides whoever records
        through 3 of its zones (corners, lens…). papel: any other object; no zones. Fixed once the product has a
        certification. The names are historical: until decision 137 they also said where the 4-digit challenge
        was shown (dialled on the keypad or written on paper). Since decision 144 the challenge is always
        written on paper and shown to the camera, whatever the kind of object.
    Status:
      type: string
      enum: [awaiting_recording, processing, verified, denied]
      description: |
        awaiting_recording: no accepted take yet. processing: a take was accepted and is being verified
        (verification_step says where). verified: the video passed every check: recorded live in the app, no cuts or edits detected (final). denied: see denial_reason; the
        certification stays denied, and while retryable is true POST /certifications/{id}/retry opens its next
        attempt, a new certification (at most 3 per chain; never for sospecha_de_edicion nor once its page expired).
    Step:
      type: object
      required: [label]
      additionalProperties: false
      properties:
        label: { type: string, minLength: 1, maxLength: 100, description: What to show, e.g. "Pantalla encendida". }
        instruction: { type: [string, "null"], maxLength: 500, description: How to show it (optional). }
    TemplateStep:
      type: object
      required: [label]
      additionalProperties: false
      properties:
        key: { type: [string, "null"], minLength: 1, maxLength: 32, description: "A stable name for the step: 1 to 32 of a-z, 0-9 and _, unique in the template, e.g. cocina (required for every step when rules.enforce is true)." }
        label: { type: string, minLength: 1, maxLength: 100, description: What to show, e.g. "Pantalla encendida". }
        instruction: { type: [string, "null"], maxLength: 500, description: How to show it (optional). }
        required: { type: boolean, default: true, description: "false: the app lets the buyer mark it «No aplica»." }
        min_seconds: { type: integer, minimum: 0, maximum: 600, default: 0, description: The least time to stay on it. }
        checklist: { type: boolean, default: false, description: "true: the buyer declares its state." }
    StepList:
      type: array
      minItems: 1
      maxItems: 40
      items: { $ref: "#/components/schemas/TemplateStep" }
      description: The steps in the order the app shows them.
    StepRules:
      type: object
      additionalProperties: false
      properties:
        order: { type: string, enum: [strict, any], default: strict, description: "strict: in the order given; any: in any order." }
        enforce: { type: boolean, default: false, description: "true: every step needs its key." }
    StepTemplate:
      type: object
      properties:
        id: { type: integer }
        name: { type: string }
        version: { type: integer, minimum: 1, description: "1, and one more each time its steps, rules or capture profile change." }
        vertical: { type: [string, "null"] }
        capture_profile: { type: string, enum: [estandar, inmueble] }
        rules: { $ref: "#/components/schemas/StepRules" }
        steps: { type: array, items: { $ref: "#/components/schemas/TemplateStep" }, description: "Every field filled (the defaults where not given)." }
        created_at: { type: string, format: date-time }
        updated_at: { type: string, format: date-time }
    Vertical:
      type: object
      properties:
        key: { type: string, description: "e.g. vivienda (from_vertical, vertical)." }
        name: { type: string }
        asset_kind: { type: string }
        template:
          type: object
          properties:
            name: { type: string }
            vertical: { type: string }
            capture_profile: { type: string, enum: [estandar, inmueble] }
            rules: { $ref: "#/components/schemas/StepRules" }
            steps: { type: array, items: { $ref: "#/components/schemas/TemplateStep" } }
    CertificationScript:
      type: object
      description: "The script a certification's takes follow, frozen when it was requested (decision 148): editing or deleting its template never changes it."
      properties:
        step_template_id: { type: [integer, "null"], description: The template it came from (through the certification it inherited from, if so). }
        inherited_from_certification_id: { type: [integer, "null"], description: "The certification whose script it inherited (steps_from_certification_id); null when it was requested with a template." }
        version: { type: integer, minimum: 1, description: The template's version when it was frozen. }
        vertical: { type: [string, "null"] }
        capture_profile: { type: string, enum: [estandar, inmueble] }
        rules: { $ref: "#/components/schemas/StepRules" }
        steps: { type: array, items: { $ref: "#/components/schemas/TemplateStep" }, description: Every field filled. }
        steps_sha256: { type: string, minLength: 64, maxLength: 64, description: "64 hex. sha256 of its capture profile, rules and steps: the same for an exit and the entry it inherited from." }
        enforce: { type: boolean, description: "rules.enforce, frozen for the capture contract." }
        min_finish_version: { type: integer, description: "The capture protocol's finish version a take needs (5 with enforce, 4 otherwise)." }
    CertificationAsset:
      type: object
      description: "The asset's declared identity, frozen when the certification was requested (decision 147). Watchtower never certifies that the recorded object is the declared one."
      properties:
        asset_kind: { type: string, enum: [otro, vivienda] }
        identifiers:
          type: array
          items:
            type: object
            properties:
              scheme: { type: string, enum: [address_unit, cadastral_ref, custom] }
              value: { type: string }
              details: { type: object }
              label: { type: [string, "null"] }
          description: Its active identifiers then, oldest first.
        commitment: { type: string, minLength: 64, maxLength: 64, description: "64 hex. sha256(salt ‖ canonical JSON of the identity): what its seal carries instead of the identifiers." }
    StatusFilter:
      type: string
      enum: [awaiting_recording, processing, verified, denied, pending]
      description: A Status, or pending, the dashboard's «Pendientes» (no outcome yet, awaiting_recording or processing).
    Buyer:
      type: object
      description: The buyer of a buyer's certification (null for a seller's).
      properties:
        email: { type: [string, "null"], description: The email the seller wrote (null only for a buyer's certification made before buyer emails existed). }
        linked: { type: boolean, description: "The buyer has opened this certification in the app with their account. Never says whether the email has an account." }
        notice:
          type: [object, "null"]
          properties:
            status: { type: string, enum: [pending, sending, sent, failed] }
            sent_at: { type: [string, "null"], format: date-time }
            resends: { type: integer, minimum: 0, maximum: 3 }
    Certification:
      type: object
      properties:
        id: { type: integer }
        public_id: { type: string, example: WT-7K3M9QX2 }
        product_id: { type: string }
        requested_by: { type: string, enum: [vendedor, comprador] }
        label: { type: [string, "null"] }
        status: { $ref: "#/components/schemas/Status" }
        verification_step:
          type: string
          enum: [pendiente, recibido, integridad_verificada, reto_leido, verificado, fallido]
          description: "reto_leido is deprecated (decision 137): only a take processed before it can show it."
        outcome: { type: [string, "null"], enum: [aprobada, denegada, null] }
        denial_reason:
          type: [string, "null"]
          enum: [sin_latidos, dispositivo_no_verificable, sospecha_de_edicion, tomas_rechazadas, calidad_insuficiente, grabacion_abandonada, no_enciende, fotogramas_no_seleccionados, null]
          description: |
            sin_latidos: the recording's continuity broke (possible edit). dispositivo_no_verificable: the device
            could not be attested. sospecha_de_edicion: a suspected pause or edit while recording (decision
            112.2). tomas_rechazadas: the certification's 10th take was rejected (decision 132.7; it has no accepted
            take; retryable). Historical, never given to a new denial: calidad_insuficiente (before decision 118: a blurred
            or dark take no longer denies; it is rejected with reject_reason calidad and recorded again),
            grabacion_abandonada (before decision 112.3: an abandoned take no longer denies, the certification
            waits for another take), no_enciende (before decision 91) and fotogramas_no_seleccionados (before
            decision 89).
        created_at: { type: string, format: date-time }
        verified_at: { type: [string, "null"], format: date-time, description: Date of the approval or the denial. }
        record_days: { type: integer, minimum: 1, maximum: 365 }
        record_by: { type: [string, "null"], format: date-time, description: Until when a take can start; after it, ask for another certification. }
        record_window_closed: { type: boolean }
        unverified_retake:
          type: boolean
          description: "Waiting for another take because we could not verify its newest one (its processing failed for good, or it was ended while its upload was being verified): record it again before record_by (decision 122). Its take stays uploaded, which does not mean it is still in progress."
        buyer: { oneOf: [{ $ref: "#/components/schemas/Buyer" }, { type: "null" }] }
        steps:
          type: array
          items: { $ref: "#/components/schemas/Step" }
          description: The guide steps copied from its step template (or inherited) when it was requested, for either role; empty without a script. In order, fixed from then on.
        script:
          oneOf: [{ $ref: "#/components/schemas/CertificationScript" }, { type: "null" }]
          description: The script frozen when it was requested (decision 148); null without one.
        dossier:
          description: The dossier it belongs to and its phase there (decision 149.9); null outside one.
          oneOf:
            - type: "null"
            - type: object
              properties:
                id: { type: integer }
                public_id: { type: string }
                phase: { type: string, enum: [entrada, salida] }
        attempt_number: { type: integer, minimum: 1, maximum: 3, description: Which attempt of its chain it is (1 unless it retries a denied one). }
        parent_id: { type: [integer, "null"], description: The denied certification it retries (null for the first attempt). }
        successor_id: { type: [integer, "null"], description: Its own retry, the next attempt, once retried. }
        retryable: { type: boolean, description: "Denied, not for sospecha_de_edicion, not retried yet, its page not expired, not the 3rd attempt and, in a dossier, the dossier open and no other certification holding its phase: POST /certifications/{id}/retry works." }
        attempts_remaining: { type: integer, minimum: 0, maximum: 2, description: Attempts its chain still allows after this one (3 - attempt_number). }
        view: { oneOf: [{ $ref: "#/components/schemas/View" }, { type: "null" }], description: Its page (/v/) once verified or denied; null before. }
    CertificationDetail:
      allOf:
        - $ref: "#/components/schemas/Certification"
        - type: object
          properties:
            asset:
              oneOf: [{ $ref: "#/components/schemas/CertificationAsset" }, { type: "null" }]
              description: The asset's identity declared when it was requested (decision 147); null for a certification of before it.
            takes:
              type: array
              items:
                type: object
                properties:
                  id: { type: integer }
                  status: { type: string, enum: [recording, uploaded, accepted, rejected, abandoned] }
                  reject_reason:
                    type: [string, "null"]
                    enum: [corte, reto, integridad, calidad, guion, null]
                    description: "corte: the recording was cut; reto: the challenge digits were not read in the image (decision 144), or the certification closed before this take was verified; integridad: the upload did not match; calidad: the video was too blurred or too dark to verify (decision 118); guion: the take did not follow its frozen script with enforce (a required step not marked, out of order, too short, or a step without its declaration; decision 148), decided at its finish. Each one is recorded again, with a new challenge."
                  started_at: { type: string, format: date-time }
                  ended_at: { type: [string, "null"], format: date-time }
                  accepted: { type: boolean }
                  recorded_by:
                    description: "The account whose session started the take (decision 150), not the person holding the phone; null for a take of before it was kept."
                    oneOf:
                      - type: "null"
                      - type: object
                        properties:
                          id: { type: integer }
                          email: { type: string }
                  challenge:
                    type: [string, "null"]
                    enum: [visible, no_ejecutado, null]
                    description: "The challenge number (decision 144): visible when the server read it in the image; no_ejecutado for a take of the transition that was not read; null without that check (never claimed as visible)."
                  script_check:
                    type: [string, "null"]
                    enum: [ok, fallo, null]
                    description: "Whether the take's markers and declarations follow its frozen script (decision 148), as declared by whoever recorded it; null when the take was not checked against one."
                  script_results:
                    type: array
                    description: "Each step as the take's finish declared it (decision 148.2), in order; [] for a take without a v5 finish. Declared by whoever recorded it, never certified: Watchtower does not certify the asset's condition."
                    items:
                      type: object
                      properties:
                        position: { type: integer, minimum: 1 }
                        key: { type: string, description: "The step's key in the frozen script (script.steps[].key)." }
                        status: { type: [string, "null"], enum: [ok, dano, no_aplica, no_comprobado, null] }
                        note: { type: [string, "null"] }
                        start_pts_ms: { type: [integer, "null"], description: "Where the step starts in the video, in milliseconds." }
                        end_pts_ms: { type: [integer, "null"] }
                  timestamps:
                    type: array
                    description: "Its RFC 3161 time-stamps (decision 152): of its finish (the commitment of the original's hash) and of its sealing; [] for a take of before them."
                    items:
                      type: object
                      properties:
                        purpose: { type: string, enum: [finish, seal] }
                        state: { type: string, enum: [comprometido, verificado, no_recibido], description: "comprometido: committed, the TSA token not back yet; verificado: the TSA stamped the committed hash at `at` (it says nothing of the file: only the accepted take was checked against it); no_recibido: the TSA gave no token after every attempt." }
                        at: { type: [string, "null"], format: date-time, description: "The TSA's time (genTime) once verificado." }
                        qualified: { type: boolean, description: "true only with a qualified provider (QTSP); without one the stamp is RFC 3161 not qualified." }
                        provider: { type: [string, "null"] }
            publication:
              description: Only for a seller's approved certification; null otherwise.
              oneOf:
                - type: "null"
                - type: object
                  properties:
                    video_public: { type: boolean }
                    frames: { type: array, items: { $ref: "#/components/schemas/Frame" } }
    View:
      type: object
      description: The certification's page, /v/WT-...-<token>, from its verification or denial for one year.
      properties:
        url: { type: string, format: uri }
        expires_at: { type: string, format: date-time }
        public: { type: boolean, description: true when it opens without the password. }
        public_mode_allowed: { type: boolean, description: Only a seller's approved certification can be public. }
        password: { type: string, description: The page's password, generated by Watchtower with the certification (it cannot be changed); share it with whoever should see the page. }
    Frame:
      type: object
      properties:
        frame_id: { type: integer }
        pts_ms: { type: integer, description: Time in the video, in milliseconds. }
        sealed: { type: boolean, description: Sealed with C2PA; the public page only shows sealed frames. }
    Publication:
      type: object
      properties:
        certification_id: { type: integer }
        video_public: { type: boolean }
        frames: { type: array, items: { $ref: "#/components/schemas/Frame" } }
        candidates_status: { type: string, enum: [ready, preparing] }
        candidates:
          type: array
          items:
            type: object
            properties:
              candidate_id: { type: integer }
              pts_ms: { type: integer }
              public: { type: boolean }
              frame_id: { type: [integer, "null"] }
              image_url: { type: string, format: uri }
        loose_frames:
          type: array
          items:
            allOf:
              - $ref: "#/components/schemas/Frame"
              - type: object
                properties:
                  public: { type: boolean }
        image_urls_expire_at: { type: string, format: date-time }
    Download:
      type: object
      properties:
        id: { type: integer }
        kind: { type: string, enum: [video, manifest, frame, playback], description: "playback: played on the public page (channel public_v), once per visit. frame: frame_id says which." }
        frame_id: { type: [integer, "null"], description: "The frame downloaded (kind frame); null otherwise." }
        channel: { type: string, enum: [api, dashboard, public_v], description: "public_v: the public page /v/." }
        actor:
          description: The API token or the user that downloaded it; null on the public page.
          oneOf:
            - type: "null"
            - type: object
              properties:
                type: { type: string, const: api_token }
                id: { type: integer }
                name: { type: string }
            - type: object
              properties:
                type: { type: string, const: user }
                id: { type: integer }
                email: { type: [string, "null"], description: "The dashboard user's email, while they are still a member of this seller; null once they left." }
        visitor_hash: { type: [string, "null"], description: "Only on the public page: the same for the same client and certification within a UTC day." }
        created_at: { type: string, format: date-time }
